diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml
new file mode 100644
index 0000000..dd3ca2e
--- /dev/null
+++ b/.github/workflows/build_deploy.yml
@@ -0,0 +1,42 @@
+name: Build and upload to PyPI
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ uses: ./.github/workflows/test.yml
+
+ deploy:
+ needs: test
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install build guidata babel
+
+ - name: Compile translations
+ run: |
+ python -m guidata.utils.translations compile --name moduletester --directory .
+
+ - name: Build package
+ run: python -m build
+
+ - name: Publish package
+ uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..7d8c7f0
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,40 @@
+name: Tests
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+ workflow_call:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ python-version: ["3.9", "3.13"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install .[dev]
+
+ - name: Lint with Ruff
+ run: ruff check --output-format=github moduletester
+
+ - name: Test with pytest
+ run: pytest -v --tb=long
diff --git a/.gitignore b/.gitignore
index 9a34176..52d5fe8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.env
winpython.env
.spyderproject
doc.zip
@@ -13,6 +14,10 @@ doc/install_requires.txt
doc/extras_require-dev.txt
doc/extras_require-doc.txt
*.bak
+*.moduletester
+tmp/*
+rtv/*
+dtv/*
# Created by https://www.gitignore.io/api/python
@@ -28,6 +33,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
+.venv/
build/
_build/
develop-eggs/
@@ -79,3 +85,9 @@ cdl/data/doc/
target/
/.spyproject
+
+# document generation
+*/rtv/*
+*/dtv/*
+moduletester/data/icons/*.py
+*codra*.docx
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..2a49e2b
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,9 @@
+repos:
+- repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.12.2
+ hooks:
+ # Run the linter.
+ - id: ruff-check
+ args: [ --fix ]
+ # Run the formatter.
+ - id: ruff-format
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c5797b3..18d7f52 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -20,7 +20,7 @@
],
"[python]": {
"editor.codeActionsOnSave": {
- "source.organizeImports": true
+ "source.organizeImports": "explicit"
},
"editor.defaultFormatter": "ms-python.black-formatter"
},
@@ -41,4 +41,12 @@
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
+ "svg.preview.background": "editor",
+ "python-envs.pythonProjects": [
+ {
+ "path": ".",
+ "envManager": "ms-python.python:venv",
+ "packageManager": "ms-python.python:pip"
+ }
+ ],
}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 252220f..ccdf3a1 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -4,137 +4,256 @@
"version": "2.0.0",
"tasks": [
{
- "label": "gettext - Scan",
+ "label": "๐งฝ Ruff Formatter",
"type": "shell",
- "command": "cmd",
+ "command": "${command:python.interpreterPath}",
"args": [
- "/c",
- "gettext_scan.bat"
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "ruff",
+ "format",
],
"options": {
- "cwd": "scripts",
- "env": {
- "UNATTENDED": "1",
- "PYTHON": "${env:PPSTACK_PYTHONEXE}"
- }
+ "cwd": "${workspaceFolder}",
+ "statusbar": {
+ "hide": true,
+ },
},
"group": {
"kind": "build",
- "isDefault": true
+ "isDefault": true,
},
"presentation": {
+ "clear": true,
"echo": true,
- "reveal": "always",
"focus": false,
- "panel": "shared",
+ "panel": "dedicated",
+ "reveal": "always",
"showReuseMessage": true,
- "clear": false
- }
+ },
},
{
- "label": "gettext - Compile",
+ "label": "๐ฆ Ruff Linter",
"type": "shell",
- "command": "cmd",
+ "command": "${command:python.interpreterPath}",
"args": [
- "/c",
- "gettext.bat",
- "compile"
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "ruff",
+ "check",
+ "--fix",
],
"options": {
- "cwd": "scripts",
- "env": {
- "UNATTENDED": "1",
- "PYTHON": "${env:PPSTACK_PYTHONEXE}"
- }
+ "cwd": "${workspaceFolder}",
+ "statusbar": {
+ "hide": true,
+ },
},
"group": {
"kind": "build",
- "isDefault": true
+ "isDefault": true,
},
"presentation": {
+ "clear": true,
"echo": true,
+ "focus": false,
+ "panel": "dedicated",
"reveal": "always",
+ "showReuseMessage": true,
+ },
+ },
+ {
+ "label": "๐งฝ๐ฆ Ruff",
+ "dependsOrder": "sequence",
+ "dependsOn": [
+ "๐งฝ Ruff Formatter",
+ "๐ฆ Ruff Linter",
+ ],
+ "group": {
+ "kind": "build",
+ "isDefault": false,
+ },
+ "presentation": {
+ "clear": true,
+ "echo": true,
"focus": false,
- "panel": "shared",
+ "panel": "dedicated",
+ "reveal": "always",
"showReuseMessage": true,
- "clear": false
- }
+ },
},
{
- "label": "Run Pylint",
+ "label": "๐ gettext - Scan",
"type": "shell",
- "command": "cmd",
+ "command": "${command:python.interpreterPath}",
"args": [
- "/c",
- "run_pylint.bat",
- "--disable=fixme",
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "guidata.utils.translations",
+ "scan",
+ "--name",
+ "moduletester",
+ "--directory",
+ ".",
+ "--languages",
+ "fr",
],
"options": {
- "cwd": "scripts",
- "env": {
- "UNATTENDED": "1",
- "PYTHON": "${env:PPSTACK_PYTHONEXE}"
- }
+ "cwd": "${workspaceFolder}",
},
"group": {
"kind": "build",
- "isDefault": true
+ "isDefault": false,
},
"presentation": {
+ "clear": true,
"echo": true,
- "reveal": "always",
"focus": false,
"panel": "dedicated",
+ "reveal": "always",
"showReuseMessage": true,
- "clear": true
- }
+ },
},
{
- "label": "Run Coverage",
+ "label": "๐ gettext - Compile",
"type": "shell",
- "command": "cmd",
+ "command": "${command:python.interpreterPath}",
"args": [
- "/c",
- "run_coverage.bat"
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "guidata.utils.translations",
+ "compile",
+ "--name",
+ "moduletester",
+ "--directory",
+ ".",
],
"options": {
- "cwd": "scripts",
- "env": {
- "UNATTENDED": "1",
- "PYTHON": "${env:PPSTACK_PYTHONEXE}"
- }
+ "cwd": "${workspaceFolder}",
},
"group": {
"kind": "build",
- "isDefault": true
+ "isDefault": false,
},
"presentation": {
+ "clear": true,
"echo": true,
+ "focus": false,
+ "panel": "dedicated",
"reveal": "always",
+ "showReuseMessage": true,
+ },
+ },
+ {
+ "label": "๐ฆ Pylint",
+ "type": "shell",
+ "command": "${command:python.interpreterPath}",
+ "args": [
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "pylint",
+ "moduletester",
+ "--disable=duplicate-code",
+ "--disable=fixme",
+ "--disable=too-many-arguments",
+ "--disable=too-many-branches",
+ "--disable=too-many-instance-attributes",
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}",
+ },
+ "group": {
+ "kind": "build",
+ "isDefault": true,
+ },
+ "presentation": {
+ "clear": true,
+ "echo": true,
"focus": false,
"panel": "dedicated",
+ "reveal": "always",
"showReuseMessage": true,
- "clear": true
- }
+ },
},
{
- "label": "Upgrade environment",
+ "label": "๐งช Coverage tests",
"type": "shell",
- "command": "cmd",
+ "command": "${command:python.interpreterPath}",
"args": [
- "/c",
- "upgrade_env.bat"
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "coverage",
+ "run",
+ "-m",
+ "pytest",
+ "moduletester",
],
"options": {
- "cwd": "scripts",
+ "cwd": "${workspaceFolder}",
+ "env": {
+ "COVERAGE_PROCESS_START": "${workspaceFolder}/.coveragerc",
+ },
+ "statusbar": {
+ "hide": true,
+ },
+ },
+ "group": {
+ "kind": "test",
+ "isDefault": true,
+ },
+ "presentation": {
+ "panel": "dedicated",
+ },
+ "problemMatcher": [],
+ },
+ {
+ "label": "๐ Coverage full",
+ "type": "shell",
+ "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine; if ($?) { ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html; if ($?) { start htmlcov\\index.html } }",
+ "options": {
+ "cwd": "${workspaceFolder}",
"env": {
- "UNATTENDED": "1",
- "PYTHON": "${env:PPSTACK_PYTHONEXE}"
- }
+ "COVERAGE_PROCESS_START": "${workspaceFolder}/.coveragerc",
+ },
+ },
+ "presentation": {
+ "panel": "dedicated",
+ },
+ "problemMatcher": [],
+ "dependsOrder": "sequence",
+ "dependsOn": [
+ "๐งช Coverage tests",
+ ],
+ },
+ {
+ "label": "Upgrade guidata",
+ "type": "shell",
+ "command": "${command:python.interpreterPath}",
+ "args": [
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "pip",
+ "install",
+ "--upgrade",
+ "pip",
+ "guidata",
+ ],
+ "options": {
+ "cwd": "${workspaceFolder}",
+ "statusbar": {
+ "hide": true,
+ },
},
"group": {
"kind": "build",
- "isDefault": true
+ "isDefault": true,
},
"presentation": {
"echo": true,
@@ -142,23 +261,25 @@
"focus": false,
"panel": "shared",
"showReuseMessage": true,
- "clear": false
- }
+ "clear": false,
+ },
},
{
- "label": "Clean Up",
+ "label": "๐งน Clean Up",
"type": "shell",
- "command": "cmd",
+ "command": "${command:python.interpreterPath}",
"args": [
- "/c",
- "clean_up.bat"
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "guidata.utils.cleanup",
],
"options": {
- "cwd": "scripts"
+ "cwd": "${workspaceFolder}",
},
"group": {
"kind": "build",
- "isDefault": true
+ "isDefault": true,
},
"presentation": {
"echo": true,
@@ -166,68 +287,106 @@
"focus": false,
"panel": "shared",
"showReuseMessage": true,
- "clear": false
- }
+ "clear": false,
+ },
},
{
- "label": "Build documentation",
+ "label": "๐ Build doc",
"type": "shell",
- "command": "cmd",
+ "command": "${command:python.interpreterPath}",
+ "args": [
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "sphinx",
+ "build",
+ "doc",
+ "${workspaceFolder}/build/doc",
+ "-b",
+ "singlehtml",
+ ],
"options": {
- "cwd": "scripts",
+ "cwd": "${workspaceFolder}",
"env": {
- "PYTHON": "${env:PPSTACK_PYTHONEXE}",
"QT_COLOR_MODE": "light",
- "UNATTENDED": "1"
- }
+ },
},
- "args": [
- "/c",
- "build_doc.bat"
- ],
- "problemMatcher": [],
"group": {
"kind": "build",
- "isDefault": true
+ "isDefault": true,
},
"presentation": {
+ "clear": true,
"echo": true,
- "reveal": "always",
"focus": false,
- "panel": "shared",
+ "panel": "dedicated",
+ "reveal": "always",
"showReuseMessage": true,
- "clear": true
- }
+ },
},
{
- "label": "Build Python packages",
+ "label": "๐ Open HTML doc",
"type": "shell",
- "command": "cmd",
+ "windows": {
+ "command": "start build/doc/index.html",
+ },
+ "linux": {
+ "command": "xdg-open build/doc/index.html",
+ },
+ "osx": {
+ "command": "open build/doc/index.html",
+ },
"options": {
- "cwd": "scripts",
- "env": {
- "PYTHON": "${env:PPSTACK_PYTHONEXE}",
- "UNATTENDED": "1"
- }
+ "cwd": "${workspaceFolder}",
},
+ "problemMatcher": [],
+ },
+ {
+ "label": "๐ฆ Build package",
+ "type": "shell",
+ "command": "${command:python.interpreterPath}",
"args": [
- "/c",
- "build_dist.bat"
+ "scripts/run_with_env.py",
+ "${command:python.interpreterPath}",
+ "-m",
+ "build",
],
+ "options": {
+ "cwd": "${workspaceFolder}",
+ },
+ "group": {
+ "kind": "build",
+ "isDefault": false,
+ },
+ "presentation": {
+ "clear": true,
+ "panel": "dedicated",
+ },
"problemMatcher": [],
+ "dependsOrder": "sequence",
+ "dependsOn": [
+ "๐งน Clean Up",
+ ],
+ },
+ {
+ "label": "โ Untracked files",
+ "type": "shell",
+ "command": "git ls-files --others | Where-Object { $_ -notmatch '^\\.' -and $_ -notmatch '^(build|dist|releases)/' -and $_ -notmatch '.(pyc|mo)$'}",
+ "options": {
+ "cwd": "${workspaceFolder}",
+ },
"group": {
"kind": "build",
- "isDefault": true
+ "isDefault": true,
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
- "panel": "shared",
+ "panel": "dedicated",
"showReuseMessage": true,
- "clear": true
+ "clear": true,
},
- "dependsOrder": "sequence",
},
- ]
+ ],
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d26039..b62cfe1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,51 @@
# ModuleTester Releases #
+## Version 1.0.0 ##
+
+First stable release of ModuleTester.
+
+### New features
+
+- Reworked GUI with dockable panels, collapsible CLI widget, and resizable
+ result/info panels
+- Tree view for test navigation with body/tree synchronization
+- Notification system for test execution output
+- Configuration editor widget with error handling and conflict resolution
+- Web engine view for test descriptions with forced reload and restricted
+ context menu
+- New Jinja2-based document exporter supporting HTML, DOCX, ODT, PDF, MD
+ and RST output formats
+- Test list export as a standalone document
+- Customizable CSS styling for exported documents (code blocks, inline
+ code spans)
+- Missing module handling with placeholder display
+- Bundled `pyqtspinner` widget (removed external dependency)
+
+### Bug fixes
+
+- Fixed processes not being able to be opened/closed on Linux
+- Fixed package-defined templates not applied on generated files
+- Fixed module not reloading correctly after code changes
+- Fixed command timeout handling in test execution
+- Fixed command line arguments parsing
+- Fixed encoding errors on test outputs (non-UTF8)
+- Fixed double-click run synchronization with the run button
+- Fixed partial name match causing unintended tests to run
+- Fixed `end_time` remaining `None` after test completion
+- Fixed `communicate()` returning `None` in edge cases
+- Fixed invalid tests returned by `get_tests()`
+- Fixed toolbar layout and typing issues
+- Fixed dark theme rendering on Windows 10
+- Fixed result combo box state while tests are running
+- Fixed RST conversion using `sys.prefix` for executable lookup
+
+### Improvements
+
+- Improved configuration system with new tools and better error handling
+- Consistent result enum casing between widgets
+- Removed dead code and debug prints
+- Pylint pass and type checking improvements
+
## Version 0.1.0 ##
-This is an experimental release of ModuleTester. It is not yet ready for
-production use.
+Experimental release. Not ready for production use.
diff --git a/babel.cfg b/babel.cfg
new file mode 100644
index 0000000..b46a78c
--- /dev/null
+++ b/babel.cfg
@@ -0,0 +1,4 @@
+# This file is used to configure Babel for the project.
+
+[python: **.py]
+encoding = utf-8
diff --git a/doc/changelog.rst b/doc/changelog.rst
new file mode 100644
index 0000000..3a34dc5
--- /dev/null
+++ b/doc/changelog.rst
@@ -0,0 +1,14 @@
+Changelog
+=========
+
+See the full changelog on `GitHub `_.
+
+
+Version 1.0.0
+-------------
+
+First public release of ModuleTester.
+
+New features, bug fixes, and improvements โ see the
+`CHANGELOG.md `_
+for the complete list.
diff --git a/doc/conf.py b/doc/conf.py
index 913bc05..7ff14c4 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -13,13 +13,18 @@
project = "ModuleTester"
author = "Pierre Raybaut"
-copyright = "2023, Codra - " + author
+copyright = "2023-2026, Codra - " + author
html_logo = latex_logo = "_static/ModuleTester-title.png"
release = moduletester.__version__
# -- General configuration ---------------------------------------------------
-extensions = ["sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.mathjax"]
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.napoleon",
+ "sphinx.ext.mathjax",
+]
templates_path = ["_templates"]
exclude_patterns = []
diff --git a/doc/images/shots/empty.png b/doc/images/shots/empty.png
index 4899d68..43c87e5 100644
Binary files a/doc/images/shots/empty.png and b/doc/images/shots/empty.png differ
diff --git a/doc/images/shots/guidata.moduletester.png b/doc/images/shots/guidata.moduletester.png
index 96cde31..6b641c9 100644
Binary files a/doc/images/shots/guidata.moduletester.png and b/doc/images/shots/guidata.moduletester.png differ
diff --git a/doc/index.rst b/doc/index.rst
index 1f3a478..b7a499a 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -16,8 +16,8 @@ ModuleTester is a spin-off of the `DataLab`_ project, mainly used to test
:align: center
:width: 300 px
- ModuleTester is powered by `PlotPyStack `_,
- the scientific Python-Qt visualization and graphical user interface stack.
+ModuleTester is powered by `PlotPyStack `_,
+the scientific Python-Qt visualization and graphical user interface stack.
.. note:: ModuleTester was created by `Codra`_ in 2023. It is developed and
maintained by ModuleTester open-source project team
@@ -39,16 +39,16 @@ External resources:
installation
usage
example
+ changelog
Copyrights and licensing
------------------------
-- Copyright ยฉ 2023 `Codra`_
+- Copyright ยฉ 2023-2026 `Codra`_
- Licensed under the terms of the `BSD 3-Clause`_
-.. _PlotPyStack: https://github.com/PlotPyStack
.. _guidata: https://pypi.python.org/pypi/guidata
.. _PyPI: https://pypi.python.org/pypi/ModuleTester
.. _GitHub: https://github.com/Codra-Ingenierie-Informatique/ModuleTester
.. _Codra: https://codra.net/
-.. _BSD 3-Clause: https://github.com/Codra-Ingenierie-Informatique/DataLab/blob/master/LICENSE
+.. _BSD 3-Clause: https://github.com/Codra-Ingenierie-Informatique/ModuleTester/blob/main/LICENSE
diff --git a/doc/installation.rst b/doc/installation.rst
index 7e3403e..f6b5af3 100644
--- a/doc/installation.rst
+++ b/doc/installation.rst
@@ -8,27 +8,61 @@ Dependencies
.. note::
- Python 3.11 and PyQt5 are the reference for production release
+ Python 3.9+ is required. PyQt5 or PySide2 can be used as Qt binding
+ (through `QtPy `_).
How to install
--------------
-Wheel package:
-^^^^^^^^^^^^^^
+From PyPI:
+^^^^^^^^^^
-On any operating system, using pip and the Wheel package is the easiest way to
-install ModuleTester on an existing Python distribution:
+The easiest way to install ModuleTester is from PyPI:
.. code-block:: console
- $ pip install --upgrade ModuleTester-1.0.0-py2.py3-none-any.whl
+ $ pip install ModuleTester
+From a wheel package:
+^^^^^^^^^^^^^^^^^^^^^
-Source package:
-^^^^^^^^^^^^^^^
+On any operating system, using pip and the Wheel package:
-Installing ModuleTester directly from the source package is straigthforward:
+.. code-block:: console
+
+ $ pip install --upgrade moduletester-1.0.0-py3-none-any.whl
+
+Pandoc (optional):
+^^^^^^^^^^^^^^^^^^
+
+ModuleTester uses Pandoc and PyPandoc bindings to generate documents and display test
+descriptions. You can get `Pandoc `_ from
+`here `_ or by executing the following python code
+(all instructions are available on the
+`PyPandoc documentation `_).
+
+.. code-block:: python
+
+ import pypandoc
+ from pypandoc.pandoc_download import download_pandoc
+ # see the documentation how to customize the installation path
+ # but be aware that you then need to include it in the `PATH`
+ download_pandoc()
+ # check the install path with
+ print(pypandoc.get_pandoc_path())
+
+
+From source:
+^^^^^^^^^^^^
+
+Installing ModuleTester directly from the source package is straightforward:
+
+.. code-block:: console
+
+ $ pip install .
+
+Or to build a distribution package:
.. code-block:: console
diff --git a/doc/locale/fr/LC_MESSAGES/example.po b/doc/locale/fr/LC_MESSAGES/example.po
new file mode 100644
index 0000000..48b2bc6
--- /dev/null
+++ b/doc/locale/fr/LC_MESSAGES/example.po
@@ -0,0 +1,34 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2023, Codra - Pierre Raybaut
+# This file is distributed under the same license as the ModuleTester
+# package.
+# FIRST AUTHOR , 2024.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: ModuleTester \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-01-24 18:08+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language: fr\n"
+"Language-Team: fr \n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.14.0\n"
+
+#: ../../example.rst:2
+msgid "Example"
+msgstr ""
+
+#: ../../example.rst:7
+msgid "Running ModuleTester lead to an empty window"
+msgstr ""
+
+#: ../../example.rst:12
+msgid "Using ModuleTester on `guidata` Python package"
+msgstr ""
+
diff --git a/doc/locale/fr/LC_MESSAGES/index.po b/doc/locale/fr/LC_MESSAGES/index.po
new file mode 100644
index 0000000..1902db4
--- /dev/null
+++ b/doc/locale/fr/LC_MESSAGES/index.po
@@ -0,0 +1,86 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2023, Codra - Pierre Raybaut
+# This file is distributed under the same license as the ModuleTester
+# package.
+# FIRST AUTHOR , 2024.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: ModuleTester \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-02-29 16:45+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language: fr\n"
+"Language-Team: fr \n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.14.0\n"
+
+#: ../../index.rst:35
+msgid "Contents:"
+msgstr ""
+
+#: ../../index.rst:2
+msgid "ModuleTester User Guide"
+msgstr ""
+
+#: ../../index.rst:4
+msgid "ModuleTester is a library for executing and managing Python package tests."
+msgstr ""
+
+#: ../../index.rst:9
+msgid ""
+"ModuleTester is a spin-off of the `DataLab`_ project, mainly used to test"
+" `PlotPyStack`_ libraries."
+msgstr ""
+
+#: ../../index.rst:19
+msgid ""
+"ModuleTester is powered by `PlotPyStack "
+"`_, the scientific Python-Qt "
+"visualization and graphical user interface stack."
+msgstr ""
+
+#: ../../index.rst:22
+msgid ""
+"ModuleTester was created by `Codra`_ in 2023. It is developed and "
+"maintained by ModuleTester open-source project team with the support of "
+"`Codra`_."
+msgstr ""
+
+#: ../../index.rst:33
+msgid "External resources:"
+msgstr ""
+
+#: ../../index.rst:30
+msgid "`GitHub`_"
+msgstr ""
+
+#: ../../index.rst:31
+msgid "Project home page"
+msgstr ""
+
+#: ../../index.rst:32
+msgid "`PyPI`_"
+msgstr ""
+
+#: ../../index.rst:33
+msgid "Python Package Index"
+msgstr ""
+
+#: ../../index.rst:44
+msgid "Copyrights and licensing"
+msgstr ""
+
+#: ../../index.rst:46
+msgid "Copyright ยฉ 2023 `Codra`_"
+msgstr ""
+
+#: ../../index.rst:47
+msgid "Licensed under the terms of the `BSD 3-Clause`_"
+msgstr ""
+
diff --git a/doc/locale/fr/LC_MESSAGES/installation.po b/doc/locale/fr/LC_MESSAGES/installation.po
new file mode 100644
index 0000000..8cb12aa
--- /dev/null
+++ b/doc/locale/fr/LC_MESSAGES/installation.po
@@ -0,0 +1,235 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2023, Codra - Pierre Raybaut
+# This file is distributed under the same license as the ModuleTester
+# package.
+# FIRST AUTHOR , 2024.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: ModuleTester \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-03-13 16:12+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language: fr\n"
+"Language-Team: fr \n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.14.0\n"
+
+#: ../../installation.rst:2
+msgid "Installation"
+msgstr ""
+
+#: ../../installation.rst:5
+msgid "Dependencies"
+msgstr ""
+
+#: ../../requirements.rst:1
+msgid "The :mod:`moduletester` package requires the following Python modules:"
+msgstr ""
+
+#: ../../requirements.rst:7 ../../requirements.rst:44 ../../requirements.rst:66
+msgid "Name"
+msgstr ""
+
+#: ../../requirements.rst:8 ../../requirements.rst:45 ../../requirements.rst:67
+msgid "Version"
+msgstr ""
+
+#: ../../requirements.rst:9 ../../requirements.rst:46 ../../requirements.rst:68
+msgid "Summary"
+msgstr ""
+
+#: ../../requirements.rst:10
+msgid "Python"
+msgstr ""
+
+#: ../../requirements.rst:11
+msgid ">=3.8, <4"
+msgstr ""
+
+#: ../../requirements.rst:13
+msgid "guidata"
+msgstr ""
+
+#: ../../requirements.rst:14
+msgid ">= 3.3"
+msgstr ""
+
+#: ../../requirements.rst:16
+msgid "QtPy"
+msgstr ""
+
+#: ../../requirements.rst:17
+msgid ">= 1.9"
+msgstr ""
+
+#: ../../requirements.rst:19
+msgid "click"
+msgstr ""
+
+#: ../../requirements.rst:21
+msgid "Composable command line interface toolkit"
+msgstr ""
+
+#: ../../requirements.rst:22
+msgid "pyqtwebengine"
+msgstr ""
+
+#: ../../requirements.rst:24
+msgid "Python bindings for the Qt WebEngine framework"
+msgstr ""
+
+#: ../../requirements.rst:25
+msgid "pypandoc"
+msgstr ""
+
+#: ../../requirements.rst:27
+msgid "Thin wrapper for pandoc."
+msgstr ""
+
+#: ../../requirements.rst:28
+msgid "jinja2"
+msgstr ""
+
+#: ../../requirements.rst:30
+msgid "A very fast and expressive template engine."
+msgstr ""
+
+#: ../../requirements.rst:31
+msgid "beautifulsoup4"
+msgstr ""
+
+#: ../../requirements.rst:33
+msgid "Screen-scraping library"
+msgstr ""
+
+#: ../../requirements.rst:34 ../../requirements.rst:69
+msgid "PyQt5"
+msgstr ""
+
+#: ../../requirements.rst:35
+msgid ">=5.11"
+msgstr ""
+
+#: ../../requirements.rst:36 ../../requirements.rst:71
+msgid "Python bindings for the Qt cross platform application toolkit"
+msgstr ""
+
+#: ../../requirements.rst:38
+msgid "Optional modules for development:"
+msgstr ""
+
+#: ../../requirements.rst:47
+msgid "black"
+msgstr ""
+
+#: ../../requirements.rst:49
+msgid "The uncompromising code formatter."
+msgstr ""
+
+#: ../../requirements.rst:50
+msgid "isort"
+msgstr ""
+
+#: ../../requirements.rst:52
+msgid "A Python utility / library to sort Python imports."
+msgstr ""
+
+#: ../../requirements.rst:53
+msgid "pylint"
+msgstr ""
+
+#: ../../requirements.rst:55
+msgid "python code static checker"
+msgstr ""
+
+#: ../../requirements.rst:56
+msgid "Coverage"
+msgstr ""
+
+#: ../../requirements.rst:58
+msgid "Code coverage measurement for Python"
+msgstr ""
+
+#: ../../requirements.rst:60
+msgid "Optional modules for building the documentation:"
+msgstr ""
+
+#: ../../requirements.rst:72
+msgid "sphinx"
+msgstr ""
+
+#: ../../requirements.rst:73
+msgid ">6"
+msgstr ""
+
+#: ../../requirements.rst:74
+msgid "Python documentation generator"
+msgstr ""
+
+#: ../../requirements.rst:75
+msgid "pydata_sphinx_theme"
+msgstr ""
+
+#: ../../requirements.rst:77
+msgid "Bootstrap-based Sphinx theme from the PyData community"
+msgstr ""
+
+#: ../../installation.rst:11
+msgid "Python 3.11 and PyQt5 are the reference for production release"
+msgstr ""
+
+#: ../../installation.rst:15
+msgid "How to install"
+msgstr ""
+
+#: ../../installation.rst:18
+msgid "Wheel package:"
+msgstr ""
+
+#: ../../installation.rst:20
+msgid ""
+"On any operating system, using pip and the Wheel package is the easiest "
+"way to install ModuleTester on an existing Python distribution:"
+msgstr ""
+
+#: ../../installation.rst:27
+msgid ""
+"ModuleTester uses Pandoc and PyPandoc bindings to generate documents and "
+"display test descriptions. You can get `Pandoc "
+"`_ from `here "
+"`_ or by executing the following "
+"python code ( All instructions are available on the `PyPandoc "
+"documentation `_)."
+msgstr ""
+
+#: ../../installation.rst:46
+msgid "Source package:"
+msgstr ""
+
+#: ../../installation.rst:48
+msgid ""
+"Installing ModuleTester directly from the source package is "
+"straigthforward:"
+msgstr ""
+
+#~ msgid "pyqtspinner"
+#~ msgstr ""
+
+#~ msgid ">= 3.1"
+#~ msgstr ""
+
+#~ msgid "beautifulsoup4"
+#~ msgstr ""
+
+#~ msgid "Screen-scraping library"
+#~ msgstr ""
+
+#~ msgid "Python bindings for the Qt WebEngine library"
+#~ msgstr ""
+
diff --git a/doc/locale/fr/LC_MESSAGES/requirements.po b/doc/locale/fr/LC_MESSAGES/requirements.po
new file mode 100644
index 0000000..2ffe0ca
--- /dev/null
+++ b/doc/locale/fr/LC_MESSAGES/requirements.po
@@ -0,0 +1,189 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2023, Codra - Pierre Raybaut
+# This file is distributed under the same license as the ModuleTester
+# package.
+# FIRST AUTHOR , 2024.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: ModuleTester \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-03-13 16:12+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language: fr\n"
+"Language-Team: fr \n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.14.0\n"
+
+#: ../../requirements.rst:1
+msgid "The :mod:`moduletester` package requires the following Python modules:"
+msgstr ""
+
+#: ../../requirements.rst:7 ../../requirements.rst:44 ../../requirements.rst:66
+msgid "Name"
+msgstr ""
+
+#: ../../requirements.rst:8 ../../requirements.rst:45 ../../requirements.rst:67
+msgid "Version"
+msgstr ""
+
+#: ../../requirements.rst:9 ../../requirements.rst:46 ../../requirements.rst:68
+msgid "Summary"
+msgstr ""
+
+#: ../../requirements.rst:10
+msgid "Python"
+msgstr ""
+
+#: ../../requirements.rst:11
+msgid ">=3.8, <4"
+msgstr ""
+
+#: ../../requirements.rst:13
+msgid "guidata"
+msgstr ""
+
+#: ../../requirements.rst:14
+msgid ">= 3.3"
+msgstr ""
+
+#: ../../requirements.rst:16
+msgid "QtPy"
+msgstr ""
+
+#: ../../requirements.rst:17
+msgid ">= 1.9"
+msgstr ""
+
+#: ../../requirements.rst:19
+msgid "click"
+msgstr ""
+
+#: ../../requirements.rst:21
+msgid "Composable command line interface toolkit"
+msgstr ""
+
+#: ../../requirements.rst:22
+msgid "pyqtwebengine"
+msgstr ""
+
+#: ../../requirements.rst:24
+msgid "Python bindings for the Qt WebEngine framework"
+msgstr ""
+
+#: ../../requirements.rst:25
+msgid "pypandoc"
+msgstr ""
+
+#: ../../requirements.rst:27
+msgid "Thin wrapper for pandoc."
+msgstr ""
+
+#: ../../requirements.rst:28
+msgid "jinja2"
+msgstr ""
+
+#: ../../requirements.rst:30
+msgid "A very fast and expressive template engine."
+msgstr ""
+
+#: ../../requirements.rst:31
+msgid "beautifulsoup4"
+msgstr ""
+
+#: ../../requirements.rst:33
+msgid "Screen-scraping library"
+msgstr ""
+
+#: ../../requirements.rst:34 ../../requirements.rst:69
+msgid "PyQt5"
+msgstr ""
+
+#: ../../requirements.rst:35
+msgid ">=5.11"
+msgstr ""
+
+#: ../../requirements.rst:36 ../../requirements.rst:71
+msgid "Python bindings for the Qt cross platform application toolkit"
+msgstr ""
+
+#: ../../requirements.rst:38
+msgid "Optional modules for development:"
+msgstr ""
+
+#: ../../requirements.rst:47
+msgid "black"
+msgstr ""
+
+#: ../../requirements.rst:49
+msgid "The uncompromising code formatter."
+msgstr ""
+
+#: ../../requirements.rst:50
+msgid "isort"
+msgstr ""
+
+#: ../../requirements.rst:52
+msgid "A Python utility / library to sort Python imports."
+msgstr ""
+
+#: ../../requirements.rst:53
+msgid "pylint"
+msgstr ""
+
+#: ../../requirements.rst:55
+msgid "python code static checker"
+msgstr ""
+
+#: ../../requirements.rst:56
+msgid "Coverage"
+msgstr ""
+
+#: ../../requirements.rst:58
+msgid "Code coverage measurement for Python"
+msgstr ""
+
+#: ../../requirements.rst:60
+msgid "Optional modules for building the documentation:"
+msgstr ""
+
+#: ../../requirements.rst:72
+msgid "sphinx"
+msgstr ""
+
+#: ../../requirements.rst:73
+msgid ">6"
+msgstr ""
+
+#: ../../requirements.rst:74
+msgid "Python documentation generator"
+msgstr ""
+
+#: ../../requirements.rst:75
+msgid "pydata_sphinx_theme"
+msgstr ""
+
+#: ../../requirements.rst:77
+msgid "Bootstrap-based Sphinx theme from the PyData community"
+msgstr ""
+
+#~ msgid "pyqtspinner"
+#~ msgstr ""
+
+#~ msgid ">= 3.1"
+#~ msgstr ""
+
+#~ msgid "beautifulsoup4"
+#~ msgstr ""
+
+#~ msgid "Screen-scraping library"
+#~ msgstr ""
+
+#~ msgid "Python bindings for the Qt WebEngine library"
+#~ msgstr ""
+
diff --git a/doc/locale/fr/LC_MESSAGES/usage.po b/doc/locale/fr/LC_MESSAGES/usage.po
new file mode 100644
index 0000000..653a184
--- /dev/null
+++ b/doc/locale/fr/LC_MESSAGES/usage.po
@@ -0,0 +1,30 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2023, Codra - Pierre Raybaut
+# This file is distributed under the same license as the ModuleTester
+# package.
+# FIRST AUTHOR , 2024.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: ModuleTester \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-01-24 18:08+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language: fr\n"
+"Language-Team: fr \n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.14.0\n"
+
+#: ../../usage.rst:2
+msgid "Usage"
+msgstr ""
+
+#: ../../usage.rst:6
+msgid "Work in progress."
+msgstr ""
+
diff --git a/doc/requirements.rst b/doc/requirements.rst
index 80dec21..ae4ca2d 100644
--- a/doc/requirements.rst
+++ b/doc/requirements.rst
@@ -1,4 +1,4 @@
-The :mod:`moduletester` package requires the following Python modules:
+The `ModuleTester` package requires the following Python modules:
.. list-table::
:header-rows: 1
@@ -8,20 +8,26 @@ The :mod:`moduletester` package requires the following Python modules:
- Version
- Summary
* - Python
- - >=3.8, <4
+ - >=3.9, <4
+ - Python programming language
+ * - guidata
+ - >= 3.14
+ - Automatic GUI generation for easy dataset editing and display
+ * - QtPy
+ - >= 1.9
+ - Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6).
+ * - pyqtwebengine
-
- * - guidata
- - >= 3.1
+ - Python bindings for the Qt WebEngine framework
+ * - pypandoc
-
- * - QtPy
- - >= 1.9
+ - Thin wrapper for pandoc.
+ * - jinja2
-
+ - A very fast and expressive template engine.
* - beautifulsoup4
-
- Screen-scraping library
- * - PyQt5
- - >=5.11
- - Python bindings for the Qt cross platform application toolkit
Optional modules for development:
@@ -32,18 +38,21 @@ Optional modules for development:
* - Name
- Version
- Summary
- * - black
+ * - ruff
-
- - The uncompromising code formatter.
- * - isort
- -
- - A Python utility / library to sort Python imports.
+ - An extremely fast Python linter and code formatter, written in Rust.
* - pylint
-
- python code static checker
+ * - pytest
+ -
+ - pytest: simple powerful testing with Python
* - Coverage
-
- Code coverage measurement for Python
+ * - build
+ -
+ - A simple, correct Python build frontend
Optional modules for building the documentation:
diff --git a/doc/update_requirements.py b/doc/update_requirements.py
index fdefd02..f572be9 100644
--- a/doc/update_requirements.py
+++ b/doc/update_requirements.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
-"""Update requirements.rst file from pyproject.toml or setup.cfg file
+"""Update requirements.rst file from pyproject.toml file.
Warning: this has to be done manually at release time.
It is not done automatically by the sphinx 'conf.py' file because it
@@ -9,11 +9,14 @@
without internet connection like the Debian package management infrastructure).
"""
-from guidata.utils.genreqs import gen_module_req_rst # noqa: E402
+import os
-import moduletester
+from guidata.utils.genreqs import generate_requirements_rst # noqa: E402
if __name__ == "__main__":
print("Updating requirements.rst file...", end=" ")
- gen_module_req_rst(moduletester, ["Python>=3.8", "PyQt5>=5.11"])
+ root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ pyproject_path = os.path.join(root_dir, "pyproject.toml")
+ doc_dir = os.path.join(root_dir, "doc")
+ generate_requirements_rst(pyproject_path, doc_dir)
print("done.")
diff --git a/doc/usage.rst b/doc/usage.rst
index 023590f..913b012 100644
--- a/doc/usage.rst
+++ b/doc/usage.rst
@@ -1,6 +1,60 @@
Usage
=====
-.. note::
+ModuleTester can be used either as a GUI application or as a command-line tool.
- Work in progress.
\ No newline at end of file
+
+Graphical User Interface
+------------------------
+
+To launch the graphical interface:
+
+.. code-block:: console
+
+ $ moduletester
+
+Or from Python:
+
+.. code-block:: python
+
+ from moduletester.gui.main import run_gui
+ run_gui()
+
+You can also open a project file or target a specific Python package directly:
+
+.. code-block:: console
+
+ $ moduletester --path /path/to/project.mtf
+ $ moduletester --module mypackage
+
+
+Command-Line Interface
+----------------------
+
+ModuleTester provides a CLI for running tests without the GUI:
+
+.. code-block:: console
+
+ $ moduletester-cli --help
+
+Run all tests of a Python package:
+
+.. code-block:: console
+
+ $ moduletester-cli run mypackage
+
+Export results to an HTML report:
+
+.. code-block:: console
+
+ $ moduletester-cli export mypackage --output report.html
+
+
+Configuration
+-------------
+
+ModuleTester stores its configuration in a user-specific directory
+managed by `guidata `_.
+
+The configuration can be edited from the GUI via the **Settings** menu
+or by editing the configuration file directly through the built-in editor.
\ No newline at end of file
diff --git a/moduletester/__init__.py b/moduletester/__init__.py
index a5bd1f2..3c49739 100644
--- a/moduletester/__init__.py
+++ b/moduletester/__init__.py
@@ -16,7 +16,7 @@
.. _PlotPyStack: https://github.com/PlotPyStack
"""
-__version__ = "0.1.0"
+__version__ = "1.0.0"
__docurl__ = "https://moduletester.readthedocs.io/en/latest/"
__homeurl__ = "https://codra-ingenierie-informatique.github.io/moduletester/"
__supporturl__ = (
diff --git a/moduletester/config.py b/moduletester/config.py
index db80855..dbfaf13 100644
--- a/moduletester/config.py
+++ b/moduletester/config.py
@@ -1,12 +1,387 @@
-# -*- coding: utf-8 -*-
+"""Configuration management for ModuleTester."""
+from __future__ import annotations
+
+import configparser
+import os
import os.path as osp
+from dataclasses import dataclass, field, fields
+from typing import Any, Collection, TypedDict
from guidata import configtools
+from guidata.configtools import get_translation
APP_NAME = "ModuleTester"
MOD_NAME = "moduletester"
+MODULETESTER_CONFIG_NAME = "moduletester.ini"
+MODULETESTER_CONFIG_DIR = os.path.dirname(__file__)
configtools.add_image_module_path(MOD_NAME, osp.join("data", "logo"))
+configtools.add_image_module_path(MOD_NAME, osp.join("data", "icons"))
DATAPATH = configtools.get_module_data_path(MOD_NAME, "data")
+
+_ = get_translation(MOD_NAME)
+
+
+class InvalidDataError(Exception):
+ """Exception raised for invalid data in configuration file.
+
+ Args:
+ message: Explanation of the error
+ key: The key of the invalid data
+ value: The invalid value
+ """
+
+ def __init__(self, message: str, key: str, value: Any) -> None:
+ super().__init__(message)
+ self.key = key
+ self.value = value
+
+
+class InvalidPathError(InvalidDataError):
+ """Exception raised for invalid path in configuration file."""
+
+ pass
+
+
+class ConfigConflictError(Exception):
+ """Exception raised for conflicting configuration arguments.
+
+ Args:
+ message: Explanation of the error
+ missing_args: The missing arguments
+ extra_args: The extra arguments
+ """
+
+ def __init__(
+ self, message: str, missing_args: Collection[str], extra_args: Collection[str]
+ ):
+ super().__init__(message)
+ self.missing_args = missing_args
+ self.extra_args = extra_args
+
+
+def _validate_path(path: str, key, value) -> str:
+ """Validate a path.
+
+ Args:
+ path: path to validate
+ key: key of the path
+ value: value of the path
+
+ Raises:
+ InvalidPathError: If the path is not valid
+
+ Returns:
+ returns the path if it is valid
+ """
+ if not osp.exists(path):
+ raise InvalidPathError(f"Path {path} is not valid", key, value)
+ return path
+
+
+def _serialize_field(value: Any) -> str:
+ """Serialize a field value to a string.
+
+ Args:
+ value: value to serialize
+
+ Returns:
+ serialized value
+ """
+ if isinstance(value, (list, tuple)):
+ return ", ".join(map(_serialize_field, value))
+ if isinstance(value, bool):
+ return str(int(value))
+ return str(value)
+
+
+def _check_section(conf_dataclass, **kwargs) -> tuple[set[str], set[str]]:
+ """Check if the section arguments are valid.
+
+ Args:
+ conf_dataclass: the dataclass of the section
+
+ Returns:
+ missing_args: the missing arguments
+ extra_args: the extra arguments
+ """
+ if len(kwargs) == 0:
+ return set(), set()
+
+ field_set = set(map(lambda f: f.name, fields(conf_dataclass)))
+ arg_set = set(kwargs.keys())
+
+ missing_args = field_set - arg_set
+ extra_args = arg_set - field_set
+ return missing_args, extra_args
+
+
+def _resolve_conflicts(
+ conf_dataclass,
+ section: configparser.SectionProxy,
+ missing_args: Collection[str],
+ extra_args: Collection[str],
+) -> configparser.SectionProxy:
+ """Try to resolve conflicting arguments in a section.
+
+ Args:
+ conf_dataclass: configuration dataclass
+ section: Configparser section to resolve
+ missing_args: missing configuration fields in file
+ extra_args: extra configuration fields in file
+
+ Returns:
+ Resolved section
+ """
+ if len(missing_args) > 0:
+ for arg in missing_args:
+ default_value = getattr(conf_dataclass, arg)
+ section[arg] = _serialize_field(default_value)
+
+ if len(extra_args) > 0:
+ for arg in extra_args:
+ del section[arg]
+
+ return section
+
+
+def _load_conf(config: configparser.ConfigParser, resolve=False) -> None:
+ """Load the configuration from a ConfigParser object.
+
+ Args:
+ config: ConfigParser object
+ resolve: If True, tries to resolve conflicts in the configuration
+ """
+ for section_name, section_obj in PACKAGE_CONF.items():
+ # type: ignore
+ section_values: configparser.SectionProxy = config.setdefault(section_name, {})
+ missing_args, extra_args = _check_section(section_obj, **section_values)
+ if len(missing_args) > 0 or len(extra_args) > 0:
+ if resolve:
+ section_values = _resolve_conflicts(
+ section_obj, section_values, missing_args, extra_args
+ )
+ else:
+ raise ConfigConflictError(
+ f"Conflicting arguments in section {section_name}",
+ missing_args,
+ extra_args,
+ )
+ PACKAGE_CONF[section_name] = type(section_obj)(
+ **section_values
+ ) # reset section
+
+
+def load_package_conf(
+ package_path: str, filename=MODULETESTER_CONFIG_NAME, resolve=False
+) -> None:
+ """Tries to load the configuration from a file.
+
+ Args:
+ package_path: path to the package where the configuration file should be located
+ filename: File name to search in package directory. Defaults to
+ MODULETESTER_CONFIG_NAME.
+ resolve: Try to resolve confilcts if some are found. Defaults to False.
+ """
+ global MODULETESTER_CONFIG_DIR
+ custom_config_file = os.path.join(package_path, filename)
+
+ config = configparser.ConfigParser()
+ MODULETESTER_CONFIG_DIR = os.path.abspath(package_path)
+ if os.path.isfile(custom_config_file):
+ config.read(custom_config_file)
+
+ _load_conf(config, resolve)
+
+
+def load_conf_from_string(conf_str: str, resolve=False) -> None:
+ """Load the configuration from a string.
+
+ Args:
+ conf_str: Configuration string
+ resolve: Try to resolve conflicts if some are found. Defaults to False.
+ """
+ config = configparser.ConfigParser()
+ config.read_string(conf_str)
+ _load_conf(config, resolve)
+
+
+@dataclass
+class GeneralConf:
+ """Dataclass for general moduletester parameters"""
+
+ docstring_fmt: str = "rst"
+ category: str = "visible"
+
+
+@dataclass
+class ExporterConf:
+ """Dataclass for moduletester exporter parameters"""
+
+ template_dir: str = os.path.join(MODULETESTER_CONFIG_DIR, "default_templates")
+ test_results_template_name: str = "test_results_template.j2"
+ test_list_template_name: str = "test_list_template.j2"
+ docx_reference: str = "custom-reference.docx"
+ odt_reference: str = "custom-reference.odt"
+ css_style: str = "default_style.css"
+ export_fmts: list[str] = field(default_factory=lambda: ["html", "docx"])
+ reload_templates_on_export: bool = False
+ docstrings_header_shift: int = 3
+ toc_depth: int = 2
+
+ def __post_init__(self):
+ self.template_dir = _validate_path(
+ self.get_template_dir(), key="template_dir", value=self.template_dir
+ )
+ _ = _validate_path(
+ self.get_docx_ref(), key="docx_reference", value=self.docx_reference
+ )
+ _ = _validate_path(
+ self.get_odt_ref(), key="odt_reference", value=self.odt_reference
+ )
+ _ = _validate_path(self.get_css_style(), key="css_style", value=self.css_style)
+
+ _ = _validate_path(
+ osp.join(self.template_dir, self.test_results_template_name),
+ key="test_results_template_name",
+ value=self.test_results_template_name,
+ )
+ _ = _validate_path(
+ osp.join(self.template_dir, self.test_list_template_name),
+ key="dv_template_name",
+ value=self.test_list_template_name,
+ )
+ self.reload_templates_on_export = bool(int(self.reload_templates_on_export))
+ self.docstrings_header_shift = int(self.docstrings_header_shift)
+ self.toc_depth = int(self.toc_depth)
+
+ export_fmts = self.export_fmts
+ if isinstance(export_fmts, str):
+ export_fmts = export_fmts.replace(" ", "").split(",")
+
+ self.export_fmts = export_fmts
+
+ def get_template_dir(self) -> str:
+ """Return the absolute path to the template directory."""
+ return os.path.join(MODULETESTER_CONFIG_DIR, self.template_dir)
+
+ def get_docx_ref(self) -> str:
+ """Return the path to the DOCX reference template."""
+ return osp.join(self.template_dir, self.docx_reference)
+
+ def get_odt_ref(self) -> str:
+ """Return the path to the ODT reference template."""
+ return osp.join(self.template_dir, self.odt_reference)
+
+ def get_css_style(self) -> str:
+ """Return the path to the CSS style file."""
+ return osp.join(self.template_dir, self.css_style)
+
+ def _to_abs_path(self, relative_path: str) -> str:
+ return osp.join(osp.abspath(self.template_dir), relative_path)
+
+
+@dataclass
+class GuiConf:
+ """Dataclass for moduletester GUI parameters"""
+
+ test_list_visible: bool = True
+ test_list_pos: str = "left"
+ test_props_visible: bool = True
+ test_props_pos: str = "right"
+ result_tab_visible: bool = True
+ result_tab_pos: str = "bottom"
+ result_props_visible: bool = True
+ result_props_pos: str = "right"
+ cli_visible: bool = False
+ cli_pos: str = "bottom"
+ toolbox_visible: bool = False
+ toolbox_pos: str = "bottom"
+
+ def __post_init__(self):
+ self.test_list_visible = bool(int(self.test_list_visible))
+ self.test_props_visible = bool(int(self.test_props_visible))
+ self.result_tab_visible = bool(int(self.result_tab_visible))
+ self.result_props_visible = bool(int(self.result_props_visible))
+ self.cli_visible = bool(int(self.cli_visible))
+ self.toolbox_visible = bool(int(self.toolbox_visible))
+
+
+class ConfModel(TypedDict):
+ """Dict of package configuration parameters to use as a model"""
+
+ general: GeneralConf
+ export: ExporterConf
+ gui: GuiConf
+
+
+def new_config() -> ConfModel:
+ """Returns a new default config
+
+ Returns:
+ dict: default configuration
+ """
+ return {
+ "general": GeneralConf(),
+ "export": ExporterConf(),
+ "gui": GuiConf(),
+ }
+
+
+# Default initialization
+PACKAGE_CONF: ConfModel = new_config()
+
+
+def serialize_conf_obj(conf: ConfModel) -> configparser.ConfigParser:
+ """Serialize a ConfModel TypedDict to a ConfigParser object.
+
+ Args:
+ conf: ConfModel TypedDict to transform to ConfigParser object
+
+ Returns:
+ ConfigParser object
+ """
+ config = configparser.ConfigParser()
+ for section_name, section_obj in conf.items():
+ config.add_section(section_name)
+ for key, value in section_obj.__dict__.items():
+ config.set(section_name, key, _serialize_field(value))
+ return config
+
+
+def conf_obj_to_str(conf: ConfModel) -> str:
+ """Serialize a ConfModel TypedDict object to a string.
+
+ Args:
+ conf: ConfModel TypedDict to serialize to string
+
+ Returns:
+ serialized string
+ """
+ lines = []
+ for section_name, section_obj in conf.items():
+ lines.append(f"[{section_name}]")
+ for key, value in section_obj.__dict__.items():
+ if isinstance(value, (list, tuple)):
+ value = ", ".join(value)
+ if isinstance(value, bool):
+ value = str(int(value))
+ lines.append(f"{key} = {value}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def save_config(conf: ConfModel, filename: str) -> None:
+ """Save configuration to file."""
+ global PACKAGE_CONF
+ PACKAGE_CONF.update(conf)
+ with open(filename, "w") as f:
+ serialize_conf_obj(conf).write(f)
+
+
+def reset_config(self):
+ """Reset configuration to defaults."""
+ global PACKAGE_CONF
+ PACKAGE_CONF.update(new_config())
diff --git a/moduletester/data/icons/dock.svg b/moduletester/data/icons/dock.svg
new file mode 100644
index 0000000..550fd93
--- /dev/null
+++ b/moduletester/data/icons/dock.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/file-notify.png b/moduletester/data/icons/file-notify.png
new file mode 100644
index 0000000..5484a66
Binary files /dev/null and b/moduletester/data/icons/file-notify.png differ
diff --git a/moduletester/data/icons/file-notify.svg b/moduletester/data/icons/file-notify.svg
new file mode 100644
index 0000000..c8a87d3
--- /dev/null
+++ b/moduletester/data/icons/file-notify.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/file-selected.png b/moduletester/data/icons/file-selected.png
new file mode 100644
index 0000000..0b0a355
Binary files /dev/null and b/moduletester/data/icons/file-selected.png differ
diff --git a/moduletester/data/icons/file-selected.svg b/moduletester/data/icons/file-selected.svg
new file mode 100644
index 0000000..05c9d47
--- /dev/null
+++ b/moduletester/data/icons/file-selected.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/file.png b/moduletester/data/icons/file.png
new file mode 100644
index 0000000..de2172a
Binary files /dev/null and b/moduletester/data/icons/file.png differ
diff --git a/moduletester/data/icons/file.svg b/moduletester/data/icons/file.svg
new file mode 100644
index 0000000..07992a8
--- /dev/null
+++ b/moduletester/data/icons/file.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/folder.png b/moduletester/data/icons/folder.png
new file mode 100644
index 0000000..614aaf4
Binary files /dev/null and b/moduletester/data/icons/folder.png differ
diff --git a/moduletester/data/icons/folder.svg b/moduletester/data/icons/folder.svg
new file mode 100644
index 0000000..15941e5
--- /dev/null
+++ b/moduletester/data/icons/folder.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/gear.png b/moduletester/data/icons/gear.png
new file mode 100644
index 0000000..9959714
Binary files /dev/null and b/moduletester/data/icons/gear.png differ
diff --git a/moduletester/data/icons/gear.svg b/moduletester/data/icons/gear.svg
new file mode 100644
index 0000000..2d3d3b2
--- /dev/null
+++ b/moduletester/data/icons/gear.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/green-check-square.png b/moduletester/data/icons/green-check-square.png
new file mode 100644
index 0000000..5a9574e
Binary files /dev/null and b/moduletester/data/icons/green-check-square.png differ
diff --git a/moduletester/data/icons/green-check-square.svg b/moduletester/data/icons/green-check-square.svg
new file mode 100644
index 0000000..3a38e08
--- /dev/null
+++ b/moduletester/data/icons/green-check-square.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-action-edit.png b/moduletester/data/icons/libre-gui-action-edit.png
new file mode 100644
index 0000000..ecab448
Binary files /dev/null and b/moduletester/data/icons/libre-gui-action-edit.png differ
diff --git a/moduletester/data/icons/libre-gui-action-edit.svg b/moduletester/data/icons/libre-gui-action-edit.svg
new file mode 100644
index 0000000..28a9468
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-action-edit.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-address-book.png b/moduletester/data/icons/libre-gui-address-book.png
new file mode 100644
index 0000000..5f9df52
Binary files /dev/null and b/moduletester/data/icons/libre-gui-address-book.png differ
diff --git a/moduletester/data/icons/libre-gui-address-book.svg b/moduletester/data/icons/libre-gui-address-book.svg
new file mode 100644
index 0000000..0085aa2
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-address-book.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-export-doc.png b/moduletester/data/icons/libre-gui-export-doc.png
new file mode 100644
index 0000000..d2503e6
Binary files /dev/null and b/moduletester/data/icons/libre-gui-export-doc.png differ
diff --git a/moduletester/data/icons/libre-gui-export-doc.svg b/moduletester/data/icons/libre-gui-export-doc.svg
new file mode 100644
index 0000000..40e9dd0
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-export-doc.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-file-document.png b/moduletester/data/icons/libre-gui-file-document.png
new file mode 100644
index 0000000..08f7852
Binary files /dev/null and b/moduletester/data/icons/libre-gui-file-document.png differ
diff --git a/moduletester/data/icons/libre-gui-file-document.svg b/moduletester/data/icons/libre-gui-file-document.svg
new file mode 100644
index 0000000..ee1f2d8
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-file-document.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-folder-open.png b/moduletester/data/icons/libre-gui-folder-open.png
new file mode 100644
index 0000000..8a8399b
Binary files /dev/null and b/moduletester/data/icons/libre-gui-folder-open.png differ
diff --git a/moduletester/data/icons/libre-gui-folder-open.svg b/moduletester/data/icons/libre-gui-folder-open.svg
new file mode 100644
index 0000000..5c07d07
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-folder-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-new-file-document.png b/moduletester/data/icons/libre-gui-new-file-document.png
new file mode 100644
index 0000000..06aec9b
Binary files /dev/null and b/moduletester/data/icons/libre-gui-new-file-document.png differ
diff --git a/moduletester/data/icons/libre-gui-new-file-document.svg b/moduletester/data/icons/libre-gui-new-file-document.svg
new file mode 100644
index 0000000..7e08d14
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-new-file-document.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-refresh.png b/moduletester/data/icons/libre-gui-refresh.png
new file mode 100644
index 0000000..e00cae5
Binary files /dev/null and b/moduletester/data/icons/libre-gui-refresh.png differ
diff --git a/moduletester/data/icons/libre-gui-refresh.svg b/moduletester/data/icons/libre-gui-refresh.svg
new file mode 100644
index 0000000..cdba3a0
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-refresh.svg
@@ -0,0 +1,4 @@
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-save-as.png b/moduletester/data/icons/libre-gui-save-as.png
new file mode 100644
index 0000000..49e4159
Binary files /dev/null and b/moduletester/data/icons/libre-gui-save-as.png differ
diff --git a/moduletester/data/icons/libre-gui-save-as.svg b/moduletester/data/icons/libre-gui-save-as.svg
new file mode 100644
index 0000000..52a48f5
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-save-as.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-save.png b/moduletester/data/icons/libre-gui-save.png
new file mode 100644
index 0000000..c1639e7
Binary files /dev/null and b/moduletester/data/icons/libre-gui-save.png differ
diff --git a/moduletester/data/icons/libre-gui-save.svg b/moduletester/data/icons/libre-gui-save.svg
new file mode 100644
index 0000000..a76902e
--- /dev/null
+++ b/moduletester/data/icons/libre-gui-save.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/moduletester/data/icons/libre-gui-view-carousel.png b/moduletester/data/icons/libre-gui-view-carousel.png
new file mode 100644
index 0000000..725cd2d
Binary files /dev/null and b/moduletester/data/icons/libre-gui-view-carousel.png differ
diff --git a/moduletester/data/icons/notification.png b/moduletester/data/icons/notification.png
new file mode 100644
index 0000000..957112e
Binary files /dev/null and b/moduletester/data/icons/notification.png differ
diff --git a/moduletester/data/icons/notification.svg b/moduletester/data/icons/notification.svg
new file mode 100644
index 0000000..6486c05
--- /dev/null
+++ b/moduletester/data/icons/notification.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/pause.png b/moduletester/data/icons/pause.png
new file mode 100644
index 0000000..94b0947
Binary files /dev/null and b/moduletester/data/icons/pause.png differ
diff --git a/moduletester/data/icons/pause.svg b/moduletester/data/icons/pause.svg
new file mode 100644
index 0000000..f414687
--- /dev/null
+++ b/moduletester/data/icons/pause.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/play.png b/moduletester/data/icons/play.png
new file mode 100644
index 0000000..795e4d7
Binary files /dev/null and b/moduletester/data/icons/play.png differ
diff --git a/moduletester/data/icons/play.svg b/moduletester/data/icons/play.svg
new file mode 100644
index 0000000..06f5c3b
--- /dev/null
+++ b/moduletester/data/icons/play.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/rejected.png b/moduletester/data/icons/rejected.png
new file mode 100644
index 0000000..bc75345
Binary files /dev/null and b/moduletester/data/icons/rejected.png differ
diff --git a/moduletester/data/icons/rejected.svg b/moduletester/data/icons/rejected.svg
new file mode 100644
index 0000000..7147d52
--- /dev/null
+++ b/moduletester/data/icons/rejected.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/reload.png b/moduletester/data/icons/reload.png
new file mode 100644
index 0000000..99b3b38
Binary files /dev/null and b/moduletester/data/icons/reload.png differ
diff --git a/moduletester/data/icons/running.png b/moduletester/data/icons/running.png
new file mode 100644
index 0000000..b7de218
Binary files /dev/null and b/moduletester/data/icons/running.png differ
diff --git a/moduletester/data/icons/running.svg b/moduletester/data/icons/running.svg
new file mode 100644
index 0000000..11f6576
--- /dev/null
+++ b/moduletester/data/icons/running.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/skip.png b/moduletester/data/icons/skip.png
new file mode 100644
index 0000000..da1a861
Binary files /dev/null and b/moduletester/data/icons/skip.png differ
diff --git a/moduletester/data/icons/skip.svg b/moduletester/data/icons/skip.svg
new file mode 100644
index 0000000..cb63fba
--- /dev/null
+++ b/moduletester/data/icons/skip.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/stop.png b/moduletester/data/icons/stop.png
new file mode 100644
index 0000000..e12f289
Binary files /dev/null and b/moduletester/data/icons/stop.png differ
diff --git a/moduletester/data/icons/stop.svg b/moduletester/data/icons/stop.svg
new file mode 100644
index 0000000..d7a28e3
--- /dev/null
+++ b/moduletester/data/icons/stop.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/test-error.png b/moduletester/data/icons/test-error.png
new file mode 100644
index 0000000..d2dc912
Binary files /dev/null and b/moduletester/data/icons/test-error.png differ
diff --git a/moduletester/data/icons/test-error.svg b/moduletester/data/icons/test-error.svg
new file mode 100644
index 0000000..6c83c35
--- /dev/null
+++ b/moduletester/data/icons/test-error.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/unknown.png b/moduletester/data/icons/unknown.png
new file mode 100644
index 0000000..8c586a6
Binary files /dev/null and b/moduletester/data/icons/unknown.png differ
diff --git a/moduletester/data/icons/unknown.svg b/moduletester/data/icons/unknown.svg
new file mode 100644
index 0000000..8757d12
--- /dev/null
+++ b/moduletester/data/icons/unknown.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/data/icons/yellow-check-square.png b/moduletester/data/icons/yellow-check-square.png
new file mode 100644
index 0000000..0ade447
Binary files /dev/null and b/moduletester/data/icons/yellow-check-square.png differ
diff --git a/moduletester/data/icons/yellow-check-square.svg b/moduletester/data/icons/yellow-check-square.svg
new file mode 100644
index 0000000..1abb01c
--- /dev/null
+++ b/moduletester/data/icons/yellow-check-square.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/default_templates/custom-reference.docx b/moduletester/default_templates/custom-reference.docx
new file mode 100644
index 0000000..045165c
Binary files /dev/null and b/moduletester/default_templates/custom-reference.docx differ
diff --git a/moduletester/default_templates/custom-reference.odt b/moduletester/default_templates/custom-reference.odt
new file mode 100644
index 0000000..63ad93d
Binary files /dev/null and b/moduletester/default_templates/custom-reference.odt differ
diff --git a/moduletester/default_templates/default_style.css b/moduletester/default_templates/default_style.css
new file mode 100644
index 0000000..bb40de9
--- /dev/null
+++ b/moduletester/default_templates/default_style.css
@@ -0,0 +1,408 @@
+/*
+ * I add this to html files generated with pandoc.
+ */
+
+html {
+ font-size: 100%;
+ overflow-y: scroll;
+ -webkit-text-size-adjust: 100%;
+ -ms-text-size-adjust: 100%;
+}
+
+body {
+ color: #444;
+ font-family: Georgia, Palatino, 'Palatino Linotype', Times, 'Times New Roman', serif;
+ font-size: 12px;
+ line-height: 1.7;
+ padding: 2em;
+ margin: auto;
+ background: #fefefe;
+}
+
+a {
+ color: #0645ad;
+ text-decoration: none;
+}
+
+a:visited {
+ color: #0b0080;
+}
+
+a:hover {
+ color: #06e;
+}
+
+a:active {
+ color: #faa700;
+}
+
+a:focus {
+ outline: thin dotted;
+}
+
+*::-moz-selection {
+ background: rgba(255, 255, 0, 0.3);
+ color: #000;
+}
+
+*::selection {
+ background: rgba(255, 255, 0, 0.3);
+ color: #000;
+}
+
+a::-moz-selection {
+ background: rgba(255, 255, 0, 0.3);
+ color: #0645ad;
+}
+
+a::selection {
+ background: rgba(255, 255, 0, 0.3);
+ color: #0645ad;
+}
+
+p {
+ margin: 1em 0;
+}
+
+img {
+ max-width: 100%;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ color: #111;
+ line-height: 125%;
+ margin-top: 2em;
+ font-weight: normal;
+}
+
+h4,
+h5,
+h6 {
+ font-weight: bold;
+}
+
+h1 {
+ font-size: 2.5em;
+}
+
+h2 {
+ font-size: 2em;
+}
+
+h3 {
+ font-size: 1.5em;
+}
+
+h4 {
+ font-size: 1.2em;
+}
+
+h5 {
+ font-size: 1em;
+}
+
+h6 {
+ font-size: 0.9em;
+}
+
+blockquote {
+ color: #666666;
+ margin: 0;
+ padding-left: 3em;
+ border-left: 0.5em #EEE solid;
+}
+
+hr {
+ display: block;
+ height: 2px;
+ border: 0;
+ border-top: 1px solid #aaa;
+ border-bottom: 1px solid #eee;
+ margin: 1em 0;
+ padding: 0;
+}
+
+pre,
+code,
+kbd,
+samp {
+ color: #000;
+ font-family: monospace, monospace;
+ _font-family: 'courier new', monospace;
+ font-size: 0.98em;
+}
+
+pre {
+ white-space: pre;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+b,
+strong {
+ font-weight: bold;
+}
+
+dfn {
+ font-style: italic;
+}
+
+ins {
+ background: #ff9;
+ color: #000;
+ text-decoration: none;
+}
+
+mark {
+ background: #ff0;
+ color: #000;
+ font-style: italic;
+ font-weight: bold;
+}
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+ul,
+ol {
+ margin: 1em 0;
+ padding: 0 0 0 2em;
+}
+
+li p:last-child {
+ margin-bottom: 0;
+}
+
+ul ul,
+ol ol {
+ margin: .3em 0;
+}
+
+dl {
+ margin-bottom: 1em;
+}
+
+dt {
+ font-weight: bold;
+ margin-bottom: .8em;
+}
+
+dd {
+ margin: 0 0 .8em 2em;
+}
+
+dd:last-child {
+ margin-bottom: 0;
+}
+
+img {
+ border: 0;
+ -ms-interpolation-mode: bicubic;
+ vertical-align: middle;
+}
+
+figure {
+ display: block;
+ text-align: center;
+ margin: 1em 0;
+}
+
+figure img {
+ border: none;
+ margin: 0 auto;
+}
+
+figcaption {
+ font-size: 0.8em;
+ font-style: italic;
+ margin: 0 0 .8em;
+}
+
+table {
+ margin-bottom: 2em;
+ border-bottom: 1px solid #ddd;
+ border-right: 1px solid #ddd;
+ border-spacing: 0;
+ border-collapse: collapse;
+ width: 100%;
+}
+
+table th {
+ padding: .2em 1em;
+ background-color: #eee;
+ border-top: 1px solid #ddd;
+ border-left: 1px solid #ddd;
+}
+
+table td {
+ padding: .2em 1em;
+ border-top: 1px solid #ddd;
+ border-left: 1px solid #ddd;
+ vertical-align: top;
+}
+
+.author {
+ font-size: 1.2em;
+ text-align: center;
+}
+
+@media only screen and (min-width: 480px) {
+ body {
+ font-size: 14px;
+ }
+}
+
+@media only screen and (min-width: 768px) {
+ body {
+ font-size: 16px;
+ }
+}
+
+@media print {
+ * {
+ background: transparent !important;
+ color: black !important;
+ filter: none !important;
+ -ms-filter: none !important;
+ }
+
+ body {
+ font-size: 12pt;
+ max-width: 100%;
+ }
+
+ a,
+ a:visited {
+ text-decoration: underline;
+ }
+
+ hr {
+ height: 1px;
+ border: 0;
+ border-bottom: 1px solid black;
+ }
+
+ a[href]:after {
+ content: " (" attr(href) ")";
+ }
+
+ abbr[title]:after {
+ content: " (" attr(title) ")";
+ }
+
+ .ir a:after,
+ a[href^="javascript:"]:after,
+ a[href^="#"]:after {
+ content: "";
+ }
+
+ pre,
+ blockquote {
+ border: 1px solid #999;
+ padding-right: 1em;
+ page-break-inside: avoid;
+ }
+
+ tr,
+ img {
+ page-break-inside: avoid;
+ }
+
+ img {
+ max-width: 100% !important;
+ }
+
+ @page :left {
+ margin: 15mm 20mm 15mm 10mm;
+ }
+
+ @page :right {
+ margin: 15mm 10mm 15mm 20mm;
+ }
+
+ p,
+ h2,
+ h3 {
+ orphans: 3;
+ widows: 3;
+ }
+
+ h2,
+ h3 {
+ page-break-after: avoid;
+ }
+}
+
+/* ADDITIONAL CUSTOM STYLING */
+
+.section-block {
+ border: 1px solid #ccc;
+ border-radius: 5px;
+ padding: 1em;
+ margin: 1em 0;
+ box-shadow: 5px 5px 10px lightgrey;
+}
+
+.result-block {
+ display: flex;
+ align-items: center;
+}
+
+.result-block img {
+ margin-right: 1em;
+}
+
+.new-page {
+ page-break-before: always;
+}
+
+
+nav#TOC {
+ border: 1px solid #ccc;
+ border-radius: 5px;
+ padding: 1em;
+ margin: 1em 0;
+ box-shadow: 5px 5px 10px lightgrey;
+}
+
+/* Common code properties */
+code {
+ background-color: WhiteSmoke;
+}
+
+/* Code block */
+:not(p):not(td)>code {
+ box-shadow: 5px 5px 10px lightgrey;
+ border-radius: 10px;
+ display: block;
+ padding: 1em;
+}
+
+/* Inline code */
+p code {
+ border-radius: 5px;
+ padding: 0.2em;
+}
+
+td code {
+ border-radius: 5px;
+ padding: 0em;
+}
\ No newline at end of file
diff --git a/moduletester/default_templates/default_template.j2 b/moduletester/default_templates/default_template.j2
new file mode 100644
index 0000000..5844163
--- /dev/null
+++ b/moduletester/default_templates/default_template.j2
@@ -0,0 +1,10 @@
+
+
+
+
+ This is an empty template
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/default_templates/test_list_template.j2 b/moduletester/default_templates/test_list_template.j2
new file mode 100644
index 0000000..dd1cad3
--- /dev/null
+++ b/moduletester/default_templates/test_list_template.j2
@@ -0,0 +1,39 @@
+
+
+
+
+ {{ _("Test List Document") }}: {{ doc_obj.test_suite.package.full_name.capitalize() }}
+
+
+
+
+
{{ doc_obj.test_suite.author or "Unknown author " }}
+
+ {{ _("Last execution date:") }} {{ doc_obj.test_suite.last_run }}
+ Description
+
+ {{ doc_obj.test_suite.get_fmt_description("html").strip() or "No package description found."}}
+ {% for section_name, tests in doc_obj.test_suite.group_tests().items() %}
+
+
+ Test sub-module: {{ section_name.rsplit(".", 1)[-1] }}
+
+ {% for test in tests %}
+
+ {% set test_name_split = test.package.last_name.split("_") %}
+ {{test_name_split[0].capitalize()}}: {{" ".join(test_name_split[1:])}}
+
+ Description
+ {{ test.get_html_description(standalone=False, embeded=True,
+ shift_header=doc_obj.docstrings_header_shift)|safe
+ }}
+
+
+
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
\ No newline at end of file
diff --git a/moduletester/default_templates/test_results_template.j2 b/moduletester/default_templates/test_results_template.j2
new file mode 100644
index 0000000..59241d0
--- /dev/null
+++ b/moduletester/default_templates/test_results_template.j2
@@ -0,0 +1,116 @@
+
+
+
+
+ {{ _("Test Results Document") }}: {{ doc_obj.test_suite.package.full_name.capitalize() }}
+
+
+
+
+
{{ doc_obj.test_suite.author or "Unknown author " }}
+
+ {{ _("Last execution date:") }} {{ doc_obj.test_suite.last_run }}
+ Description
+
+ {% set extra_desc_gen_args = ["--shift-heading-level-by={s}".format(s=doc_obj.docstrings_header_shift)] %}
+ {{ doc_obj.test_suite.get_fmt_description("html", extra_desc_gen_args
+ ).strip() or "No package description found."}}
+
+ {% for section_name, tests in doc_obj.test_suite.group_tests().items() %}
+
+
+ Test sub-module: {{ section_name.rsplit(".", 1)[-1] }}
+
+ {% for test in tests %}
+
+ {% set test_name_split = test.package.last_name.split("_") %}
+ {{test_name_split[0].capitalize()}}: {{" ".join(test_name_split[1:])}}
+
+ Description
+ {{ test.get_html_description(standalone=False, embeded=True,
+ shift_header=doc_obj.docstrings_header_shift)|safe
+ }}
+
+
+
+
+ Command
+ {{ test.command or "-" }}
+
+
+
+ Result
+ {% if test.result != None %}
+
+
+ {{ test.result.result_name }}, executed on {{
+ test.result.last_run or "-"}}
+
+ {% else %}
+ No result found.
+ {% endif %}
+ {{ test.comment|safe }}
+
+
+ {% for image_path in doc_obj.get_images_paths(test) %}
+
+ {% endfor %}
+
+
+
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+ Summary
+ {{_("%s test results summary") % doc_obj.test_suite.package.last_name.capitalize() }}
+
+
+ Test
+ {{ _("No result") }}
+ {{ _("Accepted") }}
+ {{ _("Accepted with reserve") }}
+ {{ _("Skipped") }}
+ {{ _("Rejected") }}
+ Execution date
+
+ {% for test in doc_obj.test_suite.tests %}
+
+ {% set result_bin = test.result_binary_label() %}
+ {{ test.package.full_name }}
+ {{ "X" if result_bin[0] == 1 else "" }}
+ {{ "X" if result_bin[1] == 1 else "" }}
+ {{ "X" if result_bin[2] == 1 else "" }}
+ {{ "X" if result_bin[3] == 1 else "" }}
+ {{ "X" if result_bin[4] == 1 else "" }}
+ {{ test.result.last_run }}
+
+ {% endfor %}
+
+ {{_("Count by result cateogry")}}
+ {% set result_count = doc_obj.test_suite.results_count() %}
+
+
+ {{ _("No result") }}
+ {{ _("Accepted") }}
+ {{ _("Accepted with reserve") }}
+ {{ _("Skipped") }}
+ {{ _("Rejected") }}
+
+
+ {{ result_count[0] }}
+ {{ result_count[1] }}
+ {{ result_count[2] }}
+ {{ result_count[3] }}
+ {{ result_count[4] }}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/moduletester/exporter.py b/moduletester/exporter.py
index 02989d8..d07a121 100644
--- a/moduletester/exporter.py
+++ b/moduletester/exporter.py
@@ -30,9 +30,8 @@ def export(
images, substitutes = self.export_images(test_images)
title = f"\t{title:<{self.padding}}"
- description = (
- f" .. include:: {desc_path}\n\t{' ' * (self.padding+2)}\t:parser: rst\n\n"
- )
+ pad = " " * (self.padding + 2)
+ description = f" .. include:: {desc_path}\n\t{pad}\t:parser: rst\n\n"
template = "".join([title, description, *images])
@@ -42,7 +41,7 @@ def export_description(self, package: Module, description: str) -> str:
""" """
desc_content = description + "\n\n|\n"
- file_name = f"{package.module.__name__}_dtv.rst"
+ file_name = f"{package.module.__name__}_test_list.rst"
path = os.path.join(self.temp_path, file_name)
with open(path, "w", encoding="utf-8") as tempfile:
@@ -96,14 +95,14 @@ def export(self, package: Module, result: TestResult) -> str:
desc = " - \n\n"
title = f"\t{name:<{self.padding_name}}"
- result = f"{result_name:<{self.padding_result}}"
+ result_str = f"{result_name:<{self.padding_result}}"
- export = " ".join([title, result, desc])
+ export = " ".join([title, result_str, desc])
return export
def export_comment(self, package: Module, result: TestResult) -> str:
""" """
- name = f"{package.module.__name__}_rtv.rst"
+ name = f"{package.module.__name__}_test_results.rst"
path = os.path.join(self.temp_path, name)
if result is not None:
@@ -132,7 +131,6 @@ def export(self, rst_path: str, temp_path: str, section_callback) -> None:
content = []
header = format_header(self.test_suite.package_name, "=")
grouped_tests = self.test_suite.group_tests()
-
for group_package, test_list in grouped_tests.items():
section = section_callback(group_package, test_list, temp_path)
content.append(section)
@@ -145,7 +143,7 @@ def write_rst(self, rst_path: str, rst_content: str) -> None:
with open(rst_path, "w", encoding="utf-8") as index_rst:
index_rst.write(rst_content)
- def export_section_dtv(
+ def export_section_test_list(
self, package: str, tests: List[Test], temp_rst_path: str
) -> str:
""" """
@@ -198,7 +196,7 @@ def export_tests_table(
return table
- def export_section_rtv(
+ def export_section_test_results(
self, package: str, tests: List[Test], temp_path: str
) -> str:
""" """
@@ -221,7 +219,8 @@ def export_results_table(
table_content = ""
result_exporter = TestResultExporter(temp_path, title_len, result_len)
for test in tests:
- table_content += result_exporter.export(test.package, test.result)
+ if test.result is not None:
+ table_content += result_exporter.export(test.package, test.result)
# Building the table
table_directive = ".. table::\n\t:widths: 15, 20, 45\n\n"
diff --git a/moduletester/gui/__init__.py b/moduletester/gui/__init__.py
index e386ba9..e2519a3 100644
--- a/moduletester/gui/__init__.py
+++ b/moduletester/gui/__init__.py
@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
+"""ModuleTester GUI package."""
-from moduletester import config # pylint: disable=unused-import
+# pylint: disable=unused-import
+from moduletester import config as config # noqa: F401
diff --git a/moduletester/gui/components/body_component.py b/moduletester/gui/components/body_component.py
index 4608ad8..a17e314 100644
--- a/moduletester/gui/components/body_component.py
+++ b/moduletester/gui/components/body_component.py
@@ -9,22 +9,27 @@
from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
-from ...model import ResultEnum, TestSuite
+from moduletester.config import PACKAGE_CONF
+from moduletester.gui.components.test_list_component import TestListComponent
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
+from moduletester.gui.widgets.toolbox_widget import Toolbox
+
+from ...model import Test, TestSuite
from ..states.runner import QSubprocess
from ..states.signals import TMSignals
from ..widgets.cli_widget import CLIWidget
-from ..widgets.test_list_widget import TestListWidget
+from ..widgets.dock_wrapper import QDockWrapper
from .result_information import ResultInformation
from .test_information import TestInformation
-class TMWidget(QW.QWidget):
+class TMWidget(DockableQWidget):
def __init__(
self,
signals: TMSignals,
test_suite: TestSuite,
moduletester_path: Optional[str] = None,
- parent: Optional[QW.QWidget] = None,
+ parent: Optional[QW.QMainWindow] = None,
) -> None:
super().__init__(parent)
# Fields
@@ -33,92 +38,227 @@ def __init__(
self.moduletester_path = moduletester_path
self.signals = signals
self._run_thread: Optional[QSubprocess] = None
+ self._running_test: Optional[Test] = None
# Widgets
- self.test_list = TestListWidget(self.test_suite.tests, self)
- self.run_btn = QW.QPushButton(get_icon("apply.png"), "Run Script", self)
+ self.h_splitter = QW.QSplitter(QC.Qt.Orientation.Horizontal, self)
+ self.v_splitter = QW.QSplitter(QC.Qt.Orientation.Vertical, self)
+
+ self.test_list_comp = TestListComponent(self.test_suite.tests, parent=self)
+ self.test_list = self.test_list_comp.test_list_widget
+ self.run_btn = self.test_list_comp.run_btn
self.test_information = TestInformation(self.signals, self)
- self.result_information = ResultInformation(self.signals, self)
+ self.result_information = ResultInformation(self.signals, parent=self)
self.cli_group = CLIWidget(self)
+ self.toolbox = Toolbox(self, signals=signals, title="Toolbox")
+
+ self.dock_widgets: list[QDockWrapper] = []
# Layouts
- self.glayout = QW.QGridLayout(self)
+ self.glayout = QW.QHBoxLayout(self)
+
+ self.view_menu = QW.QMenu()
self.setup()
+ self.setup_dock_widgets()
+
+ def update_widget(
+ self, test_suite: TestSuite, moduletester_path: Optional[str] = None
+ ):
+ """Update widget with new test_suite
+
+ Args:
+ test_suite: The new TestSuite object to update the widget with.
+ moduletester_path: The new path to the moduletester file.
+ """
+ self.test_suite = test_suite
+ self.origin_path = self.test_suite.package.root_path
+ self.moduletester_path = moduletester_path
+ self.test_list_comp.test_list_widget.reset_widget(self.test_suite.tests)
@property
def run_thread(self):
return self._run_thread
- def setup(self):
- # Widget setup
- self.set_item(False)
+ def get_main_window(self) -> QW.QMainWindow:
+ """Get the main window of the widget
- # Layout setup
- list_layout = QW.QVBoxLayout()
- list_layout.addWidget(self.test_list)
- list_layout.addWidget(self.run_btn)
+ Returns:
+ The main window of the widget.
+ """
+ window = self.window()
+ assert isinstance(window, QW.QMainWindow)
+ return window
- self.glayout.addLayout(list_layout, 0, 0, 9, 2)
- self.glayout.addWidget(self.test_information, 0, 2, 4, 4)
- self.glayout.addWidget(self.result_information, 4, 2, 4, 4)
- self.glayout.addWidget(self.cli_group, 8, 2, 1, 4)
-
- for ind in range(self.glayout.columnCount()):
- self.glayout.setColumnMinimumWidth(ind, 250)
- for ind in range(self.glayout.rowCount()):
- self.glayout.setRowMinimumHeight(ind, 85)
+ def setup(self) -> None:
+ """Setup the widget layout and event handlers."""
+ self.glayout.addWidget(self.test_information)
# Event Handlers
self.run_btn.clicked.connect(self.run_test)
+ self.test_list.itemDoubleClicked.connect(self._run_on_double_click)
self.test_list.currentItemChanged.connect(
- lambda current, previous: self.set_item(False, current)
+ lambda: self.set_item(is_test_modified=False)
)
- self.result_information.result_enum.currentTextChanged.connect(
+ self.result_information.result_enum.currentIndexChanged.connect(
self.update_result
)
self.test_list.menu.run_script.triggered.connect(self.run_test)
self.test_list.menu.code_snippet.triggered.connect(self.pop_code_snippet)
- self.test_information.table_group.table.itemChanged.connect(self.update_test)
+ self.test_information.table_group.dataset_gbox.SIG_APPLY_BUTTON_CLICKED.connect(
+ self.update_test
+ )
+ self.signals.SIG_PROJECT_LOADED.connect(
+ lambda: self.test_list.reset_widget(self.test_suite.tests)
+ )
+ self.set_item(is_test_modified=False)
+
+ def setup_dock_widgets(self):
+ """Setup the dock widgets for the widget layout using the configuration and the
+ main window.
+ """
+ window = self.get_main_window()
+ for widget, str_area, visible in (
+ (
+ self.cli_group,
+ PACKAGE_CONF["gui"].cli_pos,
+ PACKAGE_CONF["gui"].cli_visible,
+ ),
+ (
+ self.result_information,
+ PACKAGE_CONF["gui"].result_tab_pos,
+ PACKAGE_CONF["gui"].result_tab_visible,
+ ),
+ (
+ self.test_information.table_group,
+ PACKAGE_CONF["gui"].test_props_pos,
+ PACKAGE_CONF["gui"].test_props_visible,
+ ),
+ (
+ self.result_information.prop_group,
+ PACKAGE_CONF["gui"].result_props_pos,
+ PACKAGE_CONF["gui"].result_props_visible,
+ ),
+ (
+ self.test_list_comp,
+ PACKAGE_CONF["gui"].test_list_pos,
+ PACKAGE_CONF["gui"].test_list_visible,
+ ),
+ (
+ self.toolbox,
+ PACKAGE_CONF["gui"].toolbox_pos,
+ PACKAGE_CONF["gui"].toolbox_visible,
+ ),
+ ):
+ dock_widget = QDockWrapper(
+ self,
+ widget,
+ )
+ area = QDockWrapper.get_area_from_str(str_area)
+ self.dock_widgets.append(dock_widget)
+ self.view_menu.addAction(dock_widget.toggleViewAction())
+ window.addDockWidget(area, dock_widget)
+ dock_widget.setVisible(visible)
+ self.view_menu.addAction(dock_widget.toggleViewAction())
+
+ def _run_on_double_click(self, clicked_item: QW.QTreeWidgetItem):
+ """Run the test when the item is double clicked.
+
+ Args:
+ clicked_item: The item that was double clicked in the test list.
+ """
+ self.test_list.setCurrentItem(clicked_item)
+ selected_item = self.test_list.current_item
+ selected_test = self.test_list.get_selected_test()
+ if (
+ selected_item is clicked_item
+ and self.run_btn.isEnabled()
+ and selected_test is not None
+ and self.validate_command(selected_test)
+ ):
+ self.set_item(is_test_modified=False)
+ self.run_test()
+
+ def validate_command(self, test: Test) -> bool:
+ """Validate the command for the test.
+
+ Args:
+ test: The test to validate the command for.
+
+ Returns:
+ True if the command is valid, otherwise False.
+ """
+ return self.test_information.validate_command(test)
def set_item(
self,
+ test: Optional[Test] = None,
is_test_modified: bool = True,
- current_item: Optional[QW.QTreeWidgetItem] = None,
):
- self.test_list.setup_list(current_item)
-
- test = self.test_list.get_selected_test()
-
- self.test_information.set_item(test, self.origin_path)
+ """Set the item for the widget.
+
+ Args:
+ test: The test to set the item for.
+ is_test_modified: Whether the test was modified.
+ """
+ test = test or self.test_list.get_selected_test()
+ if test is None:
+ return
+ self.test_information.set_item(test, self.origin_path or "None")
self.result_information.set_item(test)
self.cli_group.set_item(test)
if is_test_modified:
+ self.test_list.update_result(test)
self.signals.SIG_PROJECT_MODIFIED.emit()
- def update_result(self, result_value: str):
+ def update_result(self, _index: int):
+ """Update the result for the test.
+
+ Args:
+ index: The index of the result to update. Unused.
+ """
test = self.test_list.get_selected_test()
- if test.result is not None:
- test.result.result = ResultEnum(result_value)
- self.test_list.setup_list(self.test_list.current_item)
- self.signals.SIG_PROJECT_MODIFIED.emit()
+ if test is not None and test.result is not None:
+ new_result = self.result_information.result_enum.currentData()
+ test.result.result = new_result
- def update_test(self, item: QW.QTreeWidgetItem, column: int):
- if column == 1:
- test = self.test_list.get_selected_test()
+ self.test_list.update_result(test)
+ self.signals.SIG_PROJECT_MODIFIED.emit()
- self.test_information.update_command(item, test)
- self.set_item(current_item=self.test_list.current_item)
+ def update_test(self):
+ """Update the test."""
+ test = self.test_list.get_selected_test()
+ if test is None:
+ return
+ self.test_information.update_command(test)
+ self.test_list.update_result(test)
+ self.cli_group.set_item(test)
def run_test(self):
+ """Run the test."""
if self._run_thread is None:
test = self.test_list.get_selected_test()
+ if test is None:
+ return
+
test_name = test.package.last_name
+ test_item = self.test_list.current_item
+
+ if not self.validate_command(test):
+ return
self._run_thread = QSubprocess(self.test_suite, test_name)
+ self._running_test = test
+ self.result_information.result_enum.setEnabled(False)
+ self.result_information.comment_widget.readonly(False)
+ self.result_information.comment_widget.comment_label.clear()
+
+ if test_item is not None:
+ self.test_list.start_test_spinner(test_item)
+
self._run_thread.run_ended.connect(self.handle_thread_end)
self._run_thread.result_modified.connect(self.handle_result_modified)
self._run_thread.SIG_RUN_STARTED.connect(self.signals.SIG_RUN_STARTED.emit)
@@ -131,6 +271,7 @@ def run_test(self):
).exec()
def stop_thread(self):
+ """Stop the test thread."""
if self._run_thread is not None:
self._run_thread.stop(forced=True)
else:
@@ -139,6 +280,7 @@ def stop_thread(self):
).exec()
def restart_thread(self):
+ """Restart the test thread."""
if self._run_thread is not None:
self.stop_thread()
self.run_test()
@@ -149,32 +291,71 @@ def restart_thread(self):
"No test currently paused or running",
).exec()
+ def notify_test(self, test: Optional[Test]):
+ """Update the test so other widgets know what to display
+ (e.g. notification icon in the treeview).
+
+ Args:
+ test: The test to notify.
+ """
+ if test is not None:
+ result = test.result
+ is_message_new = False
+ is_error_new = False
+ if result is not None:
+ is_message_new = result.output_msg not in ("", None)
+ is_error_new = result.error_msg not in ("", None)
+
+ test.set_message_state(is_message_new)
+ test.set_error_state(is_error_new)
+
def handle_thread_end(self):
- if self._run_thread is not None:
+ """Handle the end of the test thread."""
+ if self._run_thread is not None and self._running_test is not None:
self._run_thread.result_modified.disconnect()
self._run_thread.run_ended.disconnect()
self._run_thread.SIG_RUN_STARTED.disconnect()
-
self._run_thread = None
+
+ self.notify_test(self._running_test)
+
+ if self._running_test is self.test_list.get_selected_test():
+ self.set_item(self._running_test, is_test_modified=True)
+ else:
+ self.test_list.update_result(self._running_test)
+ self.test_list.set_test_icon(self._running_test, "file-notify.svg")
+
+ self._running_test = None
self.signals.SIG_RUN_STOPPED.emit()
- current_item = self.test_list.current_item
- self.set_item(current_item=current_item)
def handle_result_modified(self, _outs, _errs):
- current_item = self.test_list.selectedItems()[0]
- self.set_item(current_item=current_item)
+ """Handle the modification of the result.
+
+ Args:
+ _outs: stdouts, unused
+ _errs: stderrs, unused
+ """
+ if self._running_test is not None:
+ self.test_list.update_result(self._running_test)
def pop_code_snippet(self):
+ """Show the code snippet for the test in a popup dialog."""
test = self.test_list.get_selected_test()
+ if test is None:
+ return
+
test_package = get_test_package(self.test_suite.package.module)
code_snippet = test.get_code_snippet(test_package)
editor = CodeEditor(
- self, columns=100, rows=45, language="python", font=self.font()
+ self,
+ columns=100,
+ rows=45,
+ language="python", # font=self.font()
)
editor.setReadOnly(True)
editor.setPlainText(code_snippet)
- editor.setWindowFlags(QC.Qt.Window)
+ editor.setWindowFlags(QC.Qt.WindowType.Window)
editor.setWindowTitle(f"Code snippet - {test.package.last_name}")
editor.setWindowIcon(get_icon("python.png"))
editor.show()
diff --git a/moduletester/gui/components/result_information.py b/moduletester/gui/components/result_information.py
index e22b4c9..cb7c128 100644
--- a/moduletester/gui/components/result_information.py
+++ b/moduletester/gui/components/result_information.py
@@ -1,11 +1,14 @@
# pylint: disable=missing-module-docstring, missing-function-docstring
# pylint: disable=missing-class-docstring
-from typing import Any, Dict, Optional
+from typing import Any, Callable, Dict, Optional
+from guidata.qthelpers import get_icon
+from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
from moduletester.gui.states.signals import TMSignals
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
from moduletester.gui.widgets.result_comment import TestCommentWidget
from moduletester.gui.widgets.result_error_widget import ResultError
from moduletester.gui.widgets.result_output_widget import ResultOutput
@@ -13,25 +16,35 @@
from moduletester.model import Test
-class ResultInformation(QW.QGroupBox):
- def __init__(self, signals: TMSignals, parent: Optional[QW.QWidget] = None):
- super().__init__("Results", parent)
+class ResultInformation(DockableQWidget):
+ def __init__(
+ self,
+ signals: TMSignals,
+ title: str = "Test Result",
+ parent: Optional[QW.QWidget] = None,
+ ):
+ super().__init__(parent, title)
self.signals = signals
# Widgets
self.tab_widget = QW.QTabWidget()
- self.prop_group = ResultProps("Properties")
+ self.prop_group = ResultProps(self)
+
self.comment_widget = TestCommentWidget(self.signals)
+ self.output_widget = ResultOutput()
+ self.error_widget = ResultError()
# Layouts
- self.vlayout = QW.QHBoxLayout(self)
+ self.vlayout = QW.QVBoxLayout(self)
+ self.vlayout.addWidget(self.title_label)
self.vlayout.addWidget(self.tab_widget)
- self.vlayout.addWidget(self.prop_group)
- # Config
- self.prop_group.setFixedWidth(350)
+ # Additional
+ self._notification_icon = get_icon("notification.svg")
+
+ self._tab_bar_connected = False
@property
def comment(self) -> str:
@@ -46,26 +59,71 @@ def props(self) -> Dict[str, Any]:
return self.prop_group.props
def set_item(self, test: Test):
+ """Set the item to be displayed in the widget.
+
+ Args:
+ test: The test to be displayed.
+ """
self.prop_group.set_item(test)
self.set_tabs(test)
+ def _reset_tab_icon(self, index: int):
+ """Reset the icon of the tab at the given index.
+
+ Args:
+ index: The index of the tab to reset.
+ """
+ if index in (1, 2) and self.tab_widget.tabIcon(index) is not None:
+ self.tab_widget.setTabIcon(index, QG.QIcon())
+
+ def _remove_tab_notif(self, test: Test) -> Callable[[int], None]:
+ """Create a callback to remove the notification icon from the tab.
+
+ Args:
+ test: The test to remove the notification from.
+
+ Returns:
+ The callback function that encapsulate the given test.
+ """
+
+ def callback(index: int):
+ self.tab_widget.setTabIcon(index, QG.QIcon())
+ if index == 1:
+ test.set_message_state(False)
+ elif index == 2:
+ test.set_error_state(False)
+
+ if not (test.is_message_new() or test.is_error_new()):
+ self.tab_widget.currentChanged.disconnect(callback)
+
+ return callback
+
def set_tabs(self, test: Test):
- self.comment_widget = TestCommentWidget(self.signals)
+ """Set the tabs of the widget.
+
+ Args:
+ test: The test to display in the tabs.
+ """
self.comment_widget.set_item(test)
current_tab_ind = self.tab_widget.currentIndex()
- output_widget = ResultOutput()
- output_widget.set_item(test)
+ self.output_widget.set_item(test)
- error_widget = ResultError()
- error_widget.set_item(test)
+ self.error_widget.set_item(test)
- for _index in range(self.tab_widget.count()):
- self.tab_widget.removeTab(0)
+ self.tab_widget.clear()
self.tab_widget.insertTab(0, self.comment_widget, "Comment")
- self.tab_widget.insertTab(1, output_widget, "Output message")
- self.tab_widget.insertTab(2, error_widget, "Error message")
+ self.tab_widget.insertTab(1, self.output_widget, "Output message")
+ self.tab_widget.insertTab(2, self.error_widget, "Error message")
+
+ if test.is_message_new():
+ self.tab_widget.setTabIcon(1, self._notification_icon)
+
+ if test.is_error_new():
+ self.tab_widget.setTabIcon(2, self._notification_icon)
+
+ self.tab_widget.currentChanged.connect(self._remove_tab_notif(test))
self.tab_widget.setCurrentIndex(current_tab_ind)
diff --git a/moduletester/gui/components/status_bar_component.py b/moduletester/gui/components/status_bar_component.py
index 99f2cf9..eded308 100644
--- a/moduletester/gui/components/status_bar_component.py
+++ b/moduletester/gui/components/status_bar_component.py
@@ -1,8 +1,11 @@
# pylint: disable=missing-module-docstring, missing-class-docstring
# pylint: disable=missing-function-docstring
-
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
+from moduletester.gui.external.pyqtspinner import WaitingSpinner
+
class TMStatusBar(QW.QStatusBar):
def __init__(self, parent: QW.QWidget = None):
@@ -10,14 +13,42 @@ def __init__(self, parent: QW.QWidget = None):
self.state_label = QW.QLabel()
self.path_label = QW.QLabel()
+ self.export_label = QW.QLabel()
+
+ self.export_widget = QW.QWidget()
+ export_layout = QW.QHBoxLayout()
+ export_layout.setAlignment(QC.Qt.AlignmentFlag.AlignHCenter)
+ export_layout.setContentsMargins(0, 0, 0, 0)
+ self.export_spinner = WaitingSpinner(
+ self.export_widget,
+ False,
+ radius=4,
+ roundness=0,
+ lines=25,
+ line_length=4,
+ line_width=2,
+ fade=100,
+ speed=3.1415 / 4,
+ color=QG.QColor("#0671D5"),
+ )
+
+ export_layout.addWidget(self.export_label)
+ export_layout.addWidget(self.export_spinner)
+ self.export_widget.setLayout(export_layout)
if parent is not None:
self.setFont(parent.font())
- self.insertWidget(0, self.state_label)
- self.insertWidget(1, self.path_label)
+ self.addWidget(self.state_label)
+ self.addWidget(self.path_label)
+ self.addWidget(self.export_widget)
def set_state_label(self, state_name: str):
+ """Set the state label text and visibility.
+
+ Args:
+ state_name: The state name to set.
+ """
if state_name != "":
self.state_label.setVisible(True)
self.state_label.setText(state_name)
@@ -25,8 +56,27 @@ def set_state_label(self, state_name: str):
self.state_label.setVisible(False)
def set_path_label(self, path: str):
+ """Set the path label text and visibility.
+
+ Args:
+ path: The path to set.
+ """
if path and path != "":
self.path_label.setVisible(True)
self.path_label.setText(path)
else:
self.path_label.setVisible(False)
+
+ def set_export_label(self, export: str):
+ """Set the export label text and spinner visibility.
+
+ Args:
+ export: The export label text to set (path and formats).
+ """
+ if export and export != "":
+ self.export_label.setText(export)
+ self.export_spinner.start()
+ self.export_widget.setVisible(True)
+ else:
+ self.export_widget.setVisible(False)
+ self.export_spinner.stop()
diff --git a/moduletester/gui/components/test_information.py b/moduletester/gui/components/test_information.py
index 212a478..43d6c41 100644
--- a/moduletester/gui/components/test_information.py
+++ b/moduletester/gui/components/test_information.py
@@ -1,6 +1,5 @@
# pylint: disable=missing-class-docstring, missing-module-docstring
# pylint: disable=missing-function-docstring
-from typing import Optional
from qtpy import QtCore as QC
from qtpy import QtGui as QG
@@ -13,8 +12,12 @@
from moduletester.model import Test
-class TestInformation(QW.QGroupBox):
- def __init__(self, signals: TMSignals, parent: Optional[QW.QWidget] = None):
+class TestInformation(QW.QWidget):
+ def __init__(
+ self,
+ signals: TMSignals,
+ parent: QW.QWidget,
+ ):
super().__init__(parent)
self.props = {
"name": "",
@@ -26,86 +29,104 @@ def __init__(self, signals: TMSignals, parent: Optional[QW.QWidget] = None):
self.test = None
self.signals = signals
# Widgets
- self.tab_widget = QW.QTabWidget()
+ self.tab_widget = QW.QTabWidget(parent=self)
+ self.description_tab = TestDescriptionWidget(self)
self.table_group = TestProps()
- self.description_tab = TestDescriptionWidget(self.signals)
# Layouts
- self.hlayout = QW.QHBoxLayout(self)
+ self.vlayout = QW.QVBoxLayout(self)
+ self.vlayout.addWidget(self.tab_widget)
- self.hlayout.addWidget(self.tab_widget)
- self.hlayout.addWidget(self.table_group)
-
- self.table_group.setFixedWidth(350)
self.table_group.setup()
- @property
- def description(self) -> str:
- return self.description_tab.desc_label.toPlainText()
-
def set_item(self, test: Test, origin_path: str):
- self.setTitle(test.package.full_name)
- text = self.description
+ """Set the item to be displayed in the description and properties widgets.
- current_tab_ind = self.tab_widget.currentIndex()
+ Args:
+ test: The test to be displayed.
+ origin_path: _description_
+ """
- self.hlayout.removeWidget(self.tab_widget)
+ current_tab_ind = self.tab_widget.currentIndex()
- self.description_tab = TestDescriptionWidget(self.signals)
self.description_tab.set_item(test)
- if not self.has_test_changed(test):
- self.description_tab.desc_label.setText(text)
-
- self.tab_widget = TabImageWidget(origin_path)
- self.tab_widget.create_tab(test)
- self.tab_widget.insertTab(0, self.description_tab, "Test description")
- self.tab_widget.setCurrentIndex(0)
- self.tab_widget.menu.open_image.triggered.connect( # type: ignore
+ new_tab_widget = TabImageWidget(origin_path)
+ new_tab_widget.create_tab(test)
+ new_tab_widget.insertTab(0, self.description_tab, test.package.last_name)
+ new_tab_widget.menu.open_image.triggered.connect( # type: ignore
self.open_image
)
+ self.vlayout.removeWidget(self.tab_widget)
- self.hlayout.insertWidget(0, self.tab_widget)
+ self.vlayout.insertWidget(0, new_tab_widget)
- self.tab_widget.setCurrentIndex(current_tab_ind)
+ new_tab_widget.setCurrentIndex(current_tab_ind)
self.table_group.set_props(test)
+ self.tab_widget = new_tab_widget
+
def has_test_changed(self, test: Test):
+ """Check if the current test has changed.
+
+ Args:
+ test: The test to check.
+
+ Returns:
+ True if the test has changed, False otherwise.
+ """
if test.package.last_name == self.props["name"]:
return False
return True
def open_image(self):
+ """Open the image in the current tab if the tab is a TabImageWidget."""
+ if not isinstance(self.tab_widget, TabImageWidget):
+ return
tab_index = self.tab_widget.currentIndex() - 1 # Compensate for test desc
image = self.tab_widget.images[tab_index]
QG.QDesktopServices.openUrl(QC.QUrl.fromLocalFile(image))
- def update_command(self, item: QW.QTreeWidgetItem, test: Test):
- if item.text(0) == "args":
- test.command_args = item.text(1)
- elif item.text(0) == "timeout":
- try:
- if item.text(1) != "0":
- test.command_timeout = int(item.text(1))
- else:
- test.command_timeout = 86400
- except ValueError:
- item.setText(1, str(test.command_timeout))
-
- if item.text(0) in (
- "timeout",
- "category",
- "save_path",
- "pattern",
- ) and item.text(1) not in ("", "0"):
- if item.text(0) in test.run_opts:
- opt_index = test.run_opts.index(item.text(0))
- test.run_opts[opt_index + 1] = item.text(1)
- else:
- test.run_opts.extend([item.text(0), item.text(1)])
- elif item.text(0) in ("timeout", "category", "save_path", "pattern"):
- if item.text(0) in test.run_opts:
- opt_index = test.run_opts.index(item.text(0))
+ def update_command(self, test: Test):
+ """Update the test command arguments of the given test.
+
+ Args:
+ test: The test to update.
+ """
+ info_dataset = self.table_group.dataset_gbox.dataset
+ test.command_args = info_dataset.args # type: ignore
+ test.command_timeout = info_dataset.timeout # type: ignore
+
+ for s in test.run_opts:
+ value = self.table_group.props.get(s, None)
+ is_zero_value = value in ("", "0", 0)
+
+ if not is_zero_value and value in test.run_opts:
+ opt_index = test.run_opts.index(s)
+ test.run_opts[opt_index + 1] = value
+ elif not is_zero_value:
+ test.run_opts.extend((s, value))
+ elif is_zero_value and value in test.run_opts:
+ opt_index = test.run_opts.index(s)
test.run_opts.remove(test.run_opts[opt_index + 1])
- test.run_opts.remove(item.text(0))
+ test.run_opts.remove(s)
+
+ self.validate_command(test)
+
+ def validate_command(self, test: Test) -> bool:
+ """Validate the command line arguments of the given test. If the command line
+ arguments are invalid, a message box will be shown to the user."""
+ try:
+ test.build_command()
+ return True
+ except ValueError as e:
+ QW.QMessageBox(
+ QW.QMessageBox.NoIcon,
+ "Command Error",
+ "The following error occured while parsing the "
+ "command line arguments:"
+ f"\n\n\t{str(e)}\n\n"
+ "Please check the command line arguments.",
+ ).exec()
+ return False
diff --git a/moduletester/gui/components/test_list_component.py b/moduletester/gui/components/test_list_component.py
new file mode 100644
index 0000000..55bcd4a
--- /dev/null
+++ b/moduletester/gui/components/test_list_component.py
@@ -0,0 +1,56 @@
+"""Test list dockable component."""
+
+from __future__ import annotations
+
+from typing import Optional
+
+import qtpy.QtWidgets as QW
+from guidata.configtools import get_icon
+
+from moduletester.gui.widgets import test_list_widget
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
+from moduletester.model import Test
+
+
+class TestListComponent(DockableQWidget):
+ """Wrapper for the TestListWidget and the additional butons and search bar.
+
+ Args:
+ tests: List of Test objects. Defaults to None.
+ title: Title of the widget (and dock widget). Defaults to "Tests".
+ parent: Parent widget. Defaults to None.
+ """
+
+ def __init__(
+ self,
+ tests: Optional[list[Test]] = None,
+ title: str = "Tests",
+ parent: Optional[QW.QWidget] = None,
+ ) -> None:
+ super().__init__(parent, title)
+
+ self.test_list_widget = test_list_widget.TestListWidget(tests, self)
+ self.list_layout = QW.QVBoxLayout()
+ self.collapse_all_btn = QW.QPushButton("Collapse all", self)
+ self.expand_all_btn = QW.QPushButton("Expand all", self)
+ self.search_bar = QW.QLineEdit(self)
+ self.search_bar.setPlaceholderText("Search test...")
+ self.run_btn = QW.QPushButton(get_icon("apply.png"), "Run Script", self)
+
+ self.setup()
+
+ def setup(self):
+ """Setup the layout and signal connections for the widget."""
+ self.list_layout = QW.QGridLayout()
+ top_controls_layout = QW.QHBoxLayout()
+ top_controls_layout.addWidget(self.collapse_all_btn)
+ top_controls_layout.addWidget(self.expand_all_btn)
+ top_controls_layout.addWidget(self.search_bar)
+ self.list_layout.addLayout(top_controls_layout, 0, 0, 1, 1)
+ self.list_layout.addWidget(self.test_list_widget, 1, 0, 7, 1)
+ self.list_layout.addWidget(self.run_btn, 8, 0, 1, 1)
+ self.setLayout(self.list_layout)
+
+ self.collapse_all_btn.clicked.connect(self.test_list_widget.collapseAll)
+ self.expand_all_btn.clicked.connect(self.test_list_widget.expandAll)
+ self.search_bar.textChanged.connect(self.test_list_widget.filter_items)
diff --git a/moduletester/gui/components/tool_bar_component.py b/moduletester/gui/components/tool_bar_component.py
index 4fcc537..494551a 100644
--- a/moduletester/gui/components/tool_bar_component.py
+++ b/moduletester/gui/components/tool_bar_component.py
@@ -6,45 +6,63 @@
from guidata.configtools import get_icon # type: ignore
from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
+from qtpy.QtWidgets import QAction
-CTRL = QC.Qt.CTRL
-SHIFT = QC.Qt.SHIFT
+CTRL = QC.Qt.Modifier.CTRL
+SHIFT = QC.Qt.Modifier.SHIFT
class TestManagerToolbar(QW.QToolBar):
def __init__(self, parent: Optional[QW.QWidget] = None):
+ """Top toolbar.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ """
super().__init__(parent)
# Fields
- self.file_actions: List[QW.QAction] = []
- self.test_actions: List[QW.QAction] = []
+ self.file_actions: List[QAction] = []
+ self.test_actions: List[QAction] = []
# File Actions
- self.save_action = QW.QAction(get_icon("filesave.png"), "Save")
- self.save_as_action = QW.QAction(get_icon("filesaveas.png"), "Save As")
- self.open_action = QW.QAction(get_icon("fileopen.png"), "Open")
- self.new_file_action = QW.QAction(get_icon("filenew.png"), "New")
+ self.save_action = QAction(get_icon("libre-gui-save.svg"), "Save")
+ self.save_as_action = QAction(get_icon("libre-gui-save-as.svg"), "Save As")
+ self.open_action = QAction(get_icon("libre-gui-folder-open.svg"), "Open")
+ self.update_action = QAction(get_icon("libre-gui-refresh.svg"), "Reload tests")
+ self.new_file_action = QAction(
+ get_icon("libre-gui-new-file-document.svg"), "New"
+ )
# Expt action
self.export_menu = QW.QMenu()
- self.export_action = QW.QAction("Export")
- self.export_dtv_action = QW.QAction("Export dtv")
- self.export_rtv_action = QW.QAction("Export rtv")
+ self.export_action = QAction("Export all documents")
+ self.export_test_list_action = QAction("Export Test List Document")
+ self.export_test_results_action = QAction("Export Test Results Document")
self.export_tool_btn = QW.QToolButton()
# Test Actions
- self.run_action = QW.QAction("Run")
- self.stop_action = QW.QAction("Stop")
- self.restart_action = QW.QAction("Restart")
+ self.run_action = QAction("Run")
+ self.stop_action = QAction("Stop")
+ self.restart_action = QAction("Restart")
+
+ # Other actions
+ self.view_menu = QW.QMenu("View")
+ self.view_tool_btn = QW.QToolButton()
+ self.view_tool_btn.setIcon(get_icon("dock.svg"))
+ self.view_tool_btn.setDisabled(True)
+ self.setContextMenuPolicy(QC.Qt.ContextMenuPolicy.PreventContextMenu)
# Setup
self.setup()
def setup(self):
+ """Setup the toolbar menus and actions."""
self.setup_export()
# Actions
self.file_actions = [
self.new_file_action,
self.open_action,
+ self.update_action,
self.save_action,
self.save_as_action,
]
@@ -63,30 +81,48 @@ def setup(self):
self.addWidget(self.export_tool_btn)
self.addSeparator()
self.addActions(self.test_actions)
+ self.addSeparator()
+ self.addWidget(self.view_tool_btn)
+
+ def setup_view(self, view_menu: QW.QMenu) -> None:
+ """Setup the view (docks) menu for the toolbar.
+
+ Args:
+ view_menu: The view menu to be added to the toolbar.
+ """
+ self.view_menu = view_menu
+ self.view_tool_btn.setMenu(self.view_menu)
+ self.view_tool_btn.setPopupMode(QW.QToolButton.InstantPopup)
def setup_export(self):
+ """Setup the export menu for the toolbar."""
self.export_menu.addAction(self.export_action)
self.export_menu.addSeparator()
- self.export_menu.addActions([self.export_dtv_action, self.export_rtv_action])
+ self.export_menu.addActions(
+ [self.export_test_list_action, self.export_test_results_action]
+ )
self.export_tool_btn.setMenu(self.export_menu)
self.export_tool_btn.setPopupMode(QW.QToolButton.InstantPopup)
- self.export_tool_btn.setIcon(get_icon("edit.png"))
+ self.export_tool_btn.setIcon(get_icon("libre-gui-export-doc.svg"))
def setup_shortcuts(self):
- self.new_file_action.setShortcut(CTRL + QC.Qt.Key_N)
- self.save_action.setShortcut(CTRL + QC.Qt.Key_S)
- self.open_action.setShortcut(CTRL + QC.Qt.Key_O)
- self.save_as_action.setShortcut(CTRL + SHIFT + QC.Qt.Key_S)
+ """Setup the shortcuts for the toolbar actions."""
+ self.new_file_action.setShortcut(CTRL + QC.Qt.Key.Key_N)
+ self.save_action.setShortcut(CTRL + QC.Qt.Key.Key_S)
+ self.open_action.setShortcut(CTRL + QC.Qt.Key.Key_O)
+ self.update_action.setShortcut(CTRL + QC.Qt.Key.Key_R)
+ self.save_as_action.setShortcut(CTRL + SHIFT + QC.Qt.Key.Key_S)
- self.export_action.setShortcut(CTRL + QC.Qt.Key_E)
- self.export_dtv_action.setShortcut(CTRL + QC.Qt.Key_D)
- self.export_rtv_action.setShortcut(CTRL + QC.Qt.Key_R)
+ self.export_action.setShortcut(CTRL + QC.Qt.Key.Key_E)
+ self.export_test_list_action.setShortcut(CTRL + QC.Qt.Key.Key_D)
+ self.export_test_results_action.setShortcut(CTRL + QC.Qt.Key.Key_R)
- self.run_action.setShortcut(QC.Qt.Key_F5)
- self.stop_action.setShortcut(SHIFT + QC.Qt.Key_F5)
- self.restart_action.setShortcut(CTRL + SHIFT + QC.Qt.Key_F5)
+ self.run_action.setShortcut(QC.Qt.Key.Key_F5)
+ self.stop_action.setShortcut(SHIFT + QC.Qt.Key.Key_F5)
+ self.restart_action.setShortcut(CTRL + SHIFT + QC.Qt.Key.Key_F5)
def setup_tooltips(self):
+ """Setup the tooltips for the toolbar actions."""
for action in [*self.file_actions, *self.test_actions]:
tooltip = f"{action.text()} ({action.shortcut().toString()})"
action.setToolTip(tooltip)
diff --git a/moduletester/gui/external/__init__.py b/moduletester/gui/external/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/moduletester/gui/external/pyqtspinner/__init__.py b/moduletester/gui/external/pyqtspinner/__init__.py
new file mode 100644
index 0000000..0c2f242
--- /dev/null
+++ b/moduletester/gui/external/pyqtspinner/__init__.py
@@ -0,0 +1,3 @@
+"""Bundled pyqtspinner widget."""
+
+from .spinner import WaitingSpinner # noqa: F401
diff --git a/moduletester/gui/external/pyqtspinner/configurator.py b/moduletester/gui/external/pyqtspinner/configurator.py
new file mode 100644
index 0000000..2aae950
--- /dev/null
+++ b/moduletester/gui/external/pyqtspinner/configurator.py
@@ -0,0 +1,214 @@
+"""Spinner configurator widget."""
+
+# noqa
+import math
+import sys
+from random import random
+
+from qtpy.QtCore import Qt, Slot
+from qtpy.QtWidgets import (
+ QApplication,
+ QColorDialog,
+ QDoubleSpinBox,
+ QGridLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QMessageBox,
+ QPushButton,
+ QSpinBox,
+ QWidget,
+)
+
+from .spinner import WaitingSpinner
+
+
+# pylint: disable=too-many-instance-attributes,too-many-statements
+class SpinnerConfigurator(QWidget):
+ """Interactive configurator for the WaitingSpinner widget."""
+
+ sb_roundness = None
+ sb_opacity = None
+ sb_fadeperc = None
+ sb_lines = None
+ sb_line_length = None
+ sb_line_width = None
+ sb_inner_radius = None
+ sb_rev_s = None
+
+ btn_start = None
+ btn_stop = None
+ btn_pick_color = None
+
+ spinner = None
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.init_ui()
+
+ def init_ui(self) -> None:
+ """Initialize ui."""
+ grid = QGridLayout()
+ groupbox1 = QGroupBox()
+ groupbox1_layout = QHBoxLayout()
+ groupbox2 = QGroupBox()
+ groupbox2_layout = QGridLayout()
+ button_hbox = QHBoxLayout()
+ self.setLayout(grid)
+ self.setWindowTitle("QtWaitingSpinner Configurator")
+ self.setWindowFlags(Qt.Dialog)
+
+ # SPINNER
+ self.spinner = WaitingSpinner(self)
+
+ # Spinboxes
+ self.sb_roundness = QDoubleSpinBox()
+ self.sb_opacity = QDoubleSpinBox()
+ self.sb_fadeperc = QDoubleSpinBox()
+ self.sb_lines = QSpinBox()
+ self.sb_line_length = QSpinBox()
+ self.sb_line_width = QSpinBox()
+ self.sb_inner_radius = QSpinBox()
+ self.sb_rev_s = QDoubleSpinBox()
+
+ # set spinbox default values
+ self.sb_roundness.setValue(100)
+ self.sb_roundness.setRange(0, 9999)
+ self.sb_opacity.setValue(math.pi)
+ self.sb_opacity.setRange(0, 9999)
+ self.sb_fadeperc.setValue(80)
+ self.sb_fadeperc.setRange(0, 9999)
+ self.sb_lines.setValue(20)
+ self.sb_lines.setRange(1, 9999)
+ self.sb_line_length.setValue(10)
+ self.sb_line_length.setRange(0, 9999)
+ self.sb_line_width.setValue(2)
+ self.sb_line_width.setRange(0, 9999)
+ self.sb_inner_radius.setValue(10)
+ self.sb_inner_radius.setRange(0, 9999)
+ self.sb_rev_s.setValue(math.pi / 2)
+ self.sb_rev_s.setRange(0.1, 9999)
+
+ # Buttons
+ self.btn_start = QPushButton("Start")
+ self.btn_stop = QPushButton("Stop")
+ self.btn_pick_color = QPushButton("Pick Color")
+ self.btn_randomize = QPushButton("Randomize")
+ self.btn_show_init = QPushButton("Show init args")
+
+ # Connects
+ self.sb_roundness.valueChanged.connect(
+ lambda x: setattr(self.spinner, "roundness", x)
+ )
+ self.sb_opacity.valueChanged.connect(
+ lambda x: setattr(self.spinner, "minimum_trail_opacity", x)
+ )
+ self.sb_fadeperc.valueChanged.connect(
+ lambda x: setattr(self.spinner, "trail_fade_percentage", x)
+ )
+ self.sb_lines.valueChanged.connect(
+ lambda x: setattr(self.spinner, "number_of_lines", x)
+ )
+ self.sb_line_length.valueChanged.connect(
+ lambda x: setattr(self.spinner, "line_length", x)
+ )
+ self.sb_line_width.valueChanged.connect(
+ lambda x: setattr(self.spinner, "line_width", x)
+ )
+ self.sb_inner_radius.valueChanged.connect(
+ lambda x: setattr(self.spinner, "inner_radius", x)
+ )
+ self.sb_rev_s.valueChanged.connect(
+ lambda x: setattr(self.spinner, "revolutions_per_second", x)
+ )
+
+ self.btn_start.clicked.connect(self.spinner.start)
+ self.btn_stop.clicked.connect(self.spinner.stop)
+ self.btn_pick_color.clicked.connect(self.show_color_picker)
+ self.btn_randomize.clicked.connect(self._randomize)
+ self.btn_show_init.clicked.connect(self.show_init_args)
+
+ # Layout adds
+ groupbox1_layout.addWidget(self.spinner)
+ groupbox1.setLayout(groupbox1_layout)
+
+ groupbox2_layout.addWidget(QLabel("Roundness:"), *(1, 1))
+ groupbox2_layout.addWidget(self.sb_roundness, *(1, 2))
+ groupbox2_layout.addWidget(QLabel("Opacity:"), *(2, 1))
+ groupbox2_layout.addWidget(self.sb_opacity, *(2, 2))
+ groupbox2_layout.addWidget(QLabel("Fade Perc:"), *(3, 1))
+ groupbox2_layout.addWidget(self.sb_fadeperc, *(3, 2))
+ groupbox2_layout.addWidget(QLabel("Lines:"), *(4, 1))
+ groupbox2_layout.addWidget(self.sb_lines, *(4, 2))
+ groupbox2_layout.addWidget(QLabel("Line Length:"), *(5, 1))
+ groupbox2_layout.addWidget(self.sb_line_length, *(5, 2))
+ groupbox2_layout.addWidget(QLabel("Line Width:"), *(6, 1))
+ groupbox2_layout.addWidget(self.sb_line_width, *(6, 2))
+ groupbox2_layout.addWidget(QLabel("Inner Radius:"), *(7, 1))
+ groupbox2_layout.addWidget(self.sb_inner_radius, *(7, 2))
+ groupbox2_layout.addWidget(QLabel("Rev/s:"), *(8, 1))
+ groupbox2_layout.addWidget(self.sb_rev_s, *(8, 2))
+
+ groupbox2.setLayout(groupbox2_layout)
+
+ button_hbox.addWidget(self.btn_start)
+ button_hbox.addWidget(self.btn_stop)
+ button_hbox.addWidget(self.btn_pick_color)
+ button_hbox.addWidget(self.btn_randomize)
+ button_hbox.addWidget(self.btn_show_init)
+
+ grid.addWidget(groupbox1, *(1, 1))
+ grid.addWidget(groupbox2, *(1, 2))
+ grid.addLayout(button_hbox, *(2, 1))
+
+ self.spinner.start()
+ self.show()
+
+ @Slot(name="randomize")
+ def _randomize(self) -> None:
+ self.sb_roundness.setValue(random() * 1000)
+ self.sb_opacity.setValue(random() * 50)
+ self.sb_fadeperc.setValue(random() * 100)
+ self.sb_lines.setValue(math.floor(random() * 150))
+ self.sb_line_length.setValue(math.floor(10 + random() * 20))
+ self.sb_line_width.setValue(math.floor(random() * 30))
+ self.sb_inner_radius.setValue(math.floor(random() * 30))
+ self.sb_rev_s.setValue(random())
+
+ @Slot(name="show_color_picker")
+ def show_color_picker(self) -> None:
+ """Set the color for the spinner."""
+ assert self.spinner
+ self.spinner.color = QColorDialog.getColor()
+
+ @Slot(name="show_init_args")
+ def show_init_args(self) -> None:
+ """Display used arguments."""
+ assert self.spinner
+ text = (
+ f"WaitingSpinner(\n parent,\n "
+ f"roundness={self.spinner.roundness},\n "
+ f"opacity={self.spinner.minimum_trail_opacity},\n "
+ f"fade={self.spinner.trail_fade_percentage},\n "
+ f"radius={self.spinner.inner_radius},\n "
+ f"lines={self.spinner.number_of_lines},\n "
+ f"line_length={self.spinner.line_length},\n "
+ f"line_width={self.spinner.line_width},\n "
+ f"speed={self.spinner.revolutions_per_second},\n "
+ f"color={self.spinner.color.getRgb()[:3]}\n)\n"
+ )
+ msg_box = QMessageBox()
+ msg_box.setText(text)
+ msg_box.setWindowTitle("Text was copied to clipboard")
+ clipboard = QApplication.clipboard()
+ clipboard.clear(mode=clipboard.Clipboard)
+ clipboard.setText(text, mode=clipboard.Clipboard)
+ print(text)
+ msg_box.exec_()
+
+
+def main():
+ """Launch the spinner configurator application."""
+ app = QApplication(sys.argv)
+ configurator = SpinnerConfigurator() # noqa
+ sys.exit(app.exec())
diff --git a/moduletester/gui/external/pyqtspinner/spinner.py b/moduletester/gui/external/pyqtspinner/spinner.py
new file mode 100644
index 0000000..c67f4b8
--- /dev/null
+++ b/moduletester/gui/external/pyqtspinner/spinner.py
@@ -0,0 +1,312 @@
+"""
+The MIT License (MIT)
+
+Copyright (c) 2012-2014 Alexander Turkin
+Copyright (c) 2014 William Hallatt
+Copyright (c) 2015 Jacob Dawid
+Copyright (c) 2016 Luca Weiss
+Copyright (c) 2017 fbjorn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+import math
+
+from qtpy.QtCore import QRect, Qt, QTimer
+from qtpy.QtGui import QColor, QPainter, QPaintEvent
+from qtpy.QtWidgets import QWidget
+
+
+# pylint: disable=too-many-instance-attributes,too-many-arguments
+class WaitingSpinner(QWidget):
+ """WaitingSpinner is a highly configurable, custom spinner widget."""
+
+ def __init__(
+ self,
+ parent: QWidget,
+ center_on_parent: bool = True,
+ disable_parent_when_spinning: bool = False,
+ modality: Qt.WindowModality = Qt.NonModal,
+ roundness: float = 100.0,
+ fade: float = 80.0,
+ lines: int = 20,
+ line_length: int = 10,
+ line_width: int = 2,
+ radius: int = 10,
+ speed: float = math.pi / 2,
+ color: QColor = QColor(0, 0, 0),
+ ) -> None:
+ super().__init__(parent)
+
+ self._center_on_parent: bool = center_on_parent
+ self._disable_parent_when_spinning: bool = disable_parent_when_spinning
+
+ self._color: QColor = color
+ self._roundness: float = roundness
+ self._minimum_trail_opacity: float = math.pi
+ self._trail_fade_percentage: float = fade
+ self._revolutions_per_second: float = speed
+ self._number_of_lines: int = lines
+ self._line_length: int = line_length
+ self._line_width: int = line_width
+ self._inner_radius: int = radius
+ self._current_counter: int = 0
+ self._is_spinning: bool = False
+
+ self._timer: QTimer = QTimer(self)
+ self._timer.timeout.connect(self._rotate)
+ self._update_size()
+ self._update_timer()
+ self.hide()
+
+ self.setWindowModality(modality)
+ self.setAttribute(Qt.WA_TranslucentBackground)
+
+ def paintEvent(self, _: QPaintEvent) -> None: # pylint: disable=invalid-name
+ """Paint the WaitingSpinner."""
+ self._update_position()
+ painter = QPainter(self)
+ painter.fillRect(self.rect(), Qt.transparent)
+ painter.setRenderHint(QPainter.Antialiasing, True)
+
+ if self._current_counter >= self._number_of_lines:
+ self._current_counter = 0
+
+ painter.setPen(Qt.NoPen)
+ for i in range(self._number_of_lines):
+ painter.save()
+ painter.translate(
+ self._inner_radius + self._line_length,
+ self._inner_radius + self._line_length,
+ )
+ rotate_angle = 360 * i / self._number_of_lines
+ painter.rotate(rotate_angle)
+ painter.translate(self._inner_radius, 0)
+ distance = self._line_count_distance_from_primary(
+ i, self._current_counter, self._number_of_lines
+ )
+ color = self._current_line_color(
+ distance,
+ self._number_of_lines,
+ self._trail_fade_percentage,
+ self._minimum_trail_opacity,
+ self._color,
+ )
+ painter.setBrush(color)
+ painter.drawRoundedRect(
+ QRect(
+ 0,
+ -self._line_width // 2,
+ self._line_length,
+ self._line_width,
+ ),
+ self._roundness,
+ self._roundness,
+ Qt.RelativeSize,
+ )
+ painter.restore()
+
+ def start(self) -> None:
+ """Show and start spinning the WaitingSpinner."""
+ self._update_position()
+ self._is_spinning = True
+ self.show()
+
+ if self.parentWidget and self._disable_parent_when_spinning:
+ self.parentWidget().setEnabled(False)
+
+ if not self._timer.isActive():
+ self._timer.start()
+ self._current_counter = 0
+
+ def stop(self) -> None:
+ """Hide and stop spinning the WaitingSpinner."""
+ self._is_spinning = False
+ self.hide()
+
+ if self.parentWidget() and self._disable_parent_when_spinning:
+ self.parentWidget().setEnabled(True)
+
+ if self._timer.isActive():
+ self._timer.stop()
+ self._current_counter = 0
+
+ @property
+ def color(self) -> QColor:
+ """Return color of WaitingSpinner."""
+ return self._color
+
+ @color.setter
+ def color(self, color: Qt.GlobalColor = Qt.black) -> None:
+ """Set color of WaitingSpinner."""
+ self._color = QColor(color)
+
+ @property
+ def roundness(self) -> float:
+ """Return roundness of WaitingSpinner."""
+ return self._roundness
+
+ @roundness.setter
+ def roundness(self, roundness: float) -> None:
+ """Set color of WaitingSpinner."""
+ self._roundness = max(0.0, min(100.0, roundness))
+
+ @property
+ def minimum_trail_opacity(self) -> float:
+ """Return minimum trail opacity of WaitingSpinner."""
+ return self._minimum_trail_opacity
+
+ @minimum_trail_opacity.setter
+ def minimum_trail_opacity(self, minimum_trail_opacity: float) -> None:
+ """Set minimum trail opacity of WaitingSpinner."""
+ self._minimum_trail_opacity = minimum_trail_opacity
+
+ @property
+ def trail_fade_percentage(self) -> float:
+ """Return trail fade percentage of WaitingSpinner."""
+ return self._trail_fade_percentage
+
+ @trail_fade_percentage.setter
+ def trail_fade_percentage(self, trail: float) -> None:
+ """Set trail fade percentage of WaitingSpinner."""
+ self._trail_fade_percentage = trail
+
+ @property
+ def revolutions_per_second(self) -> float:
+ """Return revolutions per second of WaitingSpinner."""
+ return self._revolutions_per_second
+
+ @revolutions_per_second.setter
+ def revolutions_per_second(self, revolutions_per_second: float) -> None:
+ """Set revolutions per second of WaitingSpinner."""
+ self._revolutions_per_second = revolutions_per_second
+ self._update_timer()
+
+ @property
+ def number_of_lines(self) -> int:
+ """Return number of lines of WaitingSpinner."""
+ return self._number_of_lines
+
+ @number_of_lines.setter
+ def number_of_lines(self, lines: int) -> None:
+ """Set number of lines of WaitingSpinner."""
+ self._number_of_lines = lines
+ self._current_counter = 0
+ self._update_timer()
+
+ @property
+ def line_length(self) -> int:
+ """Return line length of WaitingSpinner."""
+ return self._line_length
+
+ @line_length.setter
+ def line_length(self, length: int) -> None:
+ """Set line length of WaitingSpinner."""
+ self._line_length = length
+ self._update_size()
+
+ @property
+ def line_width(self) -> int:
+ """Return line width of WaitingSpinner."""
+ return self._line_width
+
+ @line_width.setter
+ def line_width(self, width: int) -> None:
+ """Set line width of WaitingSpinner."""
+ self._line_width = width
+ self._update_size()
+
+ @property
+ def inner_radius(self) -> int:
+ """Return inner radius size of WaitingSpinner."""
+ return self._inner_radius
+
+ @inner_radius.setter
+ def inner_radius(self, radius: int) -> None:
+ """Set inner radius size of WaitingSpinner."""
+ self._inner_radius = radius
+ self._update_size()
+
+ @property
+ def is_spinning(self) -> bool:
+ """Return actual spinning status of WaitingSpinner."""
+ return self._is_spinning
+
+ def _rotate(self) -> None:
+ """Rotate the WaitingSpinner."""
+ self._current_counter += 1
+ if self._current_counter >= self._number_of_lines:
+ self._current_counter = 0
+ self.update()
+
+ def _update_size(self) -> None:
+ """Update the size of the WaitingSpinner."""
+ size = (self._inner_radius + self._line_length) * 2
+ self.setFixedSize(size, size)
+
+ def _update_timer(self) -> None:
+ """Update the spinning speed of the WaitingSpinner."""
+ self._timer.setInterval(
+ int(1000 / (self._number_of_lines * self._revolutions_per_second))
+ )
+
+ def _update_position(self) -> None:
+ """Center WaitingSpinner on parent widget."""
+ if self.parentWidget() and self._center_on_parent:
+ self.move(
+ (self.parentWidget().width() - self.width()) // 2,
+ (self.parentWidget().height() - self.height()) // 2,
+ )
+
+ @staticmethod
+ def _line_count_distance_from_primary(
+ current: int, primary: int, total_nr_of_lines: int
+ ) -> int:
+ """Return the amount of lines from _current_counter."""
+ distance = primary - current
+ if distance < 0:
+ distance += total_nr_of_lines
+ return distance
+
+ @staticmethod
+ def _current_line_color(
+ count_distance: int,
+ total_nr_of_lines: int,
+ trail_fade_perc: float,
+ min_opacity: float,
+ color_input: QColor,
+ ) -> QColor:
+ """Returns the current color for the WaitingSpinner."""
+ color = QColor(color_input)
+ if count_distance == 0:
+ return color
+ min_alpha_f = min_opacity / 100.0
+ distance_threshold = int(
+ math.ceil((total_nr_of_lines - 1) * trail_fade_perc / 100.0)
+ )
+ if count_distance > distance_threshold:
+ color.setAlphaF(min_alpha_f)
+ else:
+ alpha_diff = color.alphaF() - min_alpha_f
+ gradient = alpha_diff / float(distance_threshold + 1)
+ result_alpha = color.alphaF() - gradient * count_distance
+ # If alpha is out of bounds, clip it.
+ result_alpha = min(1.0, max(0.0, result_alpha))
+ color.setAlphaF(result_alpha)
+ return color
diff --git a/moduletester/gui/main.py b/moduletester/gui/main.py
index dd83130..3713695 100644
--- a/moduletester/gui/main.py
+++ b/moduletester/gui/main.py
@@ -1,11 +1,11 @@
# pylint: disable=missing-module-docstring, missing-class-docstring
# pylint: disable=missing-function-docstring
-import sys
+import argparse
from importlib import import_module
from typing import Optional
-from qtpy import QtWidgets as QW
+from guidata.qthelpers import qt_app_context
from moduletester.gui.states.signals import TMSignals
from moduletester.gui.states.state_machine import TMStateMachine
@@ -160,20 +160,21 @@ def run(package: Optional[str] = None, path: Optional[str] = None) -> TestManage
return main
+def run_gui():
+ """Run the gui with arguments from command line."""
+ parser = argparse.ArgumentParser("Moduletester gui launcher")
+ parser.add_argument(
+ "-p", "--package", type=str, help="Package to load", default=None
+ )
+ parser.add_argument(
+ "-f", "--file", type=str, help="Moduletester file to load", default=None
+ )
+ args = parser.parse_args()
+
+ with qt_app_context(True):
+ main = run(args.package, args.file)
+ main.window.show()
+
+
if __name__ == "__main__":
- # import faulthandler
- # faulthandler.enable()
- # faulthandler.dump_traceback_later(60, False, exit=True)
-
- PATH = r"C:\_projets\moduletester\DataLab\run.moduletester"
- PACKAGE = "cdl"
- app = QW.QApplication.instance()
- if not app:
- app = QW.QApplication(sys.argv)
-
- # run(package=PACKAGE)
- # run(path=PATH)
- moduletester = run()
- moduletester.window.show()
-
- app.exec_()
+ run_gui()
diff --git a/moduletester/gui/widgets/abstract_widget.py b/moduletester/gui/widgets/abstract_widget.py
new file mode 100644
index 0000000..426b237
--- /dev/null
+++ b/moduletester/gui/widgets/abstract_widget.py
@@ -0,0 +1,13 @@
+"""Abstract base widget classes."""
+
+from abc import ABC, ABCMeta
+
+import qtpy.QtWidgets as QW
+
+
+class MetaAbstractQWidget(ABCMeta, type(QW.QWidget)):
+ """Metaclass that combines ABCMeta and QWidget metaclasses."""
+
+
+class AbstractQWidget(ABC, QW.QWidget, metaclass=MetaAbstractQWidget):
+ """Abstract QWidget."""
diff --git a/moduletester/gui/widgets/cli_widget.py b/moduletester/gui/widgets/cli_widget.py
index c0baab9..0e8a1ea 100644
--- a/moduletester/gui/widgets/cli_widget.py
+++ b/moduletester/gui/widgets/cli_widget.py
@@ -3,27 +3,35 @@
from typing import Optional
-from click import Context
+from guidata.config import CONF
+from guidata.configtools import get_font
from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
+from qtpy.QtWidgets import QAction
-from moduletester.manager import cli, run
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
from moduletester.model import Test
-class CLIWidget(QW.QGroupBox):
- def __init__(self, parent: Optional[QW.QWidget] = None):
- super().__init__(parent)
- self.setTitle("Command line")
+class CLIWidget(DockableQWidget):
+ def __init__(
+ self, parent: Optional[QW.QWidget] = None, title: str = "Command Line"
+ ) -> None:
+ super().__init__(parent=parent, title=title)
+
self.menu = CLIContextMenu()
self.command_label = QW.QLabel()
- self.command_label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse)
+ self.command_label.setTextInteractionFlags(
+ QC.Qt.TextInteractionFlag.TextSelectableByMouse
+ )
self.command_label.setWordWrap(True)
+ font = get_font(CONF, "codeeditor")
+ self.command_label.setFont(font)
self.vlayout = QW.QVBoxLayout(self)
+ self.vlayout.addWidget(self.title_label)
self.vlayout.addWidget(self.command_label)
- self.get_help()
self.menu.copy_cli_action.triggered.connect( # type: ignore
self.copy_command_line
@@ -36,47 +44,45 @@ def command(self):
return command_txt
def set_item(self, test: Test):
+ """Set the current test to display its command line.
+
+ Args:
+ test: Test from which to display the command line.
+ """
if test.command != "":
self.command_label.setText(test.command)
else:
self.command_label.setText("No command line available")
- self.command_label.setContextMenuPolicy(QC.Qt.CustomContextMenu)
+ self.command_label.setContextMenuPolicy(
+ QC.Qt.ContextMenuPolicy.CustomContextMenu
+ )
self.command_label.customContextMenuRequested.connect( # type: ignore
self.run_menu
)
def run_menu(self, point: QC.QPoint):
- self.menu.exec_(self.command_label.mapToGlobal(point))
+ """Run the context menu.
- def get_help(self):
- ctx = Context(cli)
- run_help = run.get_help(ctx)
- options = run_help.split("Options:\n")[-1]
- options_no_help = options.split("\n --help")[0]
- return options_no_help
-
- def get_run_options(self, test: Test):
- ctx = Context(cli)
- run_params = run.get_params(ctx)
- run_options = ""
- for param in run_params:
- if param.name in test.run_opts:
- opt_index = test.run_opts.index(param.name)
- opt_str = f"{param.opts[0]} {test.run_opts[opt_index + 1]} "
- run_options += opt_str
- return run_options
+ Args:
+ point: Point where the context menu was requested.
+ """
+ self.menu.exec_(self.command_label.mapToGlobal(point))
def copy_command_line(self):
+ """Copy the command line to the clipboard."""
app = QW.QApplication.instance()
clipboard = app.clipboard()
clipboard.setText(self.command)
class CLIContextMenu(QW.QMenu):
+ """Context menu for the command line widget."""
+
def __init__(self, parent: Optional[QW.QWidget] = None) -> None:
super().__init__(parent)
# Actions
- self.copy_cli_action = QW.QAction("Copy Command Line")
+ self.copy_cli_action = QAction("Copy Command Line")
self.addAction(self.copy_cli_action)
+ self.addAction(self.copy_cli_action)
diff --git a/moduletester/gui/widgets/config_editor.py b/moduletester/gui/widgets/config_editor.py
new file mode 100644
index 0000000..21d141e
--- /dev/null
+++ b/moduletester/gui/widgets/config_editor.py
@@ -0,0 +1,126 @@
+"""Configuration editor widget."""
+
+from __future__ import annotations
+
+import os
+
+from PyQt5.QtWidgets import QWidget
+from qtpy import QtWidgets as QW
+
+import moduletester.config as cfg
+from moduletester.gui.states.signals import TMSignals
+from moduletester.gui.widgets.editor_widget import Editor
+
+
+class ConfigEditor(Editor):
+ """Widget for editing ModuleTester configuration files."""
+
+ def __init__(
+ self,
+ parent: QWidget | None,
+ signals: TMSignals,
+ title: str = "Configuration Editor",
+ ) -> None:
+ """Editor for the configuration file.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ signals: Signals object that contains shared global ModuleTester signals.
+ title: Widget title. Defaults to "Configuration Editor".
+ """
+ self.signals = signals
+ super().__init__(parent, title, language="yaml")
+
+ def setup(self):
+ """Setup the widget."""
+ super().setup()
+ self.read_config()
+ self.sig_save_text.connect(self.save_config)
+ self.signals.SIG_PROJECT_LOADED.connect(self.read_config)
+ self.config_path = os.path.join(
+ cfg.MODULETESTER_CONFIG_DIR, cfg.MODULETESTER_CONFIG_NAME
+ )
+
+ def save_config(self) -> None:
+ """Tries to save the configuration file. This methods can handle errors and
+ conflicts in the configuration file. by prompting with dialog boxes."""
+ do_save = True
+ if os.path.exists(self.config_path):
+ do_save = (
+ QW.QMessageBox.question(
+ self,
+ "Overwrite configuration file?",
+ f"Do you want to overwrite the existing file?\n{self.config_path}",
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ )
+ == QW.QMessageBox.Yes
+ )
+ if do_save:
+ config_content = self.get_text()
+ try:
+ cfg.load_conf_from_string(config_content)
+ except cfg.ConfigConflictError as e:
+ result = (
+ QW.QMessageBox.critical(
+ self,
+ "Error in configuration file",
+ f"Error in configuration file: {self.config_path}\n{str(e)}"
+ "\nDo you want to fix the error and save the file?",
+ QW.QMessageBox.Apply | QW.QMessageBox.Cancel,
+ )
+ == QW.QMessageBox.Apply
+ )
+ if not result:
+ return
+ cfg.load_conf_from_string(config_content, resolve=True)
+ self.set_text(cfg.conf_obj_to_str(cfg.PACKAGE_CONF))
+
+ except cfg.InvalidPathError as e:
+ QW.QMessageBox.critical(
+ self,
+ "Configuration file contains invalid values",
+ f"Configuration file {self.config_path} contains "
+ f"invalid value:\n {e.key} = {e.value}",
+ QW.QMessageBox.Cancel,
+ )
+
+ except Exception as e:
+ QW.QMessageBox.critical(
+ self,
+ "Configuration file is invalid",
+ f"While Parsing the new configuration file, an error "
+ f"occurred:\n{str(e)}\n\n"
+ "The file will not be saved.",
+ QW.QMessageBox.Cancel,
+ )
+ cfg.save_config(cfg.PACKAGE_CONF, self.config_path)
+ self.saved()
+
+ def read_config(self) -> None:
+ """Read the configuration file and display it in the editor. Can save the file
+ if it does not exist."""
+ cfg_exists = False
+ config_content = cfg.conf_obj_to_str(cfg.PACKAGE_CONF)
+ self.config_path = os.path.join(
+ cfg.MODULETESTER_CONFIG_DIR, cfg.MODULETESTER_CONFIG_NAME
+ )
+ cfg_exists = os.path.exists(self.config_path)
+ save_new_cfg = False
+ if not cfg_exists:
+ save_new_cfg = (
+ QW.QMessageBox.question(
+ self,
+ "File not found",
+ f"Config file not found at {self.config_path}.\n"
+ "Do you want to create a new config file?",
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ )
+ == QW.QMessageBox.Yes
+ )
+
+ if save_new_cfg and not cfg_exists:
+ self.save_config()
+
+ self.set_text(config_content)
+ self.change_saved = True
+ self.editor_save_btn.setEnabled(False)
diff --git a/moduletester/gui/widgets/dock_wrapper.py b/moduletester/gui/widgets/dock_wrapper.py
new file mode 100644
index 0000000..d872ef2
--- /dev/null
+++ b/moduletester/gui/widgets/dock_wrapper.py
@@ -0,0 +1,60 @@
+"""QDockWidget wrapper for dockable widgets."""
+
+from __future__ import annotations
+
+from typing import Generic, Optional, TypeVar
+
+import qtpy.QtCore as QC
+import qtpy.QtWidgets as QW
+
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
+
+STR_TO_DOCK_AREA: dict[str, QC.Qt.DockWidgetArea] = {
+ "left": QC.Qt.DockWidgetArea.LeftDockWidgetArea,
+ "right": QC.Qt.DockWidgetArea.RightDockWidgetArea,
+ "top": QC.Qt.DockWidgetArea.TopDockWidgetArea,
+ "bottom": QC.Qt.DockWidgetArea.BottomDockWidgetArea,
+}
+
+AnyDockableWidget = TypeVar("AnyDockableWidget", bound=DockableQWidget)
+
+
+class QDockWrapper(QW.QDockWidget, Generic[AnyDockableWidget]):
+ """QDockWidget wrapper for dockable widgets."""
+
+ def __init__(
+ self,
+ parent: Optional[QW.QWidget],
+ widget: AnyDockableWidget,
+ title: Optional[str] = None,
+ ) -> None:
+ """Wrapper for DockableQWidget to transform it into a usable QDockWidget.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ widget: DockableQWidget to wrap into a QDockWidget.
+ title: Dock widget title. If None, will default to the given widget title.
+ Defaults to None.
+ """
+ widget_title_label: QW.QLabel | None = getattr(widget, "title_label", None)
+ if widget_title_label is not None:
+ widget_title_label.hide()
+ title = title or widget_title_label.text() if widget_title_label else ""
+
+ super().__init__(title, parent)
+ self.setWidget(widget)
+ self.setFeatures(QW.QDockWidget.DockWidgetFeature.AllDockWidgetFeatures)
+ # self.setFloating(False)
+ # self.setContextMenuPolicy(QC.Qt.ContextMenuPolicy.CustomContextMenu)
+
+ @staticmethod
+ def get_area_from_str(area: str) -> QC.Qt.DockWidgetArea:
+ """Get the QDockWidgetArea from a string.
+
+ Args:
+ area: String representation of the dock area.
+
+ Returns:
+ The QDockWidgetArea corresponding to the given string.
+ """
+ return STR_TO_DOCK_AREA.get(area, QC.Qt.DockWidgetArea.RightDockWidgetArea)
diff --git a/moduletester/gui/widgets/dockable_widget.py b/moduletester/gui/widgets/dockable_widget.py
new file mode 100644
index 0000000..a3bcd26
--- /dev/null
+++ b/moduletester/gui/widgets/dockable_widget.py
@@ -0,0 +1,24 @@
+"""Dockable widget base class."""
+
+from typing import Optional
+
+import qtpy.QtWidgets as QW
+
+from moduletester.gui.widgets.abstract_widget import AbstractQWidget
+
+
+class DockableQWidget(AbstractQWidget):
+ """QWidget with a title label, usable as a dockable panel."""
+
+ def __init__(self, parent: Optional[QW.QWidget], title: str = "") -> None:
+ """Normal QWidget with a title and a label. This class is meant to be used as a
+ base class for optionnally dockable widgets by being wrapped into a
+ QDockWrapper object.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ title: Widget title. Defaults to "".
+ """
+ super().__init__(parent)
+ self.title = title
+ self.title_label = QW.QLabel(self.title)
diff --git a/moduletester/gui/widgets/editor_widget.py b/moduletester/gui/widgets/editor_widget.py
new file mode 100644
index 0000000..5282f47
--- /dev/null
+++ b/moduletester/gui/widgets/editor_widget.py
@@ -0,0 +1,158 @@
+"""Text editor widget with save/close support."""
+
+from __future__ import annotations
+
+from abc import ABC
+from typing import Optional
+
+from guidata.qthelpers import get_icon
+from guidata.widgets import codeeditor
+from PyQt5.QtGui import QCloseEvent
+from PyQt5.QtWidgets import QWidget
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
+
+
+class DialogEditor(codeeditor.CodeEditor):
+ """Code editor with save and content sync signals."""
+
+ sig_update_content = QC.Signal() # type: ignore
+ sig_save_key = QC.Signal() # type: ignore
+
+ def __init__(
+ self,
+ parent: Optional[QW.QWidget] = None,
+ language=None,
+ font=None,
+ columns=None,
+ rows=None,
+ ):
+ super().__init__(parent, language, font, columns, rows) # type: ignore
+ self.content_is_synched = True
+
+ def closeEvent(self, event: QCloseEvent) -> None: # type: ignore # noqa: N802
+ """Handle close event, emitting update signal if content changed."""
+ if not self.content_is_synched:
+ self.sig_update_content.emit()
+ super().closeEvent(event)
+
+ def keyPressEvent(self, event: QG.QKeyEvent): # type: ignore # noqa: N802
+ """Handle key press, detecting Ctrl+S for save."""
+ super().keyPressEvent(event)
+ if (
+ event.modifiers() == QC.Qt.ControlModifier
+ and event.key() == QC.Qt.Key.Key_S
+ ):
+ self.sig_save_key.emit()
+ self.content_is_synched = True
+ else:
+ self.content_is_synched = False
+
+
+class Editor(DockableQWidget, ABC):
+ """Editor widget with a read-only code editor and a button to open a popup editor.
+ The editors are guidata.widgets.codeeditor.CodeEditor objects.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ title: Widget title. Defaults to "Editor".
+ language: Language to use for the code editor (see guidata documentation).
+ Defaults to None.
+ additional_btns: Additional buttons to add to the widget. Defaults to None.
+ """
+
+ sig_save_text = QC.Signal(str) # type: ignore
+
+ def __init__(
+ self,
+ parent: QWidget | None,
+ title: str = "Editor",
+ language: str | None = None,
+ additional_btns: Optional[list[QW.QPushButton]] = None,
+ ) -> None:
+ super().__init__(parent, title)
+
+ self._vlayout = QW.QVBoxLayout()
+ self._hlayout = QW.QHBoxLayout()
+
+ self.editor_edit_btn = QW.QPushButton(
+ get_icon("libre-gui-action-edit.svg"), "Edit"
+ )
+ self.editor_save_btn = QW.QPushButton(get_icon("libre-gui-save.svg"), "Save")
+ self.readonly_editor = codeeditor.CodeEditor(self, language=language, rows=True)
+ self.readonly_editor.setReadOnly(True)
+ self.popup_editor = DialogEditor(
+ self,
+ columns=100,
+ rows=45,
+ language=language,
+ )
+ self.popup_editor.sig_save_key.connect(self.update_content)
+ self.popup_editor.sig_save_key.connect(self.save_text)
+ self.additional_btns = additional_btns or []
+
+ self.change_saved = True
+
+ self.setup()
+
+ def set_text(self, text: str) -> None:
+ """Set the text of the readonly editor.
+
+ Args:
+ text: Text to set.
+ """
+ self.readonly_editor.setPlainText(text)
+
+ def get_text(self) -> str:
+ """Get the text of the readonly editor."""
+ return self.readonly_editor.toPlainText()
+
+ def update_content(self) -> None:
+ """Update the content of the readonly editor with the content of the popup
+ editor."""
+ self.readonly_editor.setPlainText(self.popup_editor.toPlainText())
+ self.change_saved = False
+ self.editor_save_btn.setEnabled(True)
+
+ def open_popup_editor(self):
+ """Open the popup editor with the content of the readonly editor."""
+ self.popup_editor.setPlainText(self.readonly_editor.toPlainText())
+ self.popup_editor.show()
+
+ def save_text(self) -> None:
+ """Emit the sig_save_text signal with the content of the readonly editor."""
+ self.sig_save_text.emit(self.readonly_editor.toPlainText())
+
+ def saved(self) -> None:
+ """Set the change_saved attribute to True and disable the save button."""
+ self.change_saved = True
+ self.editor_save_btn.setEnabled(False)
+
+ def setup(self):
+ """Setup the widget."""
+ self.editor_save_btn.clicked.connect(self.save_text)
+ self.editor_save_btn.setEnabled(False)
+ self.editor_edit_btn.clicked.connect(self.open_popup_editor)
+
+ self._vlayout.addWidget(self.readonly_editor)
+ self._hlayout.addWidget(self.editor_edit_btn)
+ self._hlayout.addWidget(self.editor_save_btn)
+ self._vlayout.addLayout(self._hlayout)
+
+ self.popup_editor.setWindowTitle("Edit...")
+ self.popup_editor.setWindowIcon(get_icon("libre-gui-action-edit.svg"))
+ self.popup_editor.setWindowFlags(QC.Qt.WindowType.Window)
+ self.popup_editor.sig_update_content.connect(self.update_content)
+
+ if len(self.additional_btns) > 0:
+ splitter = QW.QSplitter()
+ splitter.setStyleSheet("QSplitter::border {border: 1px solid #d3d3d3;}")
+ self._vlayout.addWidget(splitter)
+
+ for btn in self.additional_btns:
+ self._vlayout.addWidget(btn)
+
+ self.setLayout(self._vlayout)
diff --git a/moduletester/gui/widgets/result_comment.py b/moduletester/gui/widgets/result_comment.py
index e4269d7..a929865 100644
--- a/moduletester/gui/widgets/result_comment.py
+++ b/moduletester/gui/widgets/result_comment.py
@@ -14,43 +14,149 @@
from moduletester.model import Test
+class _CommentTextEdit(QW.QTextEdit):
+ """Custom QTextEdit can emit a specific signal when the user presses Ctrl+Z and all
+ available undo operations have been exhausted.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ """
+
+ sig_reset_comment = QC.Signal() # type: ignore
+
+ def __init__(self, parent: Optional[QW.QWidget] = None):
+ super().__init__(parent)
+ self.allow_reset_content = False
+ self.undoAvailable.connect(self.set_allow_reset_content)
+
+ def set_allow_reset_content(self, avail: bool):
+ """Set whether the content can be reset."""
+ self.allow_reset_content = not avail
+
+ def keyPressEvent(self, e: QG.QKeyEvent) -> None: # noqa: N802
+ """Handle key press events.
+
+ Args:
+ e: Key event.
+ """
+ super().keyPressEvent(e)
+ if (
+ self.allow_reset_content
+ and e.key() == QC.Qt.Key.Key_Z
+ and e.modifiers() == QC.Qt.ControlModifier
+ ):
+ self.sig_reset_comment.emit()
+ self.allow_reset_content = False
+
+
class TestCommentWidget(QW.QWidget):
+ """Widget to display and edit the comment of a test result.
+
+ Args:
+ signals: Signals object that contains shared global ModuleTester signals.
+ parent: Parent widget. Defaults to None.
+ """
+
+ SIG_EDIT_STOPPED = QC.Signal() # type: ignore
+
def __init__(self, signals: TMSignals, parent: Optional[QW.QWidget] = None):
super().__init__(parent)
+
+ self.cached_comments: dict[str, str] = {}
+
self.signals = signals
# Widgets
self.lbl_icon = QW.QLabel()
self.lbl_icon.setFixedWidth(32)
- self.comment_label = QW.QTextEdit()
+ self.comment_label = _CommentTextEdit()
self.comment_label.setWordWrapMode(QG.QTextOption.WordWrap)
self.comment_label.setFrameStyle(0)
for label in (self.comment_label, self.lbl_icon):
- label.setAlignment(QC.Qt.AlignTop)
+ label.setAlignment(QC.Qt.AlignmentFlag.AlignTop)
# Event Handlers
self.comment_label.textChanged.connect(self.text_changed) # type: ignore
+ self.comment_label.sig_reset_comment.connect(self.reset_comment)
+
+ self.timer = QC.QTimer()
+ self.timer.setSingleShot(True)
+ self.timer.setInterval(1000)
+
+ self.timer.timeout.connect(self.SIG_EDIT_STOPPED)
+ self.comment_label.textChanged.connect(self.text_changed)
+ self.SIG_EDIT_STOPPED.connect(self.update_cached_comment)
# Layouts
self.hlayout = QW.QHBoxLayout(self)
self.hlayout.addWidget(self.lbl_icon)
self.hlayout.addWidget(self.comment_label)
+ self.test: Optional[Test] = None
+
+ def reset_comment(self):
+ """Reset the comment to the last saved version."""
+ text = "No result yet"
+ if self.test is not None and self.test.result is not None:
+ text = self.test.result.comment
+ self.cached_comments.pop(self.test.package.full_name, None)
+ self.comment_label.setText(text)
+
+ def readonly(self, readonly: bool):
+ """Set the comment label to readonly or not.
+
+ Args:
+ readonly: Whether the comment label should be readonly.
+ """
+ if readonly:
+ self.comment_label.setTextInteractionFlags(
+ QC.Qt.TextInteractionFlag.TextSelectableByMouse
+ )
+ else:
+ self.comment_label.setTextInteractionFlags(
+ QC.Qt.TextInteractionFlag.TextEditorInteraction
+ )
+
def set_item(self, test: Test):
+ """Set the test item to display the comment of."""
+ # save previous changes if save timer was still running
+ if self.timer.isActive():
+ self.timer.stop()
+ self.update_cached_comment()
+
+ self.test = test
+ cached_comment = self.cached_comments.get(test.package.full_name, None)
if test.result is not None:
- text = test.result.comment
- self.comment_label.setTextInteractionFlags(QC.Qt.TextEditorInteraction)
+ text = test.result.comment if cached_comment is None else cached_comment
+ self.readonly(False)
+ elif test.is_running():
+ text = "No result yet"
+ self.readonly(False)
else:
text = "No result yet"
- self.comment_label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse)
+ self.readonly(True)
self.lbl_icon.setPixmap(get_std_icon("MessageBoxInformation").pixmap(24, 24))
-
self.comment_label.blockSignals(True)
self.comment_label.setText(text)
self.comment_label.blockSignals(False)
+ self.comment_label.undoAvailable.emit(False)
- def text_changed(self):
+ def text_changed(self) -> None:
+ """Emit SIG_EDIT_STOPPED after a delay so some actions are not trigger at
+ every key stroke."""
+ """Text has changed: restart the timer to emit SIG_EDIT_STOPPED after a delay"""
self.signals.SIG_PROJECT_MODIFIED.emit()
+ if self.timer.isActive():
+ self.timer.stop()
+ self.timer.start()
+
+ def update_cached_comment(self):
+ """Update the cached comment with the current comment."""
+ if self.test is None:
+ return
+ self.cached_comments[self.test.package.full_name] = (
+ self.comment_label.toPlainText()
+ )
diff --git a/moduletester/gui/widgets/result_error_widget.py b/moduletester/gui/widgets/result_error_widget.py
index 8b47f26..54f2a77 100644
--- a/moduletester/gui/widgets/result_error_widget.py
+++ b/moduletester/gui/widgets/result_error_widget.py
@@ -12,6 +12,12 @@
class ResultError(QW.QWidget):
+ """Widget to display the error message of a test result (stderr).
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ """
+
def __init__(self, parent: Optional[QW.QWidget] = None):
super().__init__(parent)
@@ -26,13 +32,16 @@ def __init__(self, parent: Optional[QW.QWidget] = None):
# Config
self.label.setWordWrapMode(QG.QTextOption.WordWrap)
- self.label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse)
- self.label.setAlignment(QC.Qt.AlignTop)
+ self.label.setTextInteractionFlags(
+ QC.Qt.TextInteractionFlag.TextSelectableByMouse
+ )
+ self.label.setAlignment(QC.Qt.AlignmentFlag.AlignTop)
self.label.setFrameStyle(0)
self.icon.setFixedWidth(32)
- self.icon.setAlignment(QC.Qt.AlignTop)
+ self.icon.setAlignment(QC.Qt.AlignmentFlag.AlignTop)
def set_item(self, test: Test):
+ """Set the test to display the error message of."""
if test.result is None:
text_level = "Information"
text = "No result yet"
diff --git a/moduletester/gui/widgets/result_props_widget.py b/moduletester/gui/widgets/result_props_widget.py
index 54c5e2c..ffd3f69 100644
--- a/moduletester/gui/widgets/result_props_widget.py
+++ b/moduletester/gui/widgets/result_props_widget.py
@@ -1,77 +1,107 @@
# pylint: disable=missing-module-docstring, missing-class-docstring
# pylint: disable=missing-function-docstring
+from __future__ import annotations
from typing import Any, Dict, Optional
+from guidata.configtools import get_icon
+from guidata.dataset import DataSet
+from guidata.dataset.dataitems import StringItem
+from guidata.dataset.qtwidgets import DataSetEditGroupBox
from qtpy import QtWidgets as QW
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
from moduletester.model import ResultEnum, Test
-class ResultProps(QW.QGroupBox):
+class _PropertiesDataSet(DataSet):
+ return_code = StringItem("Return code").set_prop("display", active=False)
+ execution_duration = StringItem("Execution duration").set_prop(
+ "display", active=False
+ )
+ last_run = StringItem("Last run").set_prop("display", active=False)
+ status = StringItem("Status").set_prop("display", active=False)
+
+
+class ResultProps(DockableQWidget):
+ """Widget to display the properties of a test result.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ """
+
def __init__(self, parent: Optional[QW.QWidget] = None):
- super().__init__(parent)
+ super().__init__(parent, title="Test Execution")
self.props: Dict[str, Any] = {}
# Widgets
self.result_enum = QW.QComboBox()
- self.table = QW.QTreeWidget()
-
+ # self.table = QW.QTreeWidget()
+ self.dataset_gbox = DataSetEditGroupBox(
+ "", _PropertiesDataSet, show_button=False
+ )
+ self.dataset_gbox.updateGeometry()
+ self.dataset_gbox.get()
# Layouts
self.vlayout = QW.QVBoxLayout(self)
+ self.vlayout.addWidget(self.title_label)
self.vlayout.addWidget(self.result_enum)
- self.vlayout.addWidget(self.table)
+ self.vlayout.addWidget(self.dataset_gbox)
+ self.dataset_gbox.update()
# Config
- result_value = [result.value for result in ResultEnum]
- self.result_enum.addItems(result_value)
-
- self.table.setHeaderLabels(["Property", "Value"])
- self.table.setAlternatingRowColors(True)
- self.table.setIndentation(False)
- self.table.setColumnWidth(0, 100)
- self.table.setColumnWidth(1, 200)
+ for i, result in enumerate(ResultEnum):
+ self.result_enum.addItem(result.format(), result) # noqa: F821
+ self.result_enum.setItemIcon(i, get_icon(result.icon_path))
def set_item(self, test: Test):
+ """Set the test to display the properties of.
+
+ Args:
+ test: Test to display the properties of.
+ """
self.set_props(test)
- result_value = "no result"
+ result = ResultEnum.NO_RESULT
if test.result is not None:
- result_value = test.result.result.value
+ result = test.result.result
- if test.result is None:
+ if test.result is None or test.is_running():
self.result_enum.setEnabled(False)
else:
self.result_enum.setEnabled(True)
self.result_enum.blockSignals(True)
- self.result_enum.setCurrentText(result_value)
+ self.result_enum.setCurrentText(result.format())
self.result_enum.blockSignals(False)
def set_props(self, test: Test):
+ """Set the properties displayed in the widget using the given test.
+
+ Args:
+ test: Test to display the properties of.
+ """
if test.result is not None:
- self.props = {
- "return code": test.result.error_code,
- "execution duration": test.result.execution_duration,
- "last run": test.result.last_run,
- "status": test.result.status.value,
- }
+ self.props.update(
+ {
+ "return code": test.result.error_code,
+ "execution duration": test.result.execution_duration,
+ "last run": test.result.last_run,
+ "status": test.result.status.value,
+ }
+ )
if self.props["execution duration"] is not None:
self.props["execution duration"] = round(
self.props["execution duration"], 3
)
else:
- self.props = {}
-
- for _ in range(self.table.topLevelItemCount()):
- self.table.takeTopLevelItem(0)
+ self.props.clear()
- for key, value in self.props.items():
- item = QW.QTreeWidgetItem((str(key), str(value)))
- tooltip = f"{key}: {value}"
- self.set_tool_tips(item, tooltip)
- self.table.addTopLevelItem(item)
+ dataset = self.dataset_gbox.dataset
+ dataset.return_code = self.props.get("return code", "")
+ dataset.execution_duration = self.props.get("execution duration", "")
+ dataset.last_run = self.props.get("last run", "")
+ dataset.status = self.props.get("status", "")
- def set_tool_tips(self, item: QW.QTreeWidgetItem, tooltip: str):
- for col_index in range(item.columnCount()):
- item.setToolTip(col_index, tooltip)
+ self.dataset_gbox.updateGeometry()
+ self.dataset_gbox.get()
diff --git a/moduletester/gui/widgets/test_description_widget.py b/moduletester/gui/widgets/test_description_widget.py
index 2601fa2..e0303b9 100644
--- a/moduletester/gui/widgets/test_description_widget.py
+++ b/moduletester/gui/widgets/test_description_widget.py
@@ -5,47 +5,50 @@
from typing import Optional
-from guidata.qthelpers import get_std_icon # type: ignore
-from qtpy import QtCore as QC
-from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
+from qtpy.QtWebEngineWidgets import QWebEnginePage # type: ignore
-from moduletester.gui.states.signals import TMSignals
+from moduletester.gui.widgets.web_engine import SimpleWebViewer
from moduletester.model import Test
class TestDescriptionWidget(QW.QWidget):
- def __init__(self, signals: TMSignals, parent: Optional[QW.QWidget] = None):
+ """Widget to display the description of a test. Includes a SimpleWebViewer to
+ display the HTML description of the test.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ """
+
+ def __init__(self, parent: Optional[QW.QWidget] = None):
super().__init__(parent)
self.test: Optional[Test] = None
- self.signals = signals
# Widgets
- self.lbl_icon = QW.QLabel()
- self.lbl_icon.setFixedWidth(32)
-
- self.desc_label = QW.QTextEdit()
- self.desc_label.setWordWrapMode(QG.QTextOption.WordWrap)
- self.desc_label.setFrameStyle(0)
-
- for label in (self.desc_label, self.lbl_icon):
- label.setAlignment(QC.Qt.AlignTop)
+ self.web_view = SimpleWebViewer(web_actions=[QWebEnginePage.WebAction.Reload]) # type: ignore
+ self.web_view.pageAction(QWebEnginePage.WebAction.Reload).triggered.connect(
+ self.force_reload
+ )
# Layouts
self.hlayout = QW.QHBoxLayout(self)
- self.hlayout.addWidget(self.lbl_icon)
- self.hlayout.addWidget(self.desc_label)
-
- self.desc_label.textChanged.connect(self.text_changed) # type: ignore
-
- def text_changed(self):
- self.signals.SIG_PROJECT_MODIFIED.emit()
-
- def set_item(self, test: Test):
+ self.hlayout.addWidget(self.web_view)
+
+ def force_reload(self):
+ """Force the web view to reload the content."""
+ if self.test is not None:
+ self.set_item(self.test, use_cached=False)
+
+ def set_item(self, test: Test, use_cached: bool = True):
+ """Set the test to display the description of.
+ Args:
+ test: Test to display the description of.
+ use_cached: Whether to use the cached HTML description or not.
+ Defaults to True.
+ """
self.test = test
- text_level = "Information" if test.is_valid else "Critical"
- self.lbl_icon.setPixmap(get_std_icon(f"MessageBox{text_level}").pixmap(24, 24))
-
- self.desc_label.blockSignals(True)
- self.desc_label.setText(test.description)
- self.desc_label.blockSignals(False)
+ self.web_view.setHtml(
+ test.get_html_description(
+ standalone=True, embeded=True, apply_style=True, use_cached=use_cached
+ )
+ )
diff --git a/moduletester/gui/widgets/test_list_widget.py b/moduletester/gui/widgets/test_list_widget.py
index cf076de..1dd45b8 100644
--- a/moduletester/gui/widgets/test_list_widget.py
+++ b/moduletester/gui/widgets/test_list_widget.py
@@ -2,61 +2,364 @@
# pylint: disable=missing-module-docstring
# guitest: skip
+from __future__ import annotations
from datetime import datetime
-from typing import List, Optional
+from typing import Any, Iterable, List, Optional
+from guidata.configtools import get_icon
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
-from moduletester.model import Test
+from moduletester.gui.external.pyqtspinner import WaitingSpinner
+from moduletester.model import (
+ ModuleErrorType,
+ ModuleInternalErrorType,
+ ModuleNotFoundType,
+ Test,
+)
GREY = "#F5F5F5"
+# BLUE = "#0060C6"
+BLUE = "#007BFF"
+# TREE_STYLESHEET = """
+# QTreeWidget::item:selected {
+# background-color: #007BFF;
+# }
+# """
+TREE_STYLESHEET = ""
class TestListWidget(QW.QTreeWidget):
- def __init__(self, tests: List[Test], parent: Optional[QW.QWidget] = None):
+ """Widget to display the list of tests and some result/status information.
+
+ Args:
+ tests: List of tests to display. Defaults to None.
+ parent: Parent widget. Defaults to None.
+ """
+
+ def __init__(
+ self, tests: Optional[List[Test]], parent: Optional[QW.QWidget] = None
+ ) -> None:
super().__init__(parent)
# Fields
- self.tests = tests
+ tests = tests or []
+ self.tests: dict[str, Test] = {}
+ self._setup_tests(tests)
+ self.test_items: dict[str, QW.QTreeWidgetItem] = {}
self.menu = TestContextMenu(self)
# Config
self.setHeaderLabels(["Name", "Status", "Last run"])
self.setSelectionMode(QW.QAbstractItemView.SingleSelection)
- self.setup_list(None)
- self.setCurrentItem(self.topLevelItem(0))
+ self.bold_font = QG.QFont()
+ self.bold_font.setBold(True)
+ self.bold_font.setUnderline(True)
+ # self.bold_font.setCapitalization(QG.QFont.Capitalization.Capitalize)
+ # self.setCurrentItem(self.topLevelItem(0))
self.installEventFilter(self)
self.setAlternatingRowColors(True)
- self.setIndentation(False)
+ self.setIndentation(15)
+ self.setColumnWidth(0, 250)
+ self.setStyleSheet(TREE_STYLESHEET)
+ self.currentItemChanged.connect(self._select_item)
+
+ self.build_tree()
+
+ def _setup_tests(self, tests: List[Test]) -> None:
+ """Set the tests list.
+
+ Args:
+ tests: List of tests.
+ """
+ self.tests.clear()
+ self.tests.update(
+ {t.package.name_from_source.rsplit(".", 1)[-1]: t for t in tests}
+ )
+
+ def reset_widget(self, tests: List[Test]) -> None:
+ """Reset the widget with the given tests. Avoids creating a new widget.
+ Args:
+ tests: List of tests to display.
+ """
+ self._setup_tests(tests)
+ self.build_tree()
+
+ def start_test_spinner(self, test_item: QW.QTreeWidgetItem) -> None:
+ """Start a spinner for the given test item.
+
+ Args:
+ test_item: Test item to start the spinner for.
+ """
+ spinner = WaitingSpinner(
+ self,
+ False,
+ radius=5,
+ roundness=0,
+ lines=50,
+ line_length=5,
+ line_width=1,
+ fade=100,
+ speed=3.1415 / 4,
+ color=QG.QColor("#0671D5"),
+ )
+
+ spinner.start()
+ test_item.setText(1, "")
+ test_item.setIcon(1, QG.QIcon())
+ self.setItemWidget(test_item, 1, spinner)
+
+ def stop_test_spinner(self, test_item: QW.QTreeWidgetItem) -> None:
+ """Stop the spinner for the given test item.
+
+ Args:
+ test_item: Test item to stop the spinner for.
+ """
+ spinner = self.itemWidget(test_item, 1)
+ self.removeItemWidget(test_item, 1)
+ if isinstance(spinner, WaitingSpinner):
+ spinner.stop()
+ del spinner
@property
- def current_item(self):
- return self.selectedItems()[0]
+ def current_item(self) -> QW.QTreeWidgetItem | None:
+ return self.currentItem()
+
+ def _select_item(
+ self,
+ current_item: QW.QTreeWidgetItem,
+ previous_item: Optional[QW.QTreeWidgetItem],
+ ) -> None:
+ """Select the given item and reset the icon of the previous item. Implements
+ some logic to avoid selecting non-selectable items (items can correspond to
+ tests or directory in the package).
+
+ Args:
+ current_item: Current item.
+ previous_item: Previous item.
+ """
+ if QC.Qt.ItemFlag.ItemIsSelectable & current_item.flags(): # type: ignore
+ current_item.setSelected(True)
+ self._reset_item_icon(current_item, None)
+ if previous_item is not None:
+ self._reset_item_icon(previous_item, None)
+
+ elif (
+ previous_item is not None
+ and QC.Qt.ItemFlag.ItemIsSelectable & previous_item.flags() # type: ignore
+ ):
+ self.setCurrentItem(previous_item)
+
+ def _reset_item_icon(
+ self, item: Optional[QW.QTreeWidgetItem], test: Optional[Test]
+ ) -> None:
+ """Reset the icon of the given item. If no item is given, the current item is
+ used. One or both of the arguments must be set.
+
+ Args:
+ item: Item to reset the icon of.
+ test: Test to reset the icon of.
+ """
+ if isinstance(item, QW.QTreeWidgetItem) and isinstance(test, Test):
+ pass
+ elif item is None and test is None:
+ item, test = self.current_item, self.get_selected_test()
+ elif item is None and isinstance(test, Test):
+ item = self.test_items.get(test.package.last_name, None)
+ elif test is None and isinstance(item, QW.QTreeWidgetItem):
+ test = self.tests.get(item.text(0), None)
+
+ if item is None or test is None or item.text(0) != test.package.last_name:
+ return
+
+ if isinstance(test.package.module, ModuleErrorType) or (
+ test.is_error_new() or test.is_message_new()
+ ):
+ new_icon = get_icon("file-notify.svg")
+ elif item is self.current_item and item.childCount() == 0:
+ new_icon = get_icon("file-selected.svg")
+ elif item.childCount() == 0:
+ new_icon = get_icon("file.svg")
+ else:
+ new_icon = get_icon("libre-gui-folder-open.svg")
+
+ item.setIcon(0, new_icon)
+
+ def set_test_icon(self, test: Test, icon: str | QG.QIcon) -> None:
+ """Set the icon of the given test.
+
+ Args:
+ test: Test to set the icon of.
+ icon: icon to set.
+ """
+ item = self.test_items.get(test.package.last_name, None)
+ if item is None:
+ return
+ icon = icon if isinstance(icon, QG.QIcon) else get_icon(icon)
+ item.setIcon(0, icon)
+
+ def select_first_test_item_from(
+ self, root: Optional[QW.QTreeWidgetItem] = None
+ ) -> Optional[QW.QTreeWidgetItem]:
+ """Select the first test item from the given root. If no root is given, the
+ invisible root item is used.
+
+ Args:
+ root: Root QTreeWidgetItem. Defaults to None.
+
+ Returns:
+ The first test item from the given root.
+ """
+ item = self.invisibleRootItem() if root is None else root
+ if item is None:
+ return None
+
+ while item.childCount() > 0: # type: ignore
+ item = item.child(0) # type: ignore
+
+ if item is not None:
+ self.setCurrentItem(item)
+
+ return item
+
+ def _set_result_icon(self, item: QW.QTreeWidgetItem, test: Test) -> None:
+ """Set the result icon of the given item depending on the test result.
+
+ Args:
+ item: Item to set the result icon of.
+ test: Test to get the result from.
+ """
+ if test.result is None:
+ item.setIcon(1, get_icon("unknown.svg"))
+ else:
+ item.setIcon(1, get_icon(test.result.result.icon_path))
+
+ def update_result(self, test: Optional[Test]) -> None:
+ """Update the result of the given test. If no test is given, the selected test
+ is used.
+
+ Args:
+ test: Test to update the result of. Defaults to None.
+ """
+ test = test or self.get_selected_test()
+ if test is None:
+ return
+
+ item = self.test_items.get(test.package.last_name, None)
- def setup_list(self, current_item: Optional[QW.QTreeWidgetItem]):
- current_row = self.get_current_row(current_item)
- self.blockSignals(True)
+ if item is None:
+ return
+
+ test_columns = self.get_cols(test)
+
+ if not test.is_running():
+ self.stop_test_spinner(item)
+ item.setText(1, test_columns[1])
+ self._set_result_icon(item, test)
+ self._reset_item_icon(item, test)
+ item.setText(2, test_columns[2])
+
+ def build_tree(self) -> None:
+ """Build the tree widget with the tests. The tree is built using the tests
+ list.
+ """
self.clear_widget()
+ tree_dict = self._build_submodules_tree_dict(self.tests.values())
+ self._build_tree_widget(self, tree_dict)
+
+ for test in self.tests.values():
+ self.update_result(test)
- for test in self.tests:
- item = QW.QTreeWidgetItem(self.get_cols(test))
- for col in range(item.columnCount()):
- item.setSizeHint(col, QC.QSize(1, 25))
- self.addTopLevelItem(item)
- item_ind = self.topLevelItemCount()
+ self.select_first_test_item_from()
- test = self.tests[item_ind - 1]
+ def _build_submodules_tree_dict(self, tests: Iterable[Test]) -> dict[str, Any]:
+ """Build a dictionary with the tests and their submodules. The dictionary is
+ used to build the tree widget. This is done by calling
+ self._build_submodules_tree_dict_step recursively.
- if not test.is_valid:
- item.setForeground(0, QG.QColor("#FF3333"))
+ Args:
+ tests: Tests to build the dictionary from.
- self.setCurrentItem(self.topLevelItem(current_row))
+ Returns:
+ Dictionary with the tests and their submodules.
+ """
+ rows = [self.get_cols(test) for test in tests]
+ # T = Union[str, dict[str, "T"]]
+ grouped_elements: dict[str, Any] = {}
+ for row in rows:
+ self._build_submodules_tree_dict_step(grouped_elements, row)
+ return grouped_elements
- self.blockSignals(False)
+ def _build_submodules_tree_dict_step(
+ self, current_dict: dict[str, Any], submodule_row: list[str]
+ ) -> None:
+ """Helper function to build the dictionary with the tests and their submodules.
+
+ Args:
+ current_dict: Current dictionary.
+ submodule_row: Submodule row.
+ """
+ submodules = submodule_row[0].split(".", 1)
+
+ if len(submodules) == 1:
+ submodule, status, last_run = submodule_row
+ current_dict[submodule] = submodule, status, last_run
+ return
+
+ submodule, submodule_row[0] = submodules
+ next_level: dict[str, Any] = current_dict.setdefault(submodule, {})
+ self._build_submodules_tree_dict_step(next_level, submodule_row)
+ return
+
+ def _build_tree_widget(
+ self,
+ parent: QW.QTreeWidgetItem | QW.QTreeWidget,
+ level: dict[str, dict] | dict[str, tuple[str, str, str]] | tuple[str, str, str],
+ ) -> Optional[QW.QTreeWidgetItem]:
+ """Build the tree widget using the given level information.
+
+ Args:
+ parent: Parent QTreeWidgetItem or QTreeWidget.
+ level: Level to build the tree widget with.
+ """
+ if isinstance(level, dict):
+ for key, next_level in level.items():
+ if isinstance(next_level, tuple):
+ self._build_tree_widget(parent, next_level)
+ continue
+ new_parent_item = QW.QTreeWidgetItem(parent, (key, "", ""))
+ new_parent_item.setFont(0, self.bold_font)
+ new_parent_item.setExpanded(True)
+ new_parent_item.setFlags(
+ QC.Qt.ItemFlag(
+ new_parent_item.flags() & ~QC.Qt.ItemFlag.ItemIsSelectable
+ )
+ )
+ new_parent_item.setIcon(0, get_icon("libre-gui-folder-open.svg"))
+
+ self._build_tree_widget(new_parent_item, next_level)
+ elif isinstance(level, tuple):
+ new_leaf_item = QW.QTreeWidgetItem(parent, level)
+ if isinstance(
+ self.tests[level[0]].package.module,
+ (ModuleNotFoundType, ModuleInternalErrorType),
+ ):
+ new_leaf_item.setForeground(0, QG.QColor("red"))
+ new_leaf_item.setIcon(0, get_icon("file-notify.svg"))
+ else:
+ new_leaf_item.setIcon(0, get_icon("file.svg"))
+ self.test_items[level[0]] = new_leaf_item
def get_cols(self, test: Test) -> List[str]:
+ """Get the columns for the given test.
+
+ Args:
+ test: Test to get the columns for.
+
+ Returns:
+ Columns for the given test.
+ """
cols = [test.package.name_from_source]
if test.result is None:
cols.extend(["NOT EXECUTED", ""])
@@ -70,40 +373,94 @@ def get_cols(self, test: Test) -> List[str]:
cols.extend([test.result.result_name, last_run])
return cols
- def clear_widget(self):
- for _ in range(self.topLevelItemCount()):
+ def clear_widget(self) -> None:
+ """Clear the widget."""
+ for __ in range(self.topLevelItemCount()):
self.takeTopLevelItem(0)
- def set_row_background(self, item: QW.QTreeWidgetItem):
- for col in range(item.columnCount()):
- item.setBackground(col, QG.QColor(GREY))
+ def get_selected_test(self) -> Test | None:
+ """Return the currently selected test.
- def get_selected_test(self) -> Test:
- item = self.selectedItems()[0]
- test_index = self.get_current_row(item)
- return self.tests[test_index]
+ Returns:
+ Currently selected test.
+ """
+ item = self.current_item
+ assert isinstance(item, QW.QTreeWidgetItem)
+ if item.childCount() > 0:
+ return None
+ test_name = item.text(0)
+ return self.tests[test_name]
def get_current_row(self, current_item: Optional[QW.QTreeWidgetItem]) -> int:
+ """Return the row of the given item. If no item is given, the current item is
+ used.
+
+ Args:
+ current_item: Current item. Defaults to None.
+ """
+
if current_item is None:
return 0
-
test_name = current_item.text(0)
- for ind, test in enumerate(self.tests):
- if test.package.name_from_source == test_name:
- return ind
- return 0
+ return tuple(self.tests.keys()).index(test_name, 0)
+
+ def filter_items(self, search_str: str) -> None:
+ """Filter the items in the tree widget using the given search string.
+
+ Args:
+ search_str: Search string.
+ """
+ # Hide items that don't contain the text
+ search_str = search_str.lower()
+ for i in range(self.topLevelItemCount()):
+ self._filter(self.topLevelItem(i), search_str) # type: ignore
+
+ def _filter(self, item: QW.QTreeWidgetItem, search_str: str) -> bool:
+ """Recursively filter the items in the tree widget using the given search.
+
+ Args:
+ item: Current item to check.
+ search_str: Search string.
- def eventFilter( # pylint: disable=invalid-name
+ Returns:
+ Whether the item is enabled or not.
+ """
+ is_enabled = search_str in item.text(0).lower()
+ for i in range(item.childCount()):
+ child_item = item.child(i)
+ is_enabled = child_item is not None and (
+ is_enabled | self._filter(child_item, search_str)
+ )
+
+ item.setHidden(not is_enabled)
+ return is_enabled
+
+ def eventFilter( # pylint: disable=invalid-name # noqa: N802 #type: ignore
self, source: QC.QObject, event: QC.QEvent
) -> bool:
- if event.type() == QC.QEvent.ContextMenu and source is self:
+ """Custom event filter to handle the context menu event.
+
+ Args:
+ source: Source of the event.
+ event: Event to handle.
+
+ Returns:
+ Whether the event was handled or not.
+ """
+ if event.type() == QC.QEvent.Type.ContextMenu and source is self:
self.menu.run(event)
return True
- return super(TestListWidget, self).eventFilter(source, event)
+ return super().eventFilter(source, event)
class TestContextMenu(QW.QMenu):
+ """Context menu for the test list widget.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ """
+
def __init__(self, parent: Optional[QW.QWidget] = None) -> None:
super().__init__(parent)
# Actions
@@ -113,5 +470,10 @@ def __init__(self, parent: Optional[QW.QWidget] = None) -> None:
self.addAction(self.run_script)
self.addAction(self.code_snippet)
- def run(self, event: QC.QEvent):
+ def run(self, event: QC.QEvent) -> None:
+ """Run the context menu.
+
+ Args:
+ event: Event used to get context menu position.
+ """
super().exec_(event.globalPos())
diff --git a/moduletester/gui/widgets/test_prop_widget.py b/moduletester/gui/widgets/test_prop_widget.py
index 8ba8a12..35cd230 100644
--- a/moduletester/gui/widgets/test_prop_widget.py
+++ b/moduletester/gui/widgets/test_prop_widget.py
@@ -3,64 +3,69 @@
from typing import Any, Dict, Optional
-from qtpy import QtCore as QC
+from guidata.dataset import DataSet
+from guidata.dataset.dataitems import IntItem, StringItem
+from guidata.dataset.qtwidgets import DataSetEditGroupBox
from qtpy import QtWidgets as QW
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
from moduletester.model import Test
-class TestProps(QW.QGroupBox):
- def __init__(self, parent: Optional[QW.QWidget] = None):
- super().__init__(parent)
+class _PropertiesDataSet(DataSet):
+ name = StringItem("Name").set_prop("display", active=False)
+ source = StringItem("Source").set_prop("display", active=False)
+ path = StringItem("Path").set_prop("display", active=False)
+ args = StringItem("Args").set_prop("display", placeholder="No args")
+ timeout = IntItem("Timeout", default=0)
+
+
+class TestProps(DockableQWidget):
+ """Widget to display the properties of a test.
+
+ Args:
+ title: Title of the widget. Defaults to "Test Properties".
+ parent: Parent widget. Defaults to None.
+ """
+
+ def __init__(self, title="Test Properties", parent: Optional[QW.QWidget] = None):
+ super().__init__(parent, title)
self.props: Dict[str, Any] = {}
# Widgets
- self.table = QW.QTreeWidget()
-
+ self.dataset_gbox = DataSetEditGroupBox(None, _PropertiesDataSet)
# Layout
self.vlayout = QW.QVBoxLayout(self)
- self.vlayout.addWidget(self.table)
-
- def setup(self):
- self.setTitle("Properties")
+ self.vlayout.addWidget(self.title_label)
+ self.vlayout.addWidget(self.dataset_gbox)
- self.table.setHeaderLabels(["Property", "Value"])
- self.table.setIndentation(False)
- self.table.setColumnWidth(0, 100)
- self.table.setColumnWidth(1, 200)
- self.table.setAlternatingRowColors(True)
+ self.props = {}
- self.table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers)
-
- self.table.itemDoubleClicked.connect(self.on_item_double_clicked)
+ def setup(self):
+ """Setup the widget. Empty."""
+ pass
def set_props(self, test: Test):
- self.props = {
- "name": test.package.last_name,
- "source": test.package.full_name.split(".")[0],
- "path": test.package.root_path,
- "args": test.command_args if test.command_args != "" else "No args",
- "timeout": test.command_timeout if test.command_timeout != 86400 else 0,
- }
-
- for _ in range(self.table.topLevelItemCount()):
- self.table.takeTopLevelItem(0)
-
- for key, value in self.props.items():
- item = QW.QTreeWidgetItem((str(key), str(value)))
- if key not in ("name", "source", "path"):
- item.setFlags(
- QC.Qt.ItemIsEditable | QC.Qt.ItemIsSelectable | QC.Qt.ItemIsEnabled
- )
-
- tooltip = f"{key}: {value}"
- self.set_tool_tips(item, tooltip)
- self.table.addTopLevelItem(item)
-
- def set_tool_tips(self, item: QW.QTreeWidgetItem, tooltip: str):
- for col_index in range(item.columnCount()):
- item.setToolTip(col_index, tooltip)
-
- def on_item_double_clicked(self, item: QW.QTreeWidgetItem, column: int):
- if column == 1 and item.flags() & QC.Qt.ItemIsEditable:
- self.table.editItem(item, column)
+ """Set the widget properties from a test.
+
+ Args:
+ test: Test to set the properties from.
+ """
+ self.props.update(
+ {
+ "name": test.package.last_name,
+ "source": test.package.full_name.split(".")[0],
+ "path": test.package.root_path,
+ "args": test.command_args,
+ "timeout": test.command_timeout,
+ }
+ )
+ dataset = self.dataset_gbox.dataset
+ dataset.name = test.package.last_name
+ dataset.source = test.package.full_name.split(".")[0]
+ dataset.path = test.package.root_path
+ dataset.args = test.command_args
+ dataset.timeout = test.command_timeout
+
+ self.dataset_gbox.get()
+ self.dataset_gbox.set_apply_button_state(False)
diff --git a/moduletester/gui/widgets/toolbox_widget.py b/moduletester/gui/widgets/toolbox_widget.py
new file mode 100644
index 0000000..7e5b779
--- /dev/null
+++ b/moduletester/gui/widgets/toolbox_widget.py
@@ -0,0 +1,49 @@
+"""Toolbox widget for collapsible sections."""
+
+from __future__ import annotations
+
+from PyQt5.QtWidgets import QWidget
+from qtpy import QtWidgets as QW
+
+from moduletester.gui.states.signals import TMSignals
+from moduletester.gui.widgets.config_editor import ConfigEditor
+from moduletester.gui.widgets.dockable_widget import DockableQWidget
+
+
+class Toolbox(DockableQWidget):
+ """A toolbox widget to hold various tools.
+
+ Args:
+ parent: Parent widget. Defaults to None.
+ signals: Signals object that contains shared global ModuleTester signals.
+ title: Title of the widget. Defaults to "Toolbox".
+ """
+
+ def __init__(
+ self,
+ parent: QWidget | None,
+ signals: TMSignals,
+ title: str = "Toolbox",
+ ) -> None:
+ super().__init__(parent, title)
+ self.signals = signals
+
+ self.vlayout = QW.QVBoxLayout(self)
+
+ self.tbx = QW.QToolBox()
+
+ self.config_editor = ConfigEditor(
+ self,
+ self.signals,
+ "Config Editor",
+ )
+ self.tbx.addItem(self.config_editor, "Config editor")
+
+ self.vlayout.addWidget(self.tbx)
+
+ self.setup()
+ self.setLayout(self.vlayout)
+
+ def setup(self) -> None:
+ """Setup the widget. Empty."""
+ pass
diff --git a/moduletester/gui/widgets/web_engine.py b/moduletester/gui/widgets/web_engine.py
new file mode 100644
index 0000000..28d8d1c
--- /dev/null
+++ b/moduletester/gui/widgets/web_engine.py
@@ -0,0 +1,133 @@
+"""Web engine widget for HTML content display."""
+
+from __future__ import annotations
+
+from typing import Optional
+
+import qtpy.QtWebEngineCore as QWEBC
+import qtpy.QtWebEngineWidgets as QWEB
+from qtpy.QtWidgets import QAction, QMenu
+
+
+class UrlBloquer(QWEBC.QWebEngineUrlRequestInterceptor):
+ """Simple Url blocker for QWebEngineView."""
+
+ def interceptRequest(self, info: QWEBC.QWebEngineUrlRequestInfo) -> None:
+ """Intercept the request and block it if it is a url.
+
+ Args:
+ info: Request info.
+ """
+ scheme = info.requestUrl().scheme()
+ navigation_type = info.navigationType()
+ ressource_type = info.resourceType()
+
+ block = not (
+ (scheme == "data")
+ or (
+ navigation_type
+ == QWEBC.QWebEngineUrlRequestInfo.NavigationType.NavigationTypeLink
+ and ressource_type
+ == QWEBC.QWebEngineUrlRequestInfo.ResourceType.ResourceTypeImage
+ )
+ )
+
+ return info.block(block)
+
+
+class SimpleWebViewer(QWEB.QWebEngineView): # type: ignore
+ """Simplified QWebEngineView web viewer.
+
+ Args:
+ web_actions: List of QAction or QWebEnginePage.WebAction to add to the context
+ menu.
+ *arhs: Arguments to pass to the parent QWebEngineView class.
+ """
+
+ def __init__(
+ self,
+ *args,
+ web_actions: Optional[list[QAction | QWEB.QWebEnginePage.WebAction]] = None,
+ ):
+ super().__init__(*args)
+ self.protect_settings()
+ self.protect_profile()
+
+ self.menu = self.setup_menu(web_actions or [])
+
+ def protect_settings(self):
+ """Creates new settings for the QWebEngineView. These settings are meant to
+ protect the user from malicious content."""
+ WebAttribute = QWEB.QWebEngineSettings.WebAttribute # type: ignore
+ new_settings = {
+ WebAttribute.AutoLoadImages: True,
+ WebAttribute.JavascriptEnabled: False,
+ WebAttribute.JavascriptCanOpenWindows: False,
+ WebAttribute.JavascriptCanAccessClipboard: False,
+ WebAttribute.LinksIncludedInFocusChain: False,
+ WebAttribute.LocalStorageEnabled: False,
+ WebAttribute.LocalContentCanAccessRemoteUrls: False,
+ WebAttribute.XSSAuditingEnabled: True,
+ WebAttribute.SpatialNavigationEnabled: False,
+ WebAttribute.LocalContentCanAccessFileUrls: False,
+ WebAttribute.HyperlinkAuditingEnabled: False,
+ WebAttribute.ScrollAnimatorEnabled: False,
+ WebAttribute.ErrorPageEnabled: False,
+ WebAttribute.PluginsEnabled: False,
+ WebAttribute.FullScreenSupportEnabled: False,
+ WebAttribute.ScreenCaptureEnabled: False,
+ WebAttribute.WebGLEnabled: True,
+ WebAttribute.Accelerated2dCanvasEnabled: True,
+ WebAttribute.AutoLoadIconsForPage: False,
+ WebAttribute.TouchIconsEnabled: False,
+ WebAttribute.FocusOnNavigationEnabled: False,
+ WebAttribute.PrintElementBackgrounds: True,
+ WebAttribute.AllowRunningInsecureContent: False,
+ WebAttribute.AllowGeolocationOnInsecureOrigins: False,
+ WebAttribute.AllowWindowActivationFromJavaScript: False,
+ WebAttribute.ShowScrollBars: True,
+ WebAttribute.PlaybackRequiresUserGesture: True,
+ WebAttribute.WebRTCPublicInterfacesOnly: True,
+ WebAttribute.JavascriptCanPaste: False,
+ WebAttribute.DnsPrefetchEnabled: False,
+ WebAttribute.PdfViewerEnabled: True,
+ }
+
+ settings = self.page().settings()
+ for setting, value in new_settings.items():
+ settings.setAttribute(setting, value)
+
+ def protect_profile(self):
+ """Protect the profile of the QWebEngineView. This is done by setting a
+ UrlRequestInterceptor that will block any request that is not a data url or a
+ link to an image.
+ """
+ self.page().profile().setUrlRequestInterceptor(UrlBloquer(self))
+
+ def setup_menu(
+ self,
+ web_actions: list[QWEB.QWebEnginePage.WebAction], # type: ignore
+ ) -> QMenu:
+ """Setup the context menu.
+
+ Args:
+ web_actions: List of QAction or QWebEnginePage.WebAction to add to the
+ context menu.
+
+ Returns:
+ The context menu.
+ """
+ menu = QMenu()
+ for action in web_actions:
+ if isinstance(action, QWEB.QWebEnginePage.WebAction): # type: ignore
+ action = self.pageAction(action)
+ menu.addAction(action)
+ return menu
+
+ def contextMenuEvent(self, event):
+ """Show the context menu.
+
+ Args:
+ event: Context menu event to get the position from.
+ """
+ self.menu.exec_(event.globalPos())
diff --git a/moduletester/gui/window.py b/moduletester/gui/window.py
index 200f024..00fae17 100644
--- a/moduletester/gui/window.py
+++ b/moduletester/gui/window.py
@@ -2,10 +2,12 @@
# pylint: disable=missing-module-docstring
# guitest: skip
+from __future__ import annotations
+
import os
from importlib import import_module
from pathlib import Path
-from typing import Optional
+from typing import TYPE_CHECKING, Iterable, Optional
from guidata.config import CONF # type: ignore
from guidata.configtools import get_font, get_icon, get_image_file_path # type: ignore
@@ -13,18 +15,25 @@
from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
+from moduletester import config
+from moduletester.config import _
+from moduletester.test_exporter import TestListDocument, TestResultsDocument
+
from ..config import APP_NAME
from ..manager import TestManager
-from ..model import Module, Test
-from ..python_helpers import rst2odt
+from ..model import Module, Test, TestSuite
from .components.body_component import TMWidget
from .components.status_bar_component import TMStatusBar
from .components.tool_bar_component import TestManagerToolbar
-from .states.signals import TMSignals
-from .states.state_machine import TMStateMachine
+
+if TYPE_CHECKING:
+ from .states.signals import TMSignals
+ from .states.state_machine import TMStateMachine
class TMWindow(QW.QMainWindow):
+ export_finished = QC.Signal(str) # type: ignore
+
def __init__(
self,
signals: TMSignals,
@@ -39,22 +48,33 @@ def __init__(
self.setMinimumSize(800, 480)
font = get_font(CONF, "codeeditor")
- ffamily, fsize = font.family(), font.pointSize()
+ _ffamily, fsize = font.family(), font.pointSize()
bgurl = Path(get_image_file_path("ModuleTester-watermark.png")).as_posix()
- self.ss_nobg = f"QWidget {{ font-family: '{ffamily}'; font-size: {fsize}pt;}}"
+ # self.ss_nobg = f"QWidget {{ font-family: '{ffamily}'; font-size: {fsize}pt;}}"
+ self.ss_nobg = f"QWidget {{ font-size: {fsize}pt;}}"
self.ss_withbg = f"QMainWindow {{ background: url({bgurl}) no-repeat center;}}"
self.setStyleSheet(self.ss_withbg + " " + self.ss_nobg)
+ # self.setStyleSheet(self.ss_withbg)
self.signals = signals
+ self.last_file_dir: str
+ self.last_export_dir: str
+ self.manager: Optional[TestManager] = None
if package is not None and moduletester_path is None:
- self.manager = TestManager(package, _category="visible")
+ self.manager = self.new_test_manager(
+ package, _category=config.PACKAGE_CONF["general"].category
+ )
+ self.last_export_dir = self.last_file_dir = package.root_path or os.getcwd()
elif package is None and moduletester_path is not None:
- self.manager = TestManager(
- moduletester_path=moduletester_path, _category="visible"
+ self.manager = self.new_test_manager(
+ moduletester_path=moduletester_path,
+ _category=config.PACKAGE_CONF["general"].category,
)
+ self.last_export_dir = self.last_file_dir = moduletester_path
else:
self.manager = None
+ self.last_export_dir = self.last_file_dir = os.getcwd()
self.toolbar = TestManagerToolbar(self)
self.statusbar = TMStatusBar(self)
@@ -67,17 +87,40 @@ def __init__(
self.statusbar.set_state_label("Not loaded")
self.statusbar.set_path_label("")
+ self.export_finished.connect(self.doc_exported)
+
if self.manager is not None:
- self.central_widget = TMWidget(
- self.signals, self.manager.test_suite, moduletester_path, self
- )
+ self.set_central_widget(self.manager.test_suite, moduletester_path)
self.setup()
+ def set_central_widget(
+ self, test_suite: TestSuite, path: Optional[str] = None
+ ) -> None:
+ """Create or update the central widget with the given test_suite and path. This
+ methods avoids to create a new widget if the central widget already exists.
+
+ Args:
+ test_suite: The test_suite to display in the central widget.
+ path: The path of the moduletester file. Defaults
+ to None.
+ """
+ if hasattr(self, "central_widget"):
+ self.central_widget.update_widget(test_suite, path)
+ else:
+ self.central_widget = TMWidget(self.signals, test_suite, path, self)
+
@property
def current_test(self) -> Test:
return self.central_widget.test_list.get_selected_test()
def closeEvent(self, a0: QG.QCloseEvent) -> None: # pylint: disable=C0103
+ """Close the main window and stop the thread if it is running. If the file has
+ been modified, a message box is displayed to ask the user if he wants to save
+ the file.
+
+ Args:
+ a0: The close event.
+ """
if self.state_machine.running_state.active():
self.central_widget.stop_thread()
@@ -87,6 +130,9 @@ def closeEvent(self, a0: QG.QCloseEvent) -> None: # pylint: disable=C0103
return super().closeEvent(a0)
def save_alert(self):
+ """
+ Display a message box to ask the user if he wants to save the current file.
+ """
save_mb = QW.QMessageBox(
QW.QMessageBox.Warning,
APP_NAME,
@@ -101,6 +147,7 @@ def save_alert(self):
save_mb.exec()
def save_alert_accepted(self):
+ """Save the current file if the user wants to save the modifications."""
self.save()
QW.QMessageBox(
@@ -111,12 +158,14 @@ def save_alert_accepted(self):
).exec_()
def setup(self):
+ """Setup the main window with the current test_suite."""
self.setWindowTitle(f"{APP_NAME} - Module {self.manager.module.full_name}")
self.setMinimumSize(0, 0)
self.setStyleSheet(self.ss_nobg)
self.setCentralWidget(self.central_widget)
self.signals.SIG_PROJECT_LOADED.emit()
self.connect_test_actions()
+ self.toolbar.setup_view(self.central_widget.view_menu)
def show(self):
super().show()
@@ -126,17 +175,32 @@ def show(self):
parent=self,
)
+ def refresh_package(self):
+ """Refresh the package and the central widget. This method should keep the
+ current results."""
+ if self.manager is None:
+ return
+ self.manager.refresh_package(category=config.PACKAGE_CONF["general"].category)
+ self.signals.SIG_PROJECT_LOADED.emit()
+
def connect_file_actions(self):
+ """Connect the toolbar file actions to instance methods."""
self.toolbar.new_file_action.triggered.connect(self.create_new_file)
self.toolbar.open_action.triggered.connect(self.open)
+ self.toolbar.update_action.triggered.connect(self.refresh_package)
self.toolbar.save_action.triggered.connect(self.save)
self.toolbar.save_as_action.triggered.connect(self.save_as)
- self.toolbar.export_dtv_action.triggered.connect(lambda: self.export_dtv(None))
- self.toolbar.export_rtv_action.triggered.connect(lambda: self.export_rtv(None))
+ self.toolbar.export_test_list_action.triggered.connect(
+ lambda: self.export_test_list(None)
+ )
+ self.toolbar.export_test_results_action.triggered.connect(
+ lambda: self.export_test_results(None)
+ )
self.toolbar.export_action.triggered.connect(self.export)
def connect_test_actions(self):
+ """Connect the toolbar test actions to the central widget methods."""
self.toolbar.run_action.triggered.connect(self.central_widget.run_test)
self.toolbar.stop_action.triggered.connect(self.central_widget.stop_thread)
self.toolbar.restart_action.triggered.connect(
@@ -144,45 +208,71 @@ def connect_test_actions(self):
)
def apply_changes(self, test: Test):
- description = self.central_widget.test_information.description
- comment = self.central_widget.result_information.comment
+ """Save the comment of the test in the result object."""
+ comment = (
+ self.central_widget.result_information.comment_widget.cached_comments.get(
+ test.package.full_name, None
+ )
+ )
- test.description = description
- if test.result is not None:
+ if test.result is not None and comment is not None:
test.result.comment = comment
- def get_open_file_name(self):
- path = os.getcwd()
- if self.manager is not None:
- path = self.manager.moduletester_path
+ def apply_all_changes(self):
+ """Parse all the tests and apply the changes to the result object."""
+ if self.manager is None:
+ return
+
+ # Updates the cached comment of the current test if the timer is still running
+ comment_widget = self.central_widget.result_information.comment_widget
+ if comment_widget.timer.isActive():
+ comment_widget.timer.stop()
+ comment_widget.update_cached_comment()
+ for test in self.manager.test_suite.tests:
+ self.apply_changes(test)
+
+ def get_open_file_name(self) -> str:
+ """Open a file dialog to select a .moduletester file to open.
+ Returns:
+ str: The path of the selected file.
+ """
open_file_name = QW.QFileDialog.getOpenFileName(
- self, "Open .moduletester file", path, "*.moduletester"
+ self, "Open .moduletester file", self.last_file_dir, "*.moduletester"
)
file_path = open_file_name[0]
+ self.last_file_dir = os.path.dirname(file_path)
return file_path
- def get_save_file_name(self):
- path = os.getcwd()
- if self.manager is not None:
- path = self.manager.moduletester_path
+ def get_save_file_name(self) -> str:
+ """Open a file dialog to select a file to save the .moduletester file.
+ Returns:
+ str: The path of the selected file.
+ """
save_file_name = QW.QFileDialog.getSaveFileName(
- self, "Save .moduletester file", path, "*.moduletester *.txt"
+ self, "Save .moduletester file", self.last_file_dir, "*.moduletester *.txt"
)
file_path = save_file_name[0]
+ self.last_file_dir = os.path.dirname(file_path)
return file_path
- def get_existing_dir(self):
+ def get_existing_dir(self) -> str:
+ """Open a file dialog to select an existing directory.
+
+ Returns:
+ str: The path of the selected directory.
+ """
dir_name = QW.QFileDialog.getExistingDirectory(
self,
"Export Directory",
- self.manager.module.root_path,
+ self.last_export_dir,
QW.QFileDialog.ShowDirsOnly,
)
return dir_name
def open(self):
+ """Open a .moduletester file and load the tests."""
if (
self.state_machine.modified_state.active()
and self.state_machine.has_file_state.active()
@@ -193,13 +283,161 @@ def open(self):
if not os.path.exists(file_path):
return
- self.manager = TestManager(moduletester_path=file_path, _category="visible")
- self.central_widget = TMWidget(
- self.signals, self.manager.test_suite, file_path, self
+ if os.path.exists(
+ bkp_file := (file_path + ".bkp")
+ ) and self.backup_file_exists_warning(bkp_file):
+ file_path = bkp_file
+
+ self.manager = self.new_test_manager(
+ moduletester_path=file_path,
+ _category=config.PACKAGE_CONF["general"].category,
)
+ if self.manager is None:
+ return
+
+ if len(missing_modules := self.manager.get_missing_modules()) > 0:
+ self._handle_missing_module(missing_modules)
+
+ if len(errored_modules := self.manager.get_errored_modules()) > 0:
+ self._notifiy_errored_module(errored_modules)
+
+ self.set_central_widget(self.manager.test_suite, file_path)
self.setup()
self.signals.SIG_FILE_LOADED.emit(file_path)
+ def new_test_manager(
+ self,
+ module: Module | None = None,
+ moduletester_path: str | None = None,
+ _category: str = config.PACKAGE_CONF["general"].category,
+ _template_path: str = "",
+ ) -> TestManager | None:
+ """Create a new TestManager object and handle the configuration errors.
+
+ Args:
+ module: Module object that contains the tests. Defaults to None.
+ moduletester_path: Path to the moduletester file. Defaults to None.
+ _category: Test discovery category. Defaults to the value stored in
+ config.PACKAGE_CONF["general"].category.
+ _template_path: .moduletester template file. Defaults to "".
+
+ Returns:
+ TestManager: A new TestManager object or None if the configuration file
+ contains errors.
+ """
+ manager = TestManager(module, moduletester_path, _category, _template_path)
+ if (conf_err := manager.get_conf_conflict_err()) is not None:
+ ok = self._resolve_moduletester_config_error(conf_err)
+ if ok:
+ return self.new_test_manager(
+ module, moduletester_path, _category, _template_path
+ )
+ else:
+ return manager
+
+ if (conf_err := manager.get_conf_path_val_err()) is not None:
+ print("Error was not None")
+ # TODO: open a conf editor to allow the user to modify the config file
+ conf_file = os.path.join(
+ config.MODULETESTER_CONFIG_DIR, config.MODULETESTER_CONFIG_NAME
+ )
+ QW.QMessageBox.critical(
+ self,
+ "Configuration file contains invalid values",
+ f"Configuration file {conf_file} contains "
+ f"invalid value:\n {conf_err.key} = {conf_err.value}",
+ QW.QMessageBox.Cancel,
+ )
+ return None
+ return manager
+
+ def _resolve_moduletester_config_error(self, e: config.ConfigConflictError) -> bool:
+ """Handle the configuration file error by opening a dialog to allow the user to
+ fix the error.
+
+ Args:
+ e: The configuration error.
+
+ Returns:
+ True if the user wants to fix the error and save the file, False otherwise.
+ """
+ config_path = os.path.join(
+ config.MODULETESTER_CONFIG_DIR, config.MODULETESTER_CONFIG_NAME
+ )
+ response = (
+ QW.QMessageBox.critical(
+ self,
+ "Error in configuration file",
+ f"Error in configuration file: {config_path}\n{str(e)}"
+ "\nDo you want to fix the error and save the file?",
+ QW.QMessageBox.Apply | QW.QMessageBox.Cancel,
+ )
+ == QW.QMessageBox.Apply
+ )
+ if response:
+ config.load_package_conf(config_path, resolve=True)
+ config.save_config(config.PACKAGE_CONF, config_path)
+ print(config_path)
+ return True
+ return False
+
+ def _handle_missing_module(self, missing_modules: list[Module]) -> None:
+ """Handle the missing modules by asking the user if he wants to reimport all the
+ tests and override the current file.
+
+ Args:
+ missing_modules: List of missing modules.
+ """
+ if self.manager is None:
+ return
+ missing_modules_names = ", ".join(map(lambda m: m.full_name, missing_modules))
+ response = (
+ QW.QMessageBox.warning(
+ self,
+ _("Error during moduletester file loading."),
+ (
+ _("Error while parsing the .moduletester file: %s")
+ % f"{self.manager.moduletester_path}\n"
+ + _("Missing modules:")
+ + f"\n\n{missing_modules_names}\n\n"
+ + _(
+ "Do you want to reimport all tests"
+ " and override the current file?"
+ )
+ ),
+ buttons=QW.QMessageBox.Ok | QW.QMessageBox.Cancel,
+ )
+ == QW.QMessageBox.Ok
+ )
+ if response:
+ self.manager.reload()
+ self.manager.save()
+ self.signals.SIG_FILE_LOADED.emit(self.manager.moduletester_path)
+
+ def _notifiy_errored_module(self, errored_modules: list[Module]) -> None:
+ """Notify the user that some modules could not be imported do to error in the
+ imported file.
+
+ Args:
+ errored_modules: list of errored modules.
+ """
+ if self.manager is None:
+ return
+ errored_modules_names = ", ".join(map(lambda m: m.full_name, errored_modules))
+ _response = QW.QMessageBox.warning(
+ self,
+ _("Error during module loading."),
+ (
+ _("Error while importing modules:")
+ + f"\n\n{errored_modules_names}\n\n"
+ + _(
+ "The modules are still visible in moduletester but should be fixed "
+ "or removed."
+ )
+ ),
+ buttons=QW.QMessageBox.Ok,
+ )
+
def create_new_file(self):
if (
self.state_machine.modified_state.active()
@@ -219,52 +457,137 @@ def create_new_file(self):
edit.setFixedSize(220, 25)
btn.setFixedWidth(80)
- vlayout.addWidget(edit, alignment=QC.Qt.AlignRight)
- vlayout.addWidget(btn, alignment=QC.Qt.AlignRight)
+ vlayout.addWidget(edit, alignment=QC.Qt.AlignmentFlag.AlignRight)
+ vlayout.addWidget(btn, alignment=QC.Qt.AlignmentFlag.AlignRight)
btn.clicked.connect(lambda: self.create_template(edit.text(), dialog))
dialog.exec()
+ def clear_dock_widgets(self):
+ """Remove all the dock widgets from the main window."""
+ for dock in self.findChildren(QW.QDockWidget):
+ self.removeDockWidget(dock)
+ self.toolbar.removeAction(dock.toggleViewAction())
+
def create_template(self, module_name: str, dialog: QW.QDialog):
+ """Create a new template file with the given module name.
+
+ Args:
+ module_name: The name of the module.
+ dialog: The dialog that contains the module name input.
+ """
try:
module = Module(import_module(module_name))
dialog.close()
- self.manager = TestManager(module, _category="visible")
- self.central_widget = TMWidget(
- self.signals, self.manager.test_suite, parent=self
+ self.manager = self.new_test_manager(
+ module, _category=config.PACKAGE_CONF["general"].category
)
+ if self.manager is None:
+ return
+ if len(errored_modules := self.manager.get_errored_modules()) > 0:
+ self._notifiy_errored_module(errored_modules)
+ self.set_central_widget(self.manager.test_suite)
self.setup()
self.signals.SIG_PROJECT_LOADED.emit()
self.signals.SIG_TEMPLATE_CREATED.emit()
- except ModuleNotFoundError:
+ except (ModuleNotFoundError, ValueError):
QW.QMessageBox(
QW.QMessageBox.Icon.Critical,
"Module not found",
f"No module named {module_name}",
).exec()
+ def file_exists_warning(self, file_path: str) -> bool:
+ """Display a warning message to the user if the file already exists.
+
+ Args:
+ file_path: The path of the file.
+
+ Returns:
+ True if the user wants to overwrite the file, False otherwise.
+ """
+ res = QW.QMessageBox.warning(
+ self,
+ "File already exists",
+ f"File {file_path} already exists, do you want to overwite it?",
+ buttons=QW.QMessageBox.Yes | QW.QMessageBox.No,
+ defaultButton=QW.QMessageBox.No,
+ )
+ return res == QW.QMessageBox.Yes
+
+ def backup_file_exists_warning(self, bkp_file_path: str) -> bool:
+ """Display a warning message to the user if a backup file was found.
+
+ Args:
+ bkp_file_path: The path of the backup file.
+
+ Returns:
+ True if the user wants to import the backup file, False otherwise.
+ """
+ res = QW.QMessageBox.warning(
+ self,
+ "Backup file found",
+ f"Backup file {bkp_file_path} was found which means the application may "
+ "have crashed during saving. \nDo you want to import it instead?",
+ buttons=QW.QMessageBox.Yes | QW.QMessageBox.No,
+ defaultButton=QW.QMessageBox.No,
+ )
+ return res == QW.QMessageBox.Yes
+
+ def alert_test_running(self):
+ """Alert the user that a test is currently running."""
+ QW.QMessageBox.warning(
+ self,
+ _("Test running"),
+ _(
+ "A test is currently running, please wait for it "
+ "to finish before saving."
+ ),
+ )
+
def save(self):
- if self.manager.moduletester_path is None:
+ """Save the current test_suite to the current file. If the file does not exist,
+ the user is asked to select a file to save the test_suite.
+ """
+ if (manager := self.manager) is None:
+ return
+
+ if manager.test_suite.running_test is not None:
+ self.alert_test_running()
+ return
+
+ if manager.moduletester_path is None:
self.save_as()
else:
- test = self.current_test
- self.apply_changes(test)
- self.manager.save()
- self.signals.SIG_FILE_LOADED.emit(self.manager.moduletester_path)
- self.signals.SIG_PROJECT_SAVED.emit(self.manager.moduletester_path)
+ self.apply_all_changes()
+ manager.save()
+ self.signals.SIG_FILE_LOADED.emit(manager.moduletester_path)
+ self.signals.SIG_PROJECT_SAVED.emit(manager.moduletester_path)
def save_as(self):
+ """Save the current test_suite to a new file. If the file already exists, the
+ user is asked if he wants to overwrite it.
+ """
file_path = self.get_save_file_name()
+ # is_save_ok = True
if file_path == "":
return
+ # elif os.path.exists(file_path):
+ # is_save_ok = self.file_exists_warning(file_path)
+ # if is_save_ok:
+ # open(file_path, "w", encoding="utf-8").close()
elif not os.path.exists(file_path):
open(file_path, "w", encoding="utf-8").close()
- test = self.current_test
+ if self.manager is None:
+ return
- self.apply_changes(test)
+ if self.state_machine.running_state.active():
+ self.alert_test_running()
+ return
+ self.apply_all_changes()
self.manager.save_as(file_path)
self.central_widget.moduletester_path = self.manager.moduletester_path
self.central_widget.set_item()
@@ -272,63 +595,153 @@ def save_as(self):
self.signals.SIG_FILE_LOADED.emit(file_path)
self.signals.SIG_PROJECT_SAVED.emit(file_path)
+ def _multi_export_callback(self, basename: str, fmts: Iterable[str]):
+ """Callback function for the multi_export_async method of the TestListDocument
+ and TestResultsDocument classes.
+
+ Args:
+ basename: The basename of the file that are exported.
+ fmts: The formats of the files that are exported (their extensions).
+ """
+
+ for fmt in fmts:
+ self.export_finished.emit(f"{basename}.{fmt}")
+
+ def _check_exports(self, abs_out_basename: str, fmts: Iterable[str]) -> bool:
+ """Check if the files already exist and ask the user if he wants to overwrite
+ them.
+
+ Args:
+ abs_out_basename: The absolute path of the file to export.
+ fmts: The formats of the files to export (their extensions).
+ """
+ files_already_exist: list[str] = [
+ filename
+ for fmt in fmts
+ if os.path.isfile(filename := f"{abs_out_basename}.{fmt}")
+ ]
+ files_str = ", ".join(files_already_exist)
+ if len(files_already_exist) > 0:
+ res = QW.QMessageBox.warning(
+ self,
+ "File already exists.",
+ (
+ f"The following files already exist: {files_str}.\n"
+ f"File {abs_out_basename} already exists,"
+ " do you want to overwrite it?"
+ ),
+ buttons=QW.QMessageBox.Yes | QW.QMessageBox.No,
+ defaultButton=QW.QMessageBox.No,
+ )
+ return res == QW.QMessageBox.Yes
+
+ return True
+
def export(self):
+ """Export both TestListDocument and TestResultsDocument files to the same
+ directory. The user is asked to select the directory.
+ """
dir_name = self.get_existing_dir()
if dir_name == "":
return
- test = self.current_test
- self.apply_changes(test)
+ self.export_test_list(dir_name)
+ self.export_test_results(dir_name)
+
+ def export_test_list(self, dir_name: Optional[str] = None):
+ """Exports a TestListDocument file to the given directory. If the directory is
+ None, the user is asked to select a directory.
- self.export_dtv(dir_name)
- self.export_rtv(dir_name)
+ Args:
+ dir_name: Path to the export directory. Defaults to None.
+ """
- def export_dtv(self, dir_name: Optional[str] = None):
if dir_name is None:
dir_name = self.get_existing_dir()
if dir_name == "":
return
- test = self.current_test
- self.apply_changes(test)
- target_dir = os.path.join(dir_name, "dtv")
+ self.apply_all_changes()
- self.manager.export(dir_name, "dtv")
+ model = "test_list"
+ fmts = []
+ abs_out_basename = os.path.join(dir_name, model)
+ if self.manager is not None and self.manager.module is not None:
+ abs_out_basename = os.path.join(
+ dir_name, f"{model}_{self.manager.module.last_name}"
+ )
+ fmts.extend(config.PACKAGE_CONF["export"].export_fmts)
- source = os.path.join(target_dir, "dtv.rst")
- dest = os.path.join(target_dir, "dtv.odt")
- rst2odt(source, dest)
+ else:
+ raise ValueError("No manager or module loaded")
- self.odt_created(dest)
+ if not self._check_exports(abs_out_basename, fmts):
+ return
+
+ self.statusbar.set_export_label(f"Exporting {abs_out_basename} to {fmts}")
+ # rst2odt(source, dest)
+ doc = TestListDocument(
+ test_suite=self.manager.test_suite,
+ reload_template=config.PACKAGE_CONF["export"].reload_templates_on_export,
+ template_name=config.PACKAGE_CONF["export"].test_list_template_name,
+ )
+ doc.multi_exports_async(
+ fmts,
+ abs_out_basename,
+ lambda: self._multi_export_callback(abs_out_basename, fmts),
+ )
+
+ def export_test_results(self, dir_name: Optional[str] = None):
+ """Exports a TestResultsDocument file to the given directory. If the directory
+ is None, the user is asked to select a directory.
- def export_rtv(self, dir_name: Optional[str] = None):
+ Args:
+ dir_name: Path to the export directory. Defaults to None.
+ """
if dir_name is None:
dir_name = self.get_existing_dir()
if dir_name == "":
return
- test = self.current_test
- self.apply_changes(test)
- target_dir = os.path.join(dir_name, "rtv")
+ self.apply_all_changes()
+ model = "test_results"
- self.manager.export(dir_name, "rtv")
+ fmts = []
+ abs_out_basename = os.path.join(dir_name, model)
+ if self.manager is not None and self.manager.module is not None:
+ abs_out_basename = os.path.join(
+ dir_name, f"{model}_{self.manager.module.last_name}"
+ )
+ fmts.extend(config.PACKAGE_CONF["export"].export_fmts)
- source = os.path.join(target_dir, "rtv.rst")
- dest = os.path.join(target_dir, "rtv.odt")
- rst2odt(source, dest)
+ else:
+ raise ValueError("No manager or module loaded")
- self.odt_created(dest)
+ if not self._check_exports(abs_out_basename, fmts):
+ return
+ self.statusbar.set_export_label(f"Exporting {abs_out_basename} to {fmts}")
+ doc = TestResultsDocument(
+ test_suite=self.manager.test_suite,
+ reload_template=config.PACKAGE_CONF["export"].reload_templates_on_export,
+ template_name=config.PACKAGE_CONF["export"].test_results_template_name,
+ )
+ doc.multi_exports_async(
+ fmts,
+ abs_out_basename,
+ lambda: self._multi_export_callback(abs_out_basename, fmts),
+ )
- def odt_created(self, file: str):
+ def doc_exported(self, file: str):
+ self.statusbar.set_export_label("")
odt_mb = QW.QMessageBox(
QW.QMessageBox.NoIcon,
"TestManager",
- f"Odt file generated in: \n{file}",
+ f"File generated: \n{file}",
QW.QMessageBox.StandardButtons(QW.QMessageBox.Open | QW.QMessageBox.Close),
)
- odt_mb.accepted.connect(lambda: self.open_odt_files(file)) # type: ignore
+ odt_mb.accepted.connect(lambda: self.open_file(file)) # type: ignore
odt_mb.exec_()
- def open_odt_files(self, fname: str):
+ def open_file(self, fname: str):
QG.QDesktopServices.openUrl(QC.QUrl.fromLocalFile(fname))
diff --git a/moduletester/locale/fr/LC_MESSAGES/moduletester.po b/moduletester/locale/fr/LC_MESSAGES/moduletester.po
new file mode 100644
index 0000000..05a80b2
--- /dev/null
+++ b/moduletester/locale/fr/LC_MESSAGES/moduletester.po
@@ -0,0 +1,48 @@
+# French translations for moduletester.
+# Copyright (C) 2026 ORGANIZATION
+# This file is distributed under the same license as the moduletester project.
+#
+msgid ""
+msgstr ""
+"Language: fr\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+msgid "Error during moduletester file loading."
+msgstr "Erreur lors du chargement du fichier moduletester."
+
+#, python-format
+msgid "Error while parsing the .moduletester file: %s"
+msgstr "Erreur lors du chargement du fichier .moduletester : %s"
+
+msgid "Missing modules:"
+msgstr "Modules manquants :"
+
+msgid "Do you want to reimport all tests and override the current file?"
+msgstr "Vous voulez rรฉimporter tous les tests et รฉcraser le fichier actuel ?"
+
+msgid "Error during module loading."
+msgstr "Erreur lors du chargement du module."
+
+msgid "Error while importing modules:"
+msgstr "Erreur lors de l'importation des modules :"
+
+msgid "The modules are still visible in moduletester but should be fixed or removed."
+msgstr "Les modules sont toujours visibles dans moduletester mais devraient รชtre corrigรฉs ou supprimรฉs."
+
+msgid "Test running"
+msgstr "Test en cours"
+
+msgid "A test is currently running, please wait for it to finish before saving."
+msgstr "Un test est en cours, veuillez attendre qu'il se termine avant de sauvegarder."
+
+#, python-format
+msgid ""
+"This package encountered the following error during import:\n"
+"%s"
+msgstr ""
+"Ce paquet a rencontrรฉ l'erreur suivante lors de l'import :\n"
+"%s"
+
diff --git a/moduletester/manager.py b/moduletester/manager.py
index bf7f555..d81dc95 100644
--- a/moduletester/manager.py
+++ b/moduletester/manager.py
@@ -2,18 +2,18 @@
# pylint: disable=missing-function-docstring, missing-module-docstring
# guitest: skip
+from __future__ import annotations
import os
+import shutil
from dataclasses import dataclass, field
-from importlib import import_module
-from typing import Optional
+from typing import Callable, Optional
-import click
from guidata.guitest import get_test_package # type: ignore
-from .exporter import TestSuiteExporter
+from moduletester import config as cfg
+
from .model import Module, TestSuite
-from .python_helpers import rst2odt
from .serializer import dumper, loader
CONTEXT_SETTINGS = dict(
@@ -31,30 +31,47 @@ class TestManager:
test_suite: TestSuite = field(init=False)
up_to_date: bool = field(init=False, default=True)
+ _config_conflict_err: Optional[cfg.ConfigConflictError] = field(
+ init=False, default=None
+ )
+ _config_path_val_err: Optional[cfg.InvalidPathError] = field(
+ init=False, default=None
+ )
+
_category: str = "all"
_template_path: str = ""
def __post_init__(self):
- if (self.module is None and self.moduletester_path is None) or (
- self.module is not None and self.moduletester_path is not None
+ mod = self.module
+ if (mod is None and self.moduletester_path is None) or (
+ mod is not None and self.moduletester_path is not None
):
raise ValueError("One argument should be None")
- elif self.module is not None:
+ elif mod is not None:
print(self._category)
- self.test_suite = TestSuite(self.module, _category=self._category)
+ test_suite = self._try_load_testsuite(
+ lambda: TestSuite(mod, _category=self._category)
+ )
+ if test_suite is None:
+ return
+
+ self.test_suite = test_suite
if self._template_path == "":
- self._template_path = os.path.join(
- self.module.path, "template.moduletester"
- )
+ self._template_path = os.path.join(mod.path, "template.moduletester")
dumper(self._template_path, self.test_suite)
print(f"Template created in '{self._template_path}'")
- elif self.moduletester_path is not None:
- test_suite = loader(self.moduletester_path)
+ elif (mod_p := self.moduletester_path) is not None:
+ test_suite = self._try_load_testsuite(lambda: loader(mod_p))
+
+ if test_suite is None:
+ return
+
self.module = test_suite.package
+
test_package = get_test_package(self.module.module)
for test in test_suite.tests:
test.retrieve_category(test_package)
@@ -62,197 +79,55 @@ def __post_init__(self):
self.test_suite = test_suite
self.up_to_date = True
+ def _try_load_testsuite(
+ self, test_suite_init: Callable[[], TestSuite | None]
+ ) -> TestSuite | None:
+ try:
+ return test_suite_init() # type: ignore
+ except cfg.ConfigConflictError as e:
+ self._config_conflict_err = e
+ return None
+ except cfg.InvalidPathError as e:
+ self._config_path_val_err = e
+ return None
+
+ def get_missing_modules(self) -> list[Module]:
+ return self.test_suite.get_missing_modules()
+
+ def get_errored_modules(self) -> list[Module]:
+ return self.test_suite.get_errored_modules()
+
def reload(self):
""" """
self.test_suite.reset()
- def save_as(self, moduletester_path):
+ def refresh_package(self, category: Optional[str] = None):
+ self.test_suite.refresh_package(category)
+
+ def save_as(self, moduletester_path: str):
""" """
+ backup_file = moduletester_path + ".bkp"
+ shutil.copy(moduletester_path, backup_file)
dumper(moduletester_path, self.test_suite)
self.moduletester_path = moduletester_path
+ os.remove(backup_file)
def save(self):
""" """
- dumper(self.moduletester_path, self.test_suite)
+ self.save_as(self.moduletester_path or "")
def open(self, moduletester_path: str):
""" """
test_suite = loader(moduletester_path)
self.test_suite = test_suite
- def export(self, basedir: str, model: str):
- if model.lower() in ["rtv", "dtv"]:
- basedir = os.path.abspath(basedir)
- path_to_temp = os.path.join(basedir, model, "tmp")
- path_to_rst = os.path.join(basedir, model, f"{model}.rst")
-
- os.makedirs(path_to_temp, exist_ok=True)
-
- exporter = TestSuiteExporter(self.test_suite)
- export_section = (
- exporter.export_section_dtv
- if model == "dtv"
- else exporter.export_section_rtv
- )
-
- exporter.export(path_to_rst, path_to_temp, export_section)
- else:
- print("model parameter must be in ['rtv', 'dtv']")
-
-
-@click.group(context_settings=CONTEXT_SETTINGS)
-def cli():
- pass
-
-
-@cli.command()
-@click.argument("package")
-@click.option(
- "--output", "-o", default="", help="output path for .moduletester template file"
-)
-def template(package: str, output: str = ""):
- """Generate .moduletester template file"""
- mod = import_module(package)
-
- if output != "" and os.path.isdir(output):
- output = os.path.join(output, "template.moduletester")
- elif (
- output != ""
- and not os.path.exists(output)
- and not os.path.basename(output).endswith(".moduletester")
- ):
- raise ValueError(f"'{output}' is incorrect for output")
-
- _ = TestManager(Module(mod), _template_path=output)
-
-
-@cli.command(context_settings=CONTEXT_SETTINGS)
-@click.pass_context
-@click.argument("moduletester_path")
-@click.option("--category", "-c", default="all", help="guitest category")
-@click.option("save_path", "--save", "-s", default="", help="Path to save result")
-@click.option("pattern", "--pattern", "-p", default="", help="test name pattern")
-@click.option("--timeout", default=86400, type=int, help="test timeout")
-def run(
- ctx,
- moduletester_path: str,
- pattern: str = "",
- category: str = "all",
- save_path: str = "",
- timeout: int = 86400,
-):
- """Run tests with --test-args"""
- testmanager = TestManager(moduletester_path=moduletester_path)
-
- if save_path == "":
- save_path = moduletester_path
- elif os.path.exists(save_path):
- raise ValueError(f'"{save_path}" already exists')
-
- args = ""
- if len(ctx.args) != 0:
- args = " ".join(ctx.args)
-
- testmanager.test_suite.run(category, pattern, timeout, args)
- testmanager.save_as(save_path)
- print(f"Run saved in {save_path}")
-
-
-@cli.command()
-@click.argument("moduletester_path")
-@click.option("output_dir", "--output", "-o", default="", help="Output directory")
-def dtv(moduletester_path: str, output_dir: str = ""):
- """Generate dtv for given .moduletester file"""
- testmanager = TestManager(moduletester_path=moduletester_path)
- assert testmanager.module
-
- if output_dir == "":
- output_dir = testmanager.module.path
-
- testmanager.export(output_dir, "dtv")
- print(f"DTV exported in {output_dir}")
-
-
-@cli.command()
-@click.argument("moduletester_path")
-@click.option("output_dir", "--output", "-o", default="", help="Output directory")
-def rtv(moduletester_path: str, output_dir: str = ""):
- """Generate rtv for given .moduletester file"""
- testmanager = TestManager(moduletester_path=moduletester_path)
- assert testmanager.module
-
- if output_dir == "":
- output_dir = testmanager.module.path
-
- testmanager.export(output_dir, "rtv")
- print(f"RTV exported in {output_dir}")
-
-
-@cli.command()
-@click.argument("moduletester_path")
-@click.option("output_dir", "--output", "-o", default="", help="Output directory")
-def doc(moduletester_path: str, output_dir: str = ""):
- """Generate both rtv and dtv for given .testmanager file"""
- testmanager = TestManager(moduletester_path=moduletester_path)
-
- if testmanager.module is not None and output_dir == "":
- path = testmanager.module.path
- elif output_dir != "":
- path = output_dir
- else:
- return
-
- testmanager.export(path, "dtv")
- testmanager.export(path, "rtv")
- print(f"DTV/RTV exported in {path}")
-
-
-@cli.command
-@click.argument("rst_path")
-@click.option("output_file", "--output", "-o", default="", help="Output file")
-def odt(rst_path: str, output_file: str = ""):
- """Convert rst file to odt file"""
- if output_file == "":
- output_file = os.path.abspath(rst_path.replace(".rst", ".odt"))
- rst2odt(rst_path, output_file)
-
-
-@cli.command
-@click.argument("moduletester_path")
-def ls(moduletester_path: str): # pylint: disable=invalid-name
- """list test in file"""
- testmanager = TestManager(moduletester_path=moduletester_path, _category="batch")
-
- if len(testmanager.test_suite.tests) == 0:
- print(f"No test found in file {moduletester_path}")
- return
-
- print(f"{len(testmanager.test_suite.tests)} tests found")
- for test in testmanager.test_suite.tests:
- print(test.package.name_from_source)
-
-
-@cli.command
-@click.argument("moduletester_path")
-def tree(moduletester_path: str):
- """list test in file grouped by directory"""
- testmanager = TestManager(moduletester_path=moduletester_path, _category="batch")
- grouped_tests = testmanager.test_suite.group_tests()
-
- len_test = len(testmanager.test_suite.tests)
- if len_test == 0:
- print(f"No tests found in file {moduletester_path}")
- return
-
- print(f"{len_test} test found\n")
- for key, tests in grouped_tests.items():
- print(f"{key}:")
-
- for test in tests:
- print(f" | {test.package.last_name}")
-
- print("\n")
+ def pre_export(self, basedir: str, model: str):
+ basedir = os.path.abspath(basedir)
+ model_path = os.path.join(basedir, model)
+ os.makedirs(model_path, exist_ok=True)
+ def get_conf_conflict_err(self) -> Optional[cfg.ConfigConflictError]:
+ return self._config_conflict_err
-if __name__ == "__main__":
- cli()
+ def get_conf_path_val_err(self) -> Optional[cfg.InvalidPathError]:
+ return self._config_path_val_err
diff --git a/moduletester/model.py b/moduletester/model.py
index f172411..7632db5 100644
--- a/moduletester/model.py
+++ b/moduletester/model.py
@@ -1,6 +1,9 @@
# pylint: disable=empty-docstring, missing-class-docstring, keyword-arg-before-vararg
# pylint: disable=missing-function-docstring, missing-module-docstring
# guitest: skip
+
+import contextlib
+import importlib
import os
import shlex
import signal
@@ -11,11 +14,21 @@
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
+from importlib import metadata
from types import ModuleType
-from typing import Dict, List, Optional, Union
-from guidata.guitest import TestModule, get_tests # type: ignore
+# cannot import __future__.annotations because it breaks the ModuleType import for some
+# reason. The line: 'loader(self.moduletester_path)' return a string instead of a
+# module
+from typing import Dict, List, Optional, Tuple, Union
+
+import pypandoc
+from guidata.configtools import get_image_file_path
+from guidata.guitest import TestModule, get_test_package, get_tests # type: ignore
+import moduletester.module_not_found as empty_module
+from moduletester import config
+from moduletester.config import _
from moduletester.python_helpers import get_image_path # type: ignore
from moduletester.serializer import (
DataclassSerializer,
@@ -30,11 +43,60 @@
# ============================================================================
+class ModuleErrorType(ModuleType):
+ """Base class for module errors."""
+
+
+class ModuleNotFoundType(ModuleErrorType):
+ """Module proxy to handle missing modules cleanly.
+
+ Args:
+ name: The name of the missing module.
+ """
+
+ __file__ = empty_module.__file__
+ __path__ = [os.path.dirname(empty_module.__file__)]
+
+ def __init__(self, name: str):
+ super().__init__(name)
+ self.__doc__ = empty_module.__doc__
+
+
+class ModuleInternalErrorType(ModuleErrorType):
+ """Module proxy to handle module erros at import (error in the module).
+
+ Args:
+ test_package: The test package from which the import was tried.
+ path: The path of the errored module.
+ error: The error message.
+ """
+
+ def __init__(self, test_package: ModuleType, path: str, error: str):
+ test_pkg_file = test_package.__file__
+ if test_pkg_file is None:
+ raise ValueError(
+ "Attribute test_package.__file__ is None instead of a str path."
+ )
+ test_package_path = os.path.dirname(os.path.realpath(test_pkg_file))
+ name = os.path.relpath(path, test_package_path)
+ subpkgname = test_package.__name__
+ if len(name.split(os.sep)) > 1:
+ subpkgname += "." + ".".join(name.split(os.sep)).rstrip(".py")
+ super().__init__(subpkgname)
+ self.__file__ = path
+ self.__path__ = [os.path.dirname(path)]
+ self.__doc__ = (
+ _("This package encountered the following error during import:\n%s") % error
+ )
+
+
# @xxx.register
class Module:
""" """
def __init__(self, module: ModuleType):
+ if module.__name__ in sys.modules and not isinstance(module, ModuleErrorType):
+ module = importlib.reload(module)
self.module = module
def __copy__(self):
@@ -71,28 +133,38 @@ def path(self) -> str:
return self.module.__path__[0]
@property
- def doc(self) -> Optional[str]:
- if self.module.__doc__ is None:
- return None
- return self.module.__doc__.strip()
+ def doc(self) -> str:
+ return self.module.__doc__ or ""
@property
- def root_path(self) -> Optional[str]:
+ def author(self) -> str:
+ try:
+ return metadata.metadata(self.module.__name__)["Author"] or ""
+ except metadata.PackageNotFoundError as e:
+ print(e)
+ return ""
+
+ @property
+ def root_path(self) -> str:
path = self.module.__file__
if path is not None:
- if os.path.basename(path) == "__init__.py":
- path = os.path.join(path, "..")
- path = os.path.abspath(os.path.join(path, ".."))
-
- return path
+ # if os.path.basename(path) == "__init__.py":
+ # path = os.path.join(path, "..")
+ return os.path.abspath(os.path.join(path, ".."))
+ else:
+ return os.path.join(*self.module.__path__)
@classmethod
def __deserialize__(cls, obj: str) -> "Module":
try:
return cls(sys.modules[obj])
except KeyError:
- __import__(obj)
- return cls(sys.modules[obj])
+ try:
+ __import__(obj)
+ return cls(sys.modules[obj])
+ except ModuleNotFoundError as e:
+ print(e)
+ return cls(ModuleNotFoundType(obj))
class ModuleSerializer(ValueSerializerBase[Module, str]):
@@ -123,11 +195,26 @@ class StatusEnum(Enum):
class ResultEnum(Enum):
"""Results value for a test."""
- ACCEPTED = "accepted"
- ACCEPTED_WITH_RESERVES = "accepted with reserves"
- SKIPPED = "skipped"
- REJECTED = "rejected"
- NO_RESULT = "no result"
+ ACCEPTED = "accepted", "green-check-square.png"
+ ACCEPTED_WITH_RESERVES = "accepted with reserves", "yellow-check-square.png"
+ SKIPPED = "skipped", "skip.png"
+ REJECTED = "rejected", "rejected.png"
+ NO_RESULT = "no result", "unknown.png"
+
+ icon_path: str
+
+ def __new__(cls, label: str, icon_name: Optional[str] = None):
+ obj = object.__new__(cls)
+ obj._value_ = label
+ obj.icon_path = get_image_file_path(icon_name or "")
+ return obj
+
+ def __init__(self, _label: str, __ignored=None) -> None: ...
+
+ """Fake init method used to get the correct linting/auto-completion."""
+
+ def format(self) -> str:
+ return self.name.replace("_", " ")
# ============================================================================
@@ -151,6 +238,10 @@ class TestResult:
error_code: Optional[int] = None
error_msg: str = ""
+ def __post_init__(self):
+ if isinstance(self.last_run, str):
+ self.last_run = datetime.strptime(self.last_run, "%d/%m/%y %H:%M:%S.%f")
+
@property
def result_name(self) -> str:
return self.result.name.replace("_", " ")
@@ -160,6 +251,9 @@ def status_name(self) -> str:
return self.status.name.replace("_", " ")
+FormatArgsType = Tuple[str, ...]
+
+
@DataclassSerializer.register
@dataclass
class Test:
@@ -169,9 +263,11 @@ class Test:
description: str = ""
result: Optional[TestResult] = None
command_args: str = ""
- command_timeout: int = 86400
+ command_timeout: int = 0
run_opts: List[str] = field(default_factory=list)
is_valid: bool = True
+ _is_new_message: bool = False
+ _is_new_error: bool = False
_end_time: float = 0
_is_running: bool = False
_forced: bool = False
@@ -181,6 +277,8 @@ class Test:
_tf: float = 0
_command: str = ""
_is_stopped: bool = False
+ _cached_description: Dict[FormatArgsType, str] = field(default_factory=dict)
+ _max_cache_size = 10
def __post_init__(self):
if self.description == "":
@@ -196,6 +294,7 @@ def end_time(self, end_time):
@property
def command(self):
+ self._command = self.build_command()
return self._command
def is_visible(self):
@@ -210,17 +309,31 @@ def is_skipped(self):
def set_skipped(self, is_skipped):
self._is_skipped = is_skipped
+ def is_message_new(self):
+ return self._is_new_message
+
+ def is_error_new(self):
+ return self._is_new_error
+
+ def set_error_state(self, is_new: bool):
+ self._is_new_error = is_new
+
+ def set_message_state(self, is_new: bool):
+ self._is_new_message = is_new
+
def __enter__(self):
""" """
def __exit__(self, _type, _value, _traceback):
""" """
- forced = self._end_time > self._tf
+ forced = self._end_time is None or self._end_time > self._tf
if not forced:
self.stop(False)
- assert self.result is not None
+ assert self.result is not None and self._proc is not None
- self.result.execution_duration = time.time() - (self._tf - self.command_timeout)
+ self.result.execution_duration = round(
+ time.time() - (self._tf - self.command_timeout), 2
+ )
self.result.error_code = self._proc.returncode
self.result.last_run = datetime.now()
@@ -242,42 +355,64 @@ def stop(self, forced: bool = False):
if self._proc is not None and self._is_running:
self._forced = forced
if forced:
- self._proc.send_signal(signal.CTRL_BREAK_EVENT)
+ if sys.platform == "win32":
+ self._proc.send_signal(signal.CTRL_BREAK_EVENT)
+ elif sys.platform == "linux":
+ os.killpg(self._proc.pid, signal.SIGKILL)
self._is_stopped = True
self._is_running = False
else:
self.wait_kill()
self._is_running = False
+ def build_command(self) -> str:
+ """Builds the command to run the test.
+
+ Returns:
+ Command line as a string
+ """
+ command = [
+ sys.executable,
+ "-u",
+ "-X",
+ "utf8",
+ f"{self.package.module.__file__}",
+ ]
+
+ if self.command_args:
+ command.extend(shlex.split(self.command_args))
+ self._tf = time.time() + self.command_timeout
+
+ return shlex.join(command).replace("'", '"')
+
def run(self):
"""Runs test"""
if self._proc is None:
self._is_stopped = False
- self._end_time = None
+ self._end_time = 0
os.environ["PYTHONPATH"] = os.pathsep.join(sys.path)
- path = self.package.module.__file__
if self.result is None:
self.result = TestResult(StatusEnum.NOT_EXECUTED)
self.result.error_msg = ""
self.result.output_msg = ""
- command = [sys.executable, "-u", "-X", "utf8", f"{path}"]
-
- if self.command_args:
- command.append(self.command_args)
- self._tf = time.time() + self.command_timeout
-
- self._command = shlex.join(command).replace("'", '"')
+ self._command = self.build_command()
self._proc = subprocess.Popen(
- command,
+ self._command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
+ creationflags=(
+ subprocess.CREATE_NEW_PROCESS_GROUP
+ if sys.platform == "win32"
+ else 0
+ ),
bufsize=1,
universal_newlines=True,
+ encoding="utf-8",
+ preexec_fn=os.setpgrp if sys.platform == "linux" else None,
)
self._is_running = True
else:
@@ -293,7 +428,7 @@ def is_running(self) -> bool:
""" """
return (
self._proc is not None
- and time.time() < self._tf
+ and (self.command_timeout <= 0 or time.time() < self._tf)
and self._proc.returncode is None
and not self._is_stopped
)
@@ -301,17 +436,13 @@ def is_running(self) -> bool:
def communicate(self, timeout: float = 1):
""" """
if self._proc is not None and self.result is not None:
- try:
+ with contextlib.suppress(subprocess.TimeoutExpired):
last_outs, last_errs = self._proc.communicate(timeout=timeout)
- # self.result.output_msg += last_outs.decode("utf-8")
- # self.result.error_msg += last_errs.decode("utf-8")
-
- self.result.output_msg += last_outs
- self.result.error_msg += last_errs
-
- except subprocess.TimeoutExpired:
- pass
+ if last_outs is not None:
+ self.result.output_msg += last_outs
+ if last_errs is not None:
+ self.result.error_msg += last_errs
else:
raise subprocess.SubprocessError("No subprocess running.")
@@ -321,25 +452,147 @@ def wait_kill(self):
if self._proc is not None:
self._proc.kill()
while self._proc is not None and self._proc.returncode is None:
- try:
+ with contextlib.suppress(subprocess.TimeoutExpired):
self.communicate(timeout=0.5)
- except subprocess.TimeoutExpired:
- pass
- def get_description(self) -> Optional[str]:
- return self.package.doc
+ def get_description(self) -> str:
+ return self.package.doc or ""
+
+ def get_fmt_description(
+ self, fmt: str, extra_args: Optional[List[str]] = None, use_cached: bool = True
+ ) -> str:
+ """Get the description of the test in the specified format.
+
+ Args:
+ fmt: format to convert the docstring into.
+ extra_args: Extra Pandoc args. Defaults to None.
+ use_cached: Use a cached version of the formated description.
+ Defaults to True.
+
+ Returns:
+ The description of the test in the specified format.
+ """
+ if extra_args is None:
+ extra_args = []
+ extra_args.append(f"--resource-path={self.package.root_path}")
+ fmt_args = (fmt, *extra_args)
+ doc = self._cached_description.get(fmt_args, None) if use_cached else None
+ if doc is None:
+ doc = pypandoc.convert_text(
+ self.get_description() or "",
+ fmt,
+ format=config.PACKAGE_CONF["general"].docstring_fmt,
+ extra_args=extra_args,
+ )
+ self._cached_description[fmt_args] = doc
+ return doc
+
+ def _get_pandoc_extra_args(
+ self,
+ standalone=False,
+ embeded=True,
+ shift_header=0,
+ apply_style: bool = False,
+ quiet=True,
+ ) -> List[str]:
+ """Simply computes a list of extra arguments for pandoc.
+
+ Args:
+ standalone: Generate a standalone document. Defaults to False.
+ embeded: Embed all ressources in the document. Defaults to True.
+ shift_header: Shift header level by specified value. Defaults to 0.
+ apply_style: Apply the given styles from the css file given in config.
+ Defaults to False.
+ quiet: Flag for pandoc verbose level. Defaults to True.
+
+ Returns:
+ List of extra arguments for pandoc.
+ """
+ extra_args = []
+ if standalone:
+ extra_args.append("--standalone")
+ if embeded:
+ extra_args.append("--embed-resources")
+ if shift_header != 0:
+ extra_args.append(f"--shift-heading-level-by={shift_header}")
+ if apply_style:
+ extra_args.append(f"--css={config.PACKAGE_CONF['export'].get_css_style()}")
+ if quiet:
+ extra_args.append("--quiet")
+ return extra_args
+
+ def get_html_description(
+ self,
+ standalone=False,
+ embeded=True,
+ shift_header=0,
+ apply_style: bool = False,
+ use_cached: bool = True,
+ ) -> str:
+ """Get the description of the test in HTML format.
+
+ Args:
+ standalone: Generate a standalone document. Defaults to False.
+ embeded: Embed all ressources in the document. Defaults to True.
+ shift_header: Shift header level by specified value. Defaults to 0.
+ apply_style: Apply the given styles from the css file given in config.
+ Defaults to False.
+ use_cached: Use a cached version of the formated description.
+ Defaults to True.
+
+ Returns:
+ The description of the test in HTML format.
+ """
+ extra_args = self._get_pandoc_extra_args(
+ standalone, embeded, shift_header, apply_style, True
+ )
+ return self.get_fmt_description(
+ "html", extra_args=extra_args, use_cached=use_cached
+ )
+
+ def get_txt_description(
+ self, standalone=False, embeded=True, shift_header=0
+ ) -> str:
+ """Get the description of the test in plain text format.
+
+ Args:
+ standalone: Generate a standalone document. Defaults to False.
+ embeded: Embed all ressources in the document. Defaults to True.
+ shift_header: Shift header level by specified value. Defaults to 0.
+
+ Returns:
+ The description of the test in plain text format.
+ """
+ extra_args = self._get_pandoc_extra_args(standalone, embeded, shift_header)
+
+ return self.get_fmt_description("plain", extra_args=extra_args)
+
+ def get_md_description(self, standalone=False, embeded=True, shift_header=0) -> str:
+ """Get the description of the test in markdown format.
+
+ Args:
+ standalone: Generate a standalone document. Defaults to False.
+ embeded: Embed all ressources in the document. Defaults to True.
+ shift_header: Shift header level by specified value. Defaults to 0.
+
+ Returns:
+ The description of the test in markdown format.
+ """
+ extra_args = self._get_pandoc_extra_args(standalone, embeded, shift_header)
+
+ return self.get_fmt_description("md", extra_args=extra_args)
def get_images(self, image_dirs: List[str]) -> List[str]:
""" """
return get_image_path(self.package.last_name, image_dirs)
- def retrieve_category(self, test_package: Module):
+ def retrieve_category(self, test_package: ModuleType):
path = self.package.module.__file__
test_module = TestModule(test_package, path)
self.set_visible(test_module.is_visible())
self.set_skipped(test_module.is_skipped())
- def get_code_snippet(self, test_package: Module):
+ def get_code_snippet(self, test_package: ModuleType):
path = self.package.module.__file__
test_module = TestModule(test_package, path)
@@ -355,6 +608,21 @@ def build_from_test_module(cls, test_module: TestModule) -> "Test":
test._is_visible = test_module.is_visible()
return test
+ def result_binary_label(self) -> Tuple[int, ...]:
+ """Computes the binary label of the result.
+
+ Returns:
+ Tuple of int, 1 if the result is the same as the test result, 0 otherwise.
+ """
+ return tuple(
+ map(
+ lambda res: (
+ 1 if (self.result is not None and self.result.result is res) else 0
+ ),
+ ResultEnum,
+ )
+ )
+
@DataclassSerializer.register
@dataclass
@@ -366,29 +634,92 @@ class TestSuite:
description: str = ""
last_run: Optional[datetime] = None
- tests: Optional[List[Test]] = None
- _category: str = "all"
+ tests: List[Test] = field(default_factory=list)
+
+ _category: str = config.PACKAGE_CONF["general"].category
_running_test: Optional[Test] = None
+ def __post_init__(self) -> None:
+ if len(self.tests) == 0:
+ self.reset()
+ self.author = self.package.author
+ self.description = self.package.doc
+ config.load_package_conf(self.package.root_path)
+
+ def get_fmt_description(self, fmt="html", extra_args=None):
+ """Return the description of the test suite in the specified format.
+
+ Args:
+ fmt: format to convert the docstring into. Defaults to "html".
+ extra_args: Extra Pandoc args. Defaults to None.
+
+ Returns:
+ The description of the test suite in the specified format.
+ """
+ if extra_args is None:
+ extra_args = []
+ extra_args.append(f"--resource-path={self.package.root_path}")
+ return pypandoc.convert_text(
+ self.description or "",
+ fmt,
+ format=config.PACKAGE_CONF["general"].docstring_fmt,
+ extra_args=extra_args,
+ )
+
@property
- def package_name(self):
+ def package_name(self) -> str:
return self.package.module.__name__
@property
- def running_test(self):
+ def running_test(self) -> Optional[Test]:
return self._running_test
- def __post_init__(self):
- if self.tests is None:
- self.tests = []
- self.reset()
-
- def reset(self):
+ def reset(self) -> None:
"""category must be "all", "visible", or "batch"."""
self.tests.clear()
for test_module in get_tests(self.package.module, self._category):
+ if not test_module.is_valid():
+ test_module.module = self.load_errored_test_module(test_module)
+ test = Test.build_from_test_module(test_module)
+ self.tests.append(test)
+
+ def refresh_package(self, category: Optional[str] = None) -> None:
+ """Refresh the package and its tests.
+
+ Args:
+ category: category must be "all", "visible", or "batch". Defaults to None.
+ """
+ exising_tests: Dict[str, Test] = {
+ test.package.full_name: test for test in self.tests
+ }
+ self.tests.clear()
+ category = category or self._category
+ for test_module in get_tests(self.package.module, category):
+ if not test_module.is_valid():
+ test_module.module = self.load_errored_test_module(test_module)
test = Test.build_from_test_module(test_module)
+ if old_test := exising_tests.get(test.package.full_name, None):
+ test.result = old_test.result
+ test.command_args = old_test.command_args
+ test.command_timeout = old_test.command_timeout
+ test.run_opts = old_test.run_opts
+
self.tests.append(test)
+ self.__post_init__()
+
+ def load_errored_test_module(
+ self, test_module: TestModule
+ ) -> ModuleInternalErrorType:
+ """Load the errored test module.
+
+ Args:
+ test_module: The test module that errored during import.
+
+ Returns:
+ The errored test module wrapped in a ModuleType proxy class.
+ """
+ package = get_test_package(self.package.module)
+ return ModuleInternalErrorType(package, test_module.path, test_module.error_msg)
# Run related methods
def run(
@@ -397,7 +728,7 @@ def run(
pattern: str = "",
timeout: Optional[int] = None,
test_args: Optional[str] = None,
- ):
+ ) -> None:
""""""
assert self.tests
self.last_run = datetime.now()
@@ -411,18 +742,20 @@ def run(
self._running_test = test
with test.start():
- while self.running_test.is_running():
- self.running_test.communicate(0.5)
- self.running_test.end_time = time.time()
+ while test.is_running():
+ test.communicate(0.5)
+ # Kills the test if still running, otherwise the process could keep
+ # running in the background
+ test.stop()
+ test.end_time = time.time()
self._running_test = None
- def terminate_run(self):
+ def terminate_run(self) -> None:
pass
def should_run(self, test: Test, category: str = "all", pattern: str = "") -> bool:
package = test.package
called = self.is_called(package, pattern)
-
is_valid = (
category == "all"
or (category == "visible" and test.is_visible())
@@ -432,8 +765,9 @@ def should_run(self, test: Test, category: str = "all", pattern: str = "") -> bo
return is_valid and called
def is_called(self, package: Module, pattern: str = "") -> bool:
- path = str(package.module.__file__)
- if pattern in ("", "*") or pattern in path or pattern in package.full_name:
+ # path = str(package.module.__file__)
+ # if pattern in ("", "*") or pattern in path or pattern in package.full_name:
+ if pattern in ("", "*") or pattern == package.last_name:
return True
return False
@@ -446,3 +780,47 @@ def group_tests(self) -> Dict[str, List[Test]]:
diff_path[str(test.package.module.__package__)].append(test)
return diff_path
+
+ def get_missing_modules(self) -> List[Module]:
+ """Get the list of missing modules.
+
+ Returns:
+ List of missing modules.
+ """
+ missing_modules_list = [
+ test.package
+ for test in self.tests
+ if isinstance(test.package.module, ModuleNotFoundType)
+ ]
+ return missing_modules_list
+
+ def get_errored_modules(self) -> list[Module]:
+ """Get the list of errored modules (for which the import failed).
+
+ Returns:
+ List of errored modules.
+ """
+ error_modules_list = [
+ test.package
+ for test in self.tests
+ if isinstance(test.package.module, ModuleInternalErrorType)
+ ]
+ return error_modules_list
+
+ def results_binary_labels(self) -> List[Tuple[int, ...]]:
+ """Computes the binary labels of the results.
+
+ Returns:
+ List of tuples of int, 1 if the result is the same as the test result,
+ 0 otherwise.
+ """
+ return [test.result_binary_label() for test in self.tests]
+
+ def results_count(self) -> Tuple[int, ...]:
+ """Computes the of test result by result kind.
+
+ Returns:
+ Tuple of int, the count of test result by result kind.
+ """
+ results = self.results_binary_labels()
+ return tuple(map(sum, zip(*results)))
diff --git a/moduletester/module_not_found.py b/moduletester/module_not_found.py
new file mode 100644
index 0000000..e2540f8
--- /dev/null
+++ b/moduletester/module_not_found.py
@@ -0,0 +1,14 @@
+"""This module is empty and is used as a placeholder for moduletester when importing
+the .moduletester file and a test sub-module is not found.
+
+Pleaser, reload update the moduletester files to remove this sub-module or create a
+new moduletester file.
+"""
+
+# guitest: show
+
+if __name__ == "__main__":
+ raise ModuleNotFoundError(
+ "ModuleTester did not find this module in your package, "
+ "please update the .moduletester file."
+ )
diff --git a/moduletester/new_exporter.py b/moduletester/new_exporter.py
new file mode 100644
index 0000000..dc88ab6
--- /dev/null
+++ b/moduletester/new_exporter.py
@@ -0,0 +1,308 @@
+"""Jinja2-based document exporter for test results and test lists."""
+
+from __future__ import annotations
+
+import os
+import threading
+from abc import ABC
+from dataclasses import dataclass, field
+from typing import Callable, Iterable, Optional
+
+import pypandoc
+from jinja2 import Environment, FileSystemLoader, Template, select_autoescape
+
+from moduletester import config
+from moduletester.config import _
+
+_export_conf = config.PACKAGE_CONF["export"]
+
+DEFAULT_TEMPLATE_LOADER = FileSystemLoader(searchpath=_export_conf.template_dir)
+JINJA_ENV = Environment(
+ loader=DEFAULT_TEMPLATE_LOADER,
+ autoescape=select_autoescape(["html", "xml"]),
+ trim_blocks=True,
+ lstrip_blocks=True,
+)
+JINJA_ENV.globals["_"] = _
+
+FMT_TO_EXTENSION = {
+ "html": "html",
+ "docx": "docx",
+ "odt": "odt",
+ "rst": "rst",
+ "md": "md",
+ "markdown": "md",
+ "markdown_mmd": "md",
+ "markdown_github": "md",
+ "markdown_strict": "md",
+ "pdf": "pdf",
+ "latex": "tex",
+ "tex": "tex",
+}
+
+DEFAULT_TEMPLATE_NAME = _export_conf.test_results_template_name
+DEFAULT_DOCX_REFERENCE = _export_conf.get_docx_ref()
+
+DEFAULT_ODT_REFERENCE = _export_conf.get_odt_ref()
+
+DEFAULT_CSS_STYLE = _export_conf.get_css_style()
+
+DEFAULT_HEADER_SHIFT = _export_conf.docstrings_header_shift
+
+DEFAULT_TOC_DEPTH = _export_conf.toc_depth
+
+
+@dataclass
+class DocumentExporter(ABC):
+ """BaseClass for document exportation. To define specific documents, you should
+ inherit from this class and define the template_name attribute.
+ If the template_name is the default one, the class will search for a template
+ corresponding to [class name]_template.j2 in the template directory.
+ If the template is found, it will be loaded and used as"""
+
+ template_name: str = DEFAULT_TEMPLATE_NAME
+ reload_template: bool = False
+ resource_path: Optional[str] = None
+ docstrings_header_shift = DEFAULT_HEADER_SHIFT
+ toc_depth = DEFAULT_TOC_DEPTH
+ _template: Template = field(
+ init=False, default=JINJA_ENV.get_template(template_name)
+ )
+ _docx_reference: str = field(init=False, default=DEFAULT_DOCX_REFERENCE)
+ _odt_reference: str = field(init=False, default=DEFAULT_ODT_REFERENCE)
+ _css_style: str = field(init=False, default=DEFAULT_CSS_STYLE)
+
+ def render_html(self, with_toc=False) -> str:
+ """Render the document as html with or without a table of content.
+
+ Args:
+ with_toc: Insert a table of content. Defaults to False.
+
+ Returns:
+ generated html string
+ """
+ extra_args = [
+ "--embed-resources",
+ "--standalone",
+ f"--css={self._css_style}",
+ ]
+ if with_toc:
+ extra_args.extend(["--toc", f"--toc-depth={self.toc_depth}"])
+
+ if self.reload_template:
+ self._template = JINJA_ENV.get_template(self.template_name)
+
+ if self.resource_path is not None:
+ extra_args.append(f"--resource-path={self.resource_path}")
+
+ return pypandoc.convert_text(
+ self._template.render(doc_obj=self),
+ to="html",
+ format="html",
+ extra_args=extra_args,
+ )
+
+ def export(
+ self, filename: str, fmt="html", extra_args: Optional[list[str]] = None
+ ) -> None:
+ """Export the document to a file in the specified format.
+
+ Args:
+ filename: The name of the file to write to.
+ fmt: The format to export to. Defaults to "html".
+ extra_args: Additional arguments to pass to pandoc. Defaults to None.
+ """
+ if extra_args is None:
+ extra_args = []
+ if fmt == "html":
+ self.export_html(filename)
+ return
+
+ pypandoc.convert_text(
+ self.render_html(),
+ fmt,
+ format="html",
+ outputfile=filename,
+ extra_args=extra_args,
+ )
+
+ def export_html(self, filename: str) -> None:
+ """Export the document to a file in html format.
+
+ Args:
+ filename: The name of the file to write to.
+ """
+ with open(filename, "w", encoding="utf-8") as f:
+ d = self.render_html(with_toc=True)
+ f.write(d)
+
+ def export_docx(self, filename: str) -> None:
+ """Export the document to a file in docx format.
+
+ Args:
+ filename: The name of the file to write to.
+ """
+ extra_args = [
+ f"--reference-doc={self._docx_reference}",
+ "--toc",
+ f"--toc-depth={self.toc_depth}",
+ ]
+
+ self.export(filename, "docx", extra_args=extra_args)
+
+ def export_odt(self, filename: str) -> None:
+ """Export the document to a file in odt format.
+
+ Args:
+ filename: The name of the file to write to.
+ """
+ extra_args = [
+ f"--reference-doc={self._odt_reference}",
+ "--toc",
+ f"--toc-depth={self.toc_depth}",
+ ]
+ self.export(filename, "odt", extra_args=extra_args)
+
+ def export_rst(self, filename: str) -> None:
+ """Export the document to a file in rst format.
+
+ Args:
+ filename: The name of the file to write to.
+ """
+ self.export(filename, "rst")
+
+ def export_md(self, filename: str) -> None:
+ """Export the document to a file in markdown format.
+
+ Args:
+ filename: The name of the file to write to.
+ """
+ self.export(
+ filename,
+ "markdown-raw_html-native_divs-native_spans-fenced_divs-bracketed_spans-escaped_line_breaks",
+ )
+
+ def export_pdf(self, filename: str) -> None:
+ """Export the document to a file in pdf format.
+
+ Args:
+ filename: The name of the file to write to.
+ """
+ self.export(
+ filename,
+ "pdf",
+ extra_args=[
+ "--pdf-engine=xelatex",
+ "--toc",
+ f"--toc-depth={self.toc_depth}",
+ f"--css={self._css_style}",
+ ],
+ )
+
+ def _export_with_callback(
+ self,
+ filename: str,
+ fmt: str,
+ extra_args: Optional[list[str]],
+ callback: Optional[Callable[[], None]] = None,
+ ) -> None:
+ """Export the document to a file in the specified format and call the callback
+ function when the export is complete.
+
+ Args:
+ filename: The name of the file to write to.
+ fmt: The format to export to.
+ extra_args: Additional arguments to pass to pandoc.
+ callback: The function to call when the export is complete.
+ """
+ if extra_args is None:
+ extra_args = []
+ self.export(filename, fmt, extra_args=extra_args)
+ if callback is not None:
+ callback()
+
+ def export_docx_async(
+ self, filename: str, callback: Optional[Callable[[], None]] = None
+ ) -> None:
+ """Export the document to a file in docx format in a separate thread.
+
+ Args:
+ filename: The name of the file to write to.
+ callback: Callback to call once the export is finished. Defaults to None.
+ """
+ extra_args = [
+ f"--reference-doc={self._docx_reference}",
+ "--toc",
+ f"--toc-depth={self.toc_depth}",
+ ]
+ t = threading.Thread(
+ target=self._export_with_callback,
+ args=(filename, "docx", extra_args, callback),
+ )
+ t.start()
+
+ def export_html_async(
+ self, filename: str, callback: Optional[Callable[[], None]] = None
+ ) -> None:
+ """Export the document to a file in html format in a separate thread.
+
+ Args:
+ filename: The name of the file to write to.
+ callback: Callback to call once the export is finished. Defaults to None.
+ """
+ threading.Thread(
+ target=self._export_with_callback, args=(filename, "html", None, callback)
+ ).start()
+
+ def multi_exports(self, fmts: Iterable[str], basename: str):
+ """Export the document to multiple formats successively.
+
+ Args:
+ fmts: The formats to export to.
+ basename: The base name of the files to write to.
+
+ Raises:
+ ValueError: If an unknown format is specified.
+ """
+ for fmt in fmts:
+ if fmt not in FMT_TO_EXTENSION:
+ raise ValueError(f"Unknown format {fmt}")
+ export_method = getattr(self, f"export_{fmt}")
+ export_method(f"{basename}.{fmt}")
+ print(f"Export of {basename}.{fmt} complete")
+
+ def multi_exports_async(
+ self,
+ fmts: Iterable[str],
+ basename: str,
+ callback: Optional[Callable[[], None]] = None,
+ ):
+ """Export the document to multiple formats successively in a separate thread.
+
+ Args:
+ fmts: The formats to export to.
+ basename: The base name of the files to write to.
+ callback: Callback to call once the export is finished. Defaults to None.
+ """
+
+ def _multi_exports_async(
+ fmts: Iterable[str], basename: str, callback: Optional[Callable[[], None]]
+ ):
+ self.multi_exports(fmts, basename)
+ if callback is not None:
+ callback()
+
+ threading.Thread(
+ target=_multi_exports_async, args=(fmts, basename, callback)
+ ).start()
+
+ @classmethod
+ def __init_subclass__(cls) -> None:
+
+ if os.path.exists(os.path.join(_export_conf.template_dir, cls.template_name)):
+ cls.load_template()
+
+ @classmethod
+ def load_template(cls) -> None:
+ """Load the Jinja2 template from the configured directory."""
+ cls._template = JINJA_ENV.get_template(cls.template_name)
diff --git a/moduletester/python_helpers.py b/moduletester/python_helpers.py
index a1e6da5..0feca3e 100644
--- a/moduletester/python_helpers.py
+++ b/moduletester/python_helpers.py
@@ -27,8 +27,7 @@ def get_original_bases(cls):
class SupportsWrite(Protocol):
""" """
- def write(self, text: str) -> Any:
- ...
+ def write(self, text: str, /) -> Any: ...
# ============================================================================
@@ -119,10 +118,7 @@ def setup_sphinx(
try:
outs, errs = proc.communicate(timeout=0.5)
print(
- (
- f"[STDOUT] > {outs.decode('utf-8')}"
- f"[STDERR] > {errs.decode('utf-8')}"
- )
+ (f"[STDOUT] > {outs.decode('utf-8')}[STDERR] > {errs.decode('utf-8')}")
)
except subprocess.TimeoutExpired:
pass
@@ -155,7 +151,7 @@ def exec_rst(
print(outs, errs)
-def parse_html(html_path: str, dtv_path: str):
+def parse_html(html_path: str, test_list_path: str):
"""Removes the navbar from the index.html file"""
soup = None
with open(html_path, "r", encoding="utf-8") as html_doc:
@@ -169,7 +165,7 @@ def parse_html(html_path: str, dtv_path: str):
if footer is not None:
footer.replaceWith("")
- with open(dtv_path, "w", encoding="utf-8") as html_doc:
+ with open(test_list_path, "w", encoding="utf-8") as html_doc:
html_doc.write(soup.prettify())
@@ -212,7 +208,7 @@ def get_image_path(file_name, dirs):
def rst2odt(source: str, dest: str):
""" """
python = sys.executable
- script = os.path.join(sys.base_prefix, "Scripts", "rst2odt.py")
+ script = os.path.join(sys.prefix, "Scripts", "rst2odt.py")
proc = subprocess.Popen(" ".join([python, script, source, dest]))
@@ -226,7 +222,7 @@ def rst2odt(source: str, dest: str):
def rst2html(source: str, dest: str):
""" """
python = sys.executable
- script = os.path.join(sys.base_prefix, "Scripts", "rst2html.py")
+ script = os.path.join(sys.prefix, "Scripts", "rst2html.py")
proc = subprocess.Popen(" ".join([python, script, source, dest]))
diff --git a/moduletester/serializer.py b/moduletester/serializer.py
index 49cfd2d..804de95 100644
--- a/moduletester/serializer.py
+++ b/moduletester/serializer.py
@@ -1,5 +1,7 @@
# pylint: disable=empty-docstring, missing-class-docstring,
# pylint: disable=missing-function-docstring, missing-module-docstring
+from __future__ import annotations
+
import json
from abc import ABC, abstractmethod
from dataclasses import asdict, fields, is_dataclass
@@ -30,12 +32,10 @@ def register_data_type(
cls.TYPES[data_type] = serializer or cls()
@abstractmethod
- def serialize(self, obj: ISerializerT) -> IJsonT:
- ...
+ def serialize(self, obj: ISerializerT) -> IJsonT: ...
@abstractmethod
- def deserialize(self, obj: IJsonT) -> ISerializerT:
- ...
+ def deserialize(self, obj: IJsonT) -> ISerializerT: ...
class ObjectSerializerBase(IJSONSerializer[ISerializerT, Dict[str, Any]]):
@@ -171,6 +171,6 @@ def dumper(path: str, obj: Any) -> None:
ObjectSerializerBase.dump(obj, output)
-def loader(path: str) -> Any:
- with open(path, encoding="utf-8") as obj:
+def loader(path: str) -> Any | None:
+ with open(path, "rb") as obj:
return ObjectSerializerBase.load(obj)
diff --git a/moduletester/test_exporter.py b/moduletester/test_exporter.py
new file mode 100644
index 0000000..0cb8961
--- /dev/null
+++ b/moduletester/test_exporter.py
@@ -0,0 +1,70 @@
+"""Test results and test list document generation."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from jinja2 import FileSystemLoader
+
+from moduletester import config
+from moduletester.new_exporter import JINJA_ENV, DocumentExporter
+
+from .model import Test, TestSuite
+
+
+@dataclass
+class _TestExporter(DocumentExporter):
+ test_suite: Optional[TestSuite] = None
+ _image_dirs: list[str] = field(init=False, default_factory=list)
+
+ def __post_init__(self):
+ global JINJA_ENV
+ conf = config.PACKAGE_CONF["export"]
+ # if self.test_suite is not None:
+ # if (
+ # templ := getattr(
+ # conf, f"{self.__class__.__name__.lower()}_template_name", None
+ # )
+ # ) is not None and self.template_name != templ:
+ # new_template_loader = FileSystemLoader(searchpath=conf.template_dir)
+ # JINJA_ENV.loader = new_template_loader
+ # self.template_name = templ
+ # self._template = JINJA_ENV.get_template(self.template_name)
+ # else:
+ # raise ValueError(
+ # "Configuration is missing key "
+ # f"{self.__class__.__name__.lower()}_template_name.\n"
+ # "Update file config.py and moduletester.ini to add the key."
+ # )
+
+ self._docx_reference = conf.get_docx_ref()
+ self._odt_reference = conf.get_odt_ref()
+ self._css_style = conf.get_css_style()
+ self.docstrings_header_shift = conf.docstrings_header_shift
+ self.toc_depth = conf.toc_depth
+ self.resource_path = self.test_suite.package.path
+
+ # Updating the template directory in the jinja environment
+ new_template_loader = FileSystemLoader(searchpath=conf.template_dir)
+ JINJA_ENV.loader = new_template_loader
+
+ def get_images_paths(self, test: Test) -> list[str]:
+ """Return the image paths for a given test."""
+ if self.test_suite is None:
+ return []
+ return test.get_images(self._image_dirs)
+
+
+@dataclass
+class TestResultsDocument(_TestExporter):
+ """Exporter for test results documents."""
+
+ template_name: str = "test_results_template.j2"
+
+
+@dataclass
+class TestListDocument(_TestExporter):
+ """Exporter for test list documents."""
+
+ template_name: str = "test_list_template.j2"
diff --git a/moduletester/tests/__init__.py b/moduletester/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/moduletester/tests/conftest.py b/moduletester/tests/conftest.py
new file mode 100644
index 0000000..662ba24
--- /dev/null
+++ b/moduletester/tests/conftest.py
@@ -0,0 +1,2 @@
+# pylint: disable=missing-module-docstring
+from __future__ import annotations
diff --git a/moduletester/tests/test_model.py b/moduletester/tests/test_model.py
new file mode 100644
index 0000000..fb9bf45
--- /dev/null
+++ b/moduletester/tests/test_model.py
@@ -0,0 +1,80 @@
+"""Tests for the model module (enums, dataclasses)."""
+
+# pylint: disable=missing-class-docstring,missing-function-docstring
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from moduletester.model import (
+ ModuleNotFoundType,
+ ResultEnum,
+ StatusEnum,
+ TestResult,
+)
+from moduletester.serializer import DataclassSerializer, EnumSerializer
+
+
+class TestStatusEnum:
+ def test_values(self):
+ assert StatusEnum.EXECUTED.value == "executed"
+ assert StatusEnum.NOT_EXECUTED.value == "not executed"
+ assert StatusEnum.ABORTED.value == "aborted"
+
+ def test_serializer_roundtrip(self):
+ s = EnumSerializer(StatusEnum)
+ for member in StatusEnum:
+ assert s.deserialize(s.serialize(member)) == member
+
+
+class TestResultEnum:
+ def test_values(self):
+ assert ResultEnum.ACCEPTED.value == "accepted"
+ assert ResultEnum.NO_RESULT.value == "no result"
+
+ def test_format(self):
+ assert ResultEnum.ACCEPTED_WITH_RESERVES.format() == "ACCEPTED WITH RESERVES"
+
+ def test_serializer_roundtrip(self):
+ s = EnumSerializer(ResultEnum)
+ for member in ResultEnum:
+ assert s.deserialize(s.serialize(member)) == member
+
+
+class TestTestResult:
+ def test_creation_defaults(self):
+ tr = TestResult(status=StatusEnum.NOT_EXECUTED)
+ assert tr.result == ResultEnum.NO_RESULT
+ assert tr.comment == ""
+ assert tr.output_msg == ""
+ assert tr.error_msg == ""
+ assert tr.error_code is None
+
+ def test_serialization_roundtrip(self):
+ tr = TestResult(
+ status=StatusEnum.EXECUTED,
+ result=ResultEnum.ACCEPTED,
+ execution_duration=timedelta(seconds=1.5),
+ last_run=datetime(2026, 5, 20, 10, 0, 0, 0),
+ comment="OK",
+ )
+ s = DataclassSerializer()
+ data = s.serialize(tr)
+ restored = s.deserialize(data)
+ assert isinstance(restored, TestResult)
+ assert restored.status == StatusEnum.EXECUTED
+ assert restored.result == ResultEnum.ACCEPTED
+ assert restored.comment == "OK"
+
+ def test_properties(self):
+ tr = TestResult(
+ status=StatusEnum.EXECUTED, result=ResultEnum.ACCEPTED_WITH_RESERVES
+ )
+ assert tr.result_name == "ACCEPTED WITH RESERVES"
+ assert tr.status_name == "EXECUTED"
+
+
+class TestModuleNotFoundType:
+ def test_creation(self):
+ m = ModuleNotFoundType("some.missing.module")
+ assert m.__name__ == "some.missing.module"
+ assert m.__doc__ is not None
diff --git a/moduletester/tests/test_serializer.py b/moduletester/tests/test_serializer.py
new file mode 100644
index 0000000..5e9b00c
--- /dev/null
+++ b/moduletester/tests/test_serializer.py
@@ -0,0 +1,35 @@
+"""Tests for the serializer module."""
+
+# pylint: disable=missing-class-docstring,missing-function-docstring
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from moduletester.serializer import (
+ DateTimeSerializer,
+ TimedeltaSerializer,
+)
+
+
+class TestDateTimeSerializer:
+ def test_roundtrip(self):
+ dt = datetime(2026, 5, 20, 14, 30, 45, 123456)
+ s = DateTimeSerializer()
+ assert s.deserialize(s.serialize(dt)) == dt
+
+ def test_format(self):
+ dt = datetime(2026, 1, 2, 3, 4, 5, 678900)
+ s = DateTimeSerializer()
+ assert s.serialize(dt) == "02/01/26 03:04:05.678900"
+
+
+class TestTimedeltaSerializer:
+ def test_roundtrip(self):
+ td = timedelta(hours=1, minutes=30, seconds=15)
+ s = TimedeltaSerializer()
+ assert s.deserialize(s.serialize(td)) == td
+
+ def test_serialize_seconds(self):
+ td = timedelta(seconds=90.5)
+ s = TimedeltaSerializer()
+ assert s.serialize(td) == 90.5
diff --git a/pyproject.toml b/pyproject.toml
index cff57e6..2b13163 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ description = "ModuleTester is a test management software for Python packages"
readme = "README.md"
license = { file = "LICENSE" }
classifiers = [
+ "Development Status :: 5 - Production/Stable",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Utilities",
"Topic :: Software Development :: User Interfaces",
@@ -19,16 +20,23 @@ classifiers = [
"Operating System :: OS Independent",
"Operating System :: POSIX",
"Operating System :: Unix",
- "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+]
+requires-python = ">=3.9, <4"
+dependencies = [
+ "guidata >= 3.14",
+ "QtPy >= 1.9",
+ "pyqtwebengine",
+ "pypandoc",
+ "jinja2",
+ "beautifulsoup4",
]
-requires-python = ">=3.8, <4"
-dependencies = ["guidata >= 3.1", "QtPy >= 1.9", "beautifulsoup4", "click"]
dynamic = ["version"]
-
[project.urls]
Homepage = "https://github.com/Codra-Ingenierie-Informatique/ModuleTester/"
Documentation = "https://moduletester.readthedocs.io/en/latest/"
@@ -37,17 +45,56 @@ Documentation = "https://moduletester.readthedocs.io/en/latest/"
moduletester-cli = "moduletester.manager:cli"
[project.gui-scripts]
-moduletester = "moduletester.gui:run"
+moduletester = "moduletester.gui.main:run_gui"
[project.optional-dependencies]
-dev = ["black", "isort", "pylint", "Coverage"]
+dev = ["ruff", "pylint", "pytest", "Coverage", "build"]
doc = ["PyQt5", "sphinx>6", "pydata_sphinx_theme"]
[tool.setuptools.packages.find]
include = ["moduletester*"]
[tool.setuptools.package-data]
-"*" = ["*.svg", "*.mo", "*.txt", "*.json", "*.png"]
+"*" = [
+ "*.svg",
+ "*.mo",
+ "*.txt",
+ "*.json",
+ "*.png",
+ "*.j2",
+ "*.docx",
+ "*.odt",
+ "*.css",
+]
[tool.setuptools.dynamic]
version = { attr = "moduletester.__version__" }
+
+[tool.pytest.ini_options]
+testpaths = ["moduletester/tests"]
+
+[tool.ruff]
+exclude = [".git", ".vscode", "build", "dist", "venv*", ".venv*"]
+line-length = 88
+indent-width = 4
+target-version = "py39"
+
+[tool.ruff.lint]
+select = [
+ "E", # Pycodestyle error
+ "F", # Pyflakes
+ "I", # Isort
+ "W", # Pycodestyle warning
+]
+ignore = [
+ "E203", # space before : (needed for how black formats slicing)
+]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+skip-magic-trailing-comma = false
+line-ending = "auto"
+
+[tool.ruff.lint.per-file-ignores]
+"doc/*" = ["E402"]
diff --git a/requirements.txt b/requirements.txt
index 462a1a7..54bfee5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,4 +8,6 @@ pyinstaller
sphinx
pydata_sphinx_theme
build
-twine
\ No newline at end of file
+twine
+pypandoc
+jinja2
\ No newline at end of file
diff --git a/scripts/run_black_isort.bat b/scripts/run_black_isort.bat
deleted file mode 100644
index 9193cd1..0000000
--- a/scripts/run_black_isort.bat
+++ /dev/null
@@ -1,18 +0,0 @@
-@echo off
-REM This script was derived from PythonQwt project
-REM ======================================================
-REM Run black and isort code analysis tool
-REM ======================================================
-REM Licensed under the terms of the MIT License
-REM Copyright (c) 2020 Pierre Raybaut
-REM (see PythonQwt LICENSE file for more details)
-REM ======================================================
-setlocal
-call %~dp0utils GetScriptPath SCRIPTPATH
-call %FUNC% GetLibName LIBNAME
-call %FUNC% SetPythonPath
-set PYTHON=%CDL_PYTHONEXE%
-call %FUNC% UsePython
-python -m black .
-python -m isort --profile black .
-call %FUNC% EndOfScript
\ No newline at end of file
diff --git a/scripts/run_coverage.bat b/scripts/run_coverage.bat
index c3a577b..f70ca30 100644
--- a/scripts/run_coverage.bat
+++ b/scripts/run_coverage.bat
@@ -17,7 +17,7 @@ if exist sitecustomize.py ( del /q sitecustomize.py )
echo import coverage> sitecustomize.py
echo coverage.process_startup()>> sitecustomize.py
set COVERAGE_PROCESS_START=%SCRIPTPATH%\..\.coveragerc
-coverage run -m cdl.tests.all_tests %* --timeout 600
+coverage run -m pytest %MODNAME% %*
@REM coverage report -m
coverage combine
coverage html
diff --git a/scripts/run_test_launcher.bat b/scripts/run_test_launcher.bat
index 69256fe..b847f5d 100644
--- a/scripts/run_test_launcher.bat
+++ b/scripts/run_test_launcher.bat
@@ -12,5 +12,5 @@ call %~dp0utils GetScriptPath SCRIPTPATH
call %FUNC% SetPythonPath
call %FUNC% UsePython
call %FUNC% GetModName MODNAME
-python -m %MODNAME%.tests.__init__
+python -m pytest %MODNAME% %*
call %FUNC% EndOfScript
\ No newline at end of file
diff --git a/scripts/run_unittests.bat b/scripts/run_unittests.bat
index 0cfadfe..c85e285 100644
--- a/scripts/run_unittests.bat
+++ b/scripts/run_unittests.bat
@@ -12,5 +12,5 @@ call %~dp0utils GetScriptPath SCRIPTPATH
call %FUNC% GetModName MODNAME
call %FUNC% SetPythonPath
call %FUNC% UsePython
-python -m %MODNAME%.tests.all_tests
+python -m pytest %MODNAME%
call %FUNC% EndOfScript
\ No newline at end of file
diff --git a/scripts/run_with_env.py b/scripts/run_with_env.py
new file mode 100644
index 0000000..6688cb6
--- /dev/null
+++ b/scripts/run_with_env.py
@@ -0,0 +1,210 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Run a command with environment variables loaded from a .env file.
+
+This script automatically detects the best Python interpreter to use:
+
+1. ``PYTHON`` variable in ``.env`` file (e.g. for WinPython distributions)
+2. ``WINPYDIRBASE`` variable (legacy WinPython base directory)
+3. ``VENV_DIR`` variable (explicit virtual environment directory)
+4. A local virtual environment (``.venv*`` directory in the project root)
+5. Falls back to ``sys.executable`` (the Python that launched this script)
+
+This ensures that VS Code tasks always use the correct Python environment
+regardless of which interpreter is configured globally or in VS Code.
+"""
+
+from __future__ import annotations
+
+import glob
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+def _find_venv_python(project_root: Path) -> str | None:
+ """Find a Python executable in a ``.venv*`` directory.
+
+ Searches for directories matching ``.venv*`` in the project root and
+ returns the first valid Python executable found.
+
+ Args:
+ project_root: The root directory of the project.
+
+ Returns:
+ Absolute path to the venv Python executable, or None if not found.
+ """
+ # Sort to prefer ".venv" over ".venv-xyz" etc.
+ venv_dirs = sorted(glob.glob(str(project_root / ".venv*")))
+ for venv_dir in venv_dirs:
+ venv_path = Path(venv_dir)
+ if not venv_path.is_dir():
+ continue
+ result = _get_venv_python(venv_path)
+ if result:
+ return result
+ return None
+
+
+def _get_venv_python(venv_dir: Path) -> str | None:
+ """Get the Python executable from a specific venv directory.
+
+ Args:
+ venv_dir: Path to the virtual environment directory.
+
+ Returns:
+ Absolute path to the Python executable, or None if not found.
+ """
+ if not venv_dir.is_dir():
+ return None
+ # Windows: Scripts/python.exe โ Unix: bin/python
+ candidates = [
+ venv_dir / "Scripts" / "python.exe",
+ venv_dir / "bin" / "python",
+ ]
+ for candidate in candidates:
+ if candidate.is_file():
+ # Keep the venv-local executable path without resolving symlinks:
+ # on Linux/WSL, ``bin/python`` is often a symlink to a global
+ # interpreter (e.g. /usr/bin/python3.x). Resolving it would lose
+ # venv context and site-packages selection.
+ return str(candidate.absolute())
+ return None
+
+
+def resolve_python(project_root: Path) -> str:
+ """Resolve the best Python interpreter for the project.
+
+ Priority order:
+
+ 1. ``PYTHON`` environment variable (set in ``.env`` or externally)
+ 2. ``WINPYDIRBASE`` environment variable (legacy WinPython base directory)
+ 3. ``VENV_DIR`` environment variable (explicit venv directory)
+ 4. ``.venv*`` directory in *project_root* (auto-discovery)
+ 5. ``sys.executable`` (the interpreter running this script)
+
+ Args:
+ project_root: The root directory of the project.
+
+ Returns:
+ Absolute path to the Python executable to use.
+ """
+ # 1. Explicit PYTHON variable (e.g. WinPython distribution)
+ python_env = os.environ.get("PYTHON")
+ if python_env:
+ python_path = Path(python_env)
+ if python_path.is_file():
+ # Do not resolve symlinks for the same reason as in
+ # ``_get_venv_python``.
+ resolved = str(python_path.absolute())
+ print(f" ๐ Using PYTHON from .env: {resolved}")
+ return resolved
+ print(f" โ ๏ธ PYTHON variable set but not found: {python_env}")
+
+ # 2. Legacy WINPYDIRBASE variable (WinPython distribution)
+ winpy_base = os.environ.get("WINPYDIRBASE")
+ if winpy_base and Path(winpy_base).is_dir():
+ # Search for python.exe in the WinPython directory structure
+ # Patterns: python-3.11.5.amd64/python.exe (old) or python/python.exe (new)
+ for pattern in ("python-*/python.exe", "python/python.exe"):
+ for candidate in sorted(Path(winpy_base).glob(pattern)):
+ if candidate.is_file():
+ resolved = str(candidate.absolute())
+ print(f" ๐ Using WINPYDIRBASE (legacy): {resolved}")
+ return resolved
+ # Also try direct python.exe in the base directory
+ direct = Path(winpy_base) / "python.exe"
+ if direct.is_file():
+ resolved = str(direct.absolute())
+ print(f" ๐ Using WINPYDIRBASE (legacy): {resolved}")
+ return resolved
+ print(f" โ ๏ธ WINPYDIRBASE set but no Python found in: {winpy_base}")
+
+ # 3. Explicit VENV_DIR variable (e.g. for multiple local venvs)
+ venv_dir_env = os.environ.get("VENV_DIR")
+ if venv_dir_env:
+ venv_dir = Path(venv_dir_env)
+ if not venv_dir.is_absolute():
+ venv_dir = project_root / venv_dir
+ venv_python = _get_venv_python(venv_dir)
+ if venv_python:
+ print(f" ๐ Using VENV_DIR from .env: {venv_python}")
+ return venv_python
+ print(f" โ ๏ธ VENV_DIR set but no Python found in: {venv_dir}")
+
+ # 4. Auto-discover local venv
+ venv_python = _find_venv_python(project_root)
+ if venv_python:
+ print(f" ๐ Using venv Python: {venv_python}")
+ return venv_python
+
+ # 5. Fallback
+ print(f" ๐ Using caller Python: {sys.executable}")
+ return sys.executable
+
+
+def load_env_file(env_path: str | None = None) -> None:
+ """Load environment variables from a .env file."""
+ if env_path is None:
+ env_path = Path.cwd() / ".env"
+ if not Path(env_path).is_file():
+ raise FileNotFoundError(f"Environment file not found: {env_path}")
+ print(f"Loading environment variables from: {env_path}")
+ with open(env_path, encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ value = value.strip().strip('"').strip("'")
+ os.environ[key.strip()] = value
+ print(f" Loaded variable: {key.strip()}={value}")
+
+
+def execute_command(command: list[str], python_exe: str) -> int:
+ """Execute a command, replacing ``python`` placeholders.
+
+ Any argument that is the bare word ``python`` or that points to a Python
+ executable (checked via filename) is replaced by *python_exe* so that the
+ subprocess uses the resolved interpreter rather than the global one.
+
+ Args:
+ command: The command and its arguments.
+ python_exe: The resolved Python interpreter path.
+
+ Returns:
+ The subprocess exit code.
+ """
+ resolved: list[str] = []
+ for arg in command:
+ if arg.lower() == "python" or (
+ Path(arg).name.lower().startswith("python")
+ and Path(arg).is_file()
+ and arg.lower() != python_exe.lower()
+ ):
+ resolved.append(python_exe)
+ else:
+ resolved.append(arg)
+ print("Executing command:")
+ print(" ".join(resolved))
+ print("")
+ result = subprocess.call(resolved)
+ print(f"Process exited with code {result}")
+ return result
+
+
+def main() -> None:
+ """Main function to load environment variables and execute a command."""
+ if len(sys.argv) < 2:
+ print("Usage: python run_with_env.py [args ...]")
+ sys.exit(1)
+ print("๐ Running with environment variables")
+ project_root = Path.cwd()
+ load_env_file()
+ python_exe = resolve_python(project_root)
+ return execute_command(sys.argv[1:], python_exe)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test_results_exemple.html b/test_results_exemple.html
new file mode 100644
index 0000000..a849f2e
--- /dev/null
+++ b/test_results_exemple.html
@@ -0,0 +1,3920 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ RTV: exemple_module__test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unknown author
+
+
+
+ Last execution date: 12/02/24 13:39:52.833739
+
+ Description
+
+
+
+ No package description found.
+
+
+
+
+
+ Test sub-module: benchmarks
+
+
+
+ Test: bigimages
+
+
+
+ Description
+
+ Test showing 10 big images
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\benchmarks\test_bigimages.py"
+
+
+
+
+
+ Result
+
+
+
+
+
+ ACCEPTED, executed on 24/01/24 13:16:06.796710
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: loadtest
+
+
+
+ Description
+
+ Load test: instantiating a large number of image widgets
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\benchmarks\test_loadtest.py"
+
+
+
+
+
+ Result
+
+
+
+
+
+ REJECTED, executed on 24/01/24 11:35:33.697528
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test sub-module: features
+
+
+
+ Data:
+
+
+
+ Description
+
+ pyplot test
+
+ Interactive plotting interface with MATLAB-like syntax
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\data.py"
+
+
+
+
+
+ Result
+
+
+
+
+
+ SKIPPED, executed on 24/01/24 14:05:07.607218
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Data: 1
+
+
+
+ Description
+
+ pyplot test
+
+ Interactive plotting interface with MATLAB-like syntax
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\data_1.py"
+
+
+
+
+
+ Result
+
+
+
+
+
+ NO RESULT, executed on 12/02/24 13:40:03.146832
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Data: 2
+
+
+
+ Description
+
+ Resize test: using the scaler C++ engine to resize images
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\data_2.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: autoscale shapes
+
+
+
+ Description
+
+ This example shows autoscaling of plot with various shapes.
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_autoscale_shapes.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: auto curve image
+
+
+
+ Description
+
+ Testing 'auto' plot type
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_auto_curve_image.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: builder
+
+
+
+ Description
+
+ Builder tests
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_builder.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: computations
+
+
+
+ Description
+
+ Plot computations test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_computations.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: contrast
+
+
+
+ Description
+
+ Contrast tool test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_contrast.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: cursors
+
+
+
+ Description
+
+ Horizontal/vertical cursors test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_cursors.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: fit
+
+
+
+ Description
+
+ Curve fitting tools
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_fit.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: imagefilter
+
+
+
+ Description
+
+ Image filter demo
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_imagefilter.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: imagesuperp
+
+
+
+ Description
+
+ Image superposition test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_imagesuperp.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: loadsaveitems hdf5
+
+
+
+ Description
+
+ Load/save items from/to HDF5 file
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_loadsaveitems_hdf5.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: loadsaveitems json
+
+
+
+ Description
+
+ Unit test for plot items <--> JSON
+
+ serialization/deserialization
+
+ How to save/restore items to/from a JSON string?
+
+
+
+ # Plot items --> JSON: writer = JSONWriter(None)
+
+ save_items(writer, items) text = writer.get_json()
+
+ # JSON --> Plot items: items = load_items(JSONReader(text))
+
+
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_loadsaveitems_json.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: loadsaveitems pickle
+
+
+
+ Description
+
+ Load/save items using Python's pickle protocol
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_loadsaveitems_pickle.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: manager
+
+
+
+ Description
+
+ PlotManager test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_manager.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: plot log
+
+
+
+ Description
+
+ Logarithmic scale test for curve plotting
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_plot_log.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: plot types
+
+
+
+ Description
+
+ PlotTypes test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_plot_types.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: plot yreverse
+
+
+
+ Description
+
+ Reverse y-axis test for curve plotting
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_plot_yreverse.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: pyplot
+
+
+
+ Description
+
+ pyplot test
+
+ Interactive plotting interface with MATLAB-like syntax
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_pyplot.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: resize
+
+
+
+ Description
+
+ Resize test: using the scaler C++ engine to resize images
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\features\test_resize.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test sub-module: items
+
+
+
+ Test: annotations
+
+
+
+ Description
+
+ Annotation test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_annotations.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: curves
+
+
+
+ Description
+
+ Curve plotting test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_curves.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: hist2d
+
+
+
+ Description
+
+ 2-D Histogram test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_hist2d.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: histogram
+
+
+
+ Description
+
+ Histogram test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_histogram.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: image
+
+
+
+ Description
+
+ Test showing an image
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_image.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: image contour
+
+
+
+ Description
+
+ Contour test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_image_contour.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: image masked
+
+
+
+ Description
+
+ Masked Image test, creating the MaskedImageItem object via
+
+ make.maskedimage
+
+ Masked image items are constructed using a masked array item. Masked
+
+ data is ignored in computations, like the average cross sections.
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_image_masked.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: image masked xy
+
+
+
+ Description
+
+ Masked Image test, creating the MaskedXYImageItem object via
+
+ make.maskedxyimage
+
+ Masked image XY items are constructed using a masked array item.
+
+ Masked data is ignored in computations, like the average cross
+
+ sections.
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_image_masked_xy.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: image rgb
+
+
+
+ Description
+
+ RGB Image test, creating the RGBImageItem object via
+
+ make.rgbimage
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_image_rgb.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: image xy
+
+
+
+ Description
+
+ Image with custom X/Y axes linear scales
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_image_xy.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: mandelbrot
+
+
+
+ Description
+
+ Mandelbrot demo
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_mandelbrot.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: pcolor
+
+
+
+ Description
+
+ Test showing a pcolor plot
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_pcolor.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: polygons
+
+
+
+ Description
+
+ PolygonMapItem test
+
+ PolygonMapItem is intended to display maps ie items containing
+
+ several hundreds of independent polygons.
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_polygons.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: svgshapes
+
+
+
+ Description
+
+ Test showing SVG shapes
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\items\test_svgshapes.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test sub-module: tools
+
+
+
+
+
+
+
+
+
+ Test: cross section
+
+
+
+ Description
+
+ Renders a cross section chosen by a cross marker
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\tools\test_cross_section.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: cross section oblique
+
+
+
+ Description
+
+ Oblique averaged cross section test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\tools\test_cross_section_oblique.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: get point
+
+
+
+ Description
+
+ SelectPointTool test
+
+ This exemple_module_ tool provide a MATLAB-like "ginput" feature.
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\tools\test_get_point.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: get rectangle
+
+
+
+ Description
+
+ Get rectangular selection from image
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\tools\test_get_rectangle.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: get rectangle with svg
+
+
+
+ Description
+
+ Get rectangular selection from image with SVG shape
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\tools\test_get_rectangle_with_svg.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: get segment
+
+
+
+ Description
+
+ Test get_segment feature: select a segment on an
+
+ image.
+
+ This exemple_module_ tool provide a MATLAB-like "ginput" feature.
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\tools\test_get_segment.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test sub-module: unit
+
+
+
+ Test: baseplot
+
+
+
+ Description
+
+ Testing BasePlot API
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\unit\test_baseplot.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test sub-module: widgets
+
+
+
+ Test: dotarraydemo
+
+
+
+ Description
+
+ Dot array example
+
+ Example showing how to create a custom item (drawing dots of variable
+
+ size) and integrate the associated guidata dataset (GUI-based form) to edit its
+
+ parameters (directly into the same window as the plot itself,
+
+ and within the custom item parameters: right-click on the
+
+ selectable item to open the associated dialog box).
+
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_dotarraydemo.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: filtertest1
+
+
+
+ Description
+
+ Simple filter testing application based on PyQt and exemple_module_
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_filtertest1.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: filtertest2
+
+
+
+ Description
+
+ Simple filter testing application based on PyQt and exemple_module_
+
+ filtertest1.py + plot manager
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_filtertest2.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: fliprotate
+
+
+
+ Description
+
+ Flip/rotate test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_fliprotate.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: plot timecurve
+
+
+
+ Description
+
+ Dynamic curve widget test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_plot_timecurve.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: qtdesigner
+
+
+
+ Description
+
+ Testing exemple_module_ QtDesigner plugins
+
+ These plugins provide PlotWidget objects embedding in GUI layouts
+
+ directly from QtDesigner.
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_qtdesigner.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: resize dialog
+
+
+
+ Description
+
+ ResizeDialog test
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_resize_dialog.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: rotatecrop
+
+
+
+ Description
+
+ Rotate/crop test: using the scaler C++ engine to rotate/crop
+
+ images
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_rotatecrop.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: simple dialog
+
+
+
+ Description
+
+ Simple dialog box based on exemple_module_
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_simple_dialog.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Test: simple window
+
+
+
+ Description
+
+ Simple application based on exemple_module_
+
+
+
+
+
+ Command
+
+ "-path-to-python-env-\ModuleTester\.venv\Scripts\python.exe" -u -X utf8 "-path-to-python-env-\ModuleTester\.venv\lib\site-packages\exemple_module__test\tests\widgets\test_simple_window.py"
+
+
+
+
+
+ Result
+
+ No result found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file