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) %} + Test Image + {% endfor %} +
+
+
+
+ {% endfor %} +
+ {% endfor %} + +
+
+

Summary

+

{{_("%s test results summary") % doc_obj.test_suite.package.last_name.capitalize() }}

+ + + + + + + + + + + {% for test in doc_obj.test_suite.tests %} + + {% set result_bin = test.result_binary_label() %} + + + + + + + + + {% endfor %} +
Test{{ _("No result") }}{{ _("Accepted") }}{{ _("Accepted with reserve") }}{{ _("Skipped") }}{{ _("Rejected") }}Execution date
{{ 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 }}
+

{{_("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 + + + + + + + + + +
+ +

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: dicom image

+ +
+ +

Description

+ +

DICOM image test

+ +

Requires pydicom (>=0.9.3)

+ +
+ +
+ +

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_dicom_image.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: no auto tools

+ +
+ +

Description

+ +

Testing auto_tools plot option

+ +
+ +
+ +

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_no_auto_tools.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: transform

+ +
+ +

Description

+ +

Tests around image transforms: rotation, translation, ...

+ +
+ +
+ +

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_transform.py" + +
+ +
+ +

Result

+ +

No result found.

+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ +
+ +

Test sub-module: tools

+ +
+ +

Test: actiontool

+ +
+ +

Description

+ +

ActionTool 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_actiontool.py" + +
+ +
+ +

Result

+ +

No result found.

+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ +

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: customize shape tool

+ +
+ +

Description

+ +

Shows how to customize a shape created with a tool like + + RectangleTool

+ +
+ +
+ +

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_customize_shape_tool.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: image plot tools

+ +
+ +

Description

+ +

All image and plot tools 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_image_plot_tools.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: rst format

+ +
+ +

Description

+ +

Subtitle

+ +
+ +

Overview

+ +
+ +
Section 1
+ +

Text can be italicized or bolded as well as + + monospaced. You can /escape certain/ special + + characters. +

+ +
Subsection 1 (Level 2)
+ +

Some section 2 text

+ +

Sub-subsection 1 (level 3)

+ +

Some more text.

+ +
Examples
+ +
Comments
+ +
Images
+ +

Add an image with:

+ +

alternate text

+ +

You can inline an image or other directive with the (missing image text) command.

+ +
Lists
+ +
    + +
  • Bullet are made like this
  • + +
  • +
    + +
    Point levels must be consistent
    + +
    + +
      + +
    • +
      + +
      Sub-bullets
      + +
      + +
        + +
      • Sub-sub-bullets
      • + +
      + +
      + +
      +
    • + +
    + +
    + +
    +
  • + +
  • Lists
  • + +
+ +
+ +
Term
+ +
+ +

Definition for term

+ +
+ +
Term2
+ +
+ +

Definition for term 2

+ +
+ +
:List of Things:
+ +
+ +

item1 - these are 'field lists' not bulleted lists item2 item 3

+ +
+ +
+ +
+ +
Something
+ +
+ +

single item

+ +
+ +
Someitem
+ +
+ +

single item

+ +
+ +
+ +
Preformatted text
+ +

A code example prefix must always end with double colon like it's + + presenting something:

+ +
Anything indented is part of the preformatted block
+
+Until
+
+It gets back to
+
+Allll the way left
+ +

Now we're out of the preformatted block.

+ +
Code blocks
+ +

There are three equivalents: code, + + sourcecode, and code-block. +

+ +
+ +
+
import os
+
+print(help(os))
+
+ +
+ +
# Equivalent
+ +
# Equivalent
+ + + +

Web addresses by themselves will auto link, like this: https://www.devdungeon.com

+ +

You can also inline custom links: Google search engine

+ +

This is a simple link to Google + + with the link defined separately.

+ +

This is a link to the Python + + website.

+ +

This is a link back to Section 1. You can + + link based off of the heading name within a document.

+ +
Footnotes
+ +

Footnote Reference1

+ +

Or autonumbered [#]

+ +
Lines/Transitions
+ +

Any 4+ repeated characters with blank lines surrounding it becomes an + + hr line, like this.

+ +
+ +
Tables
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeNumberValue
12:00422
23:00234
+ +
Preserving line breaks
+ +

Normally you can break the line in the middle of a paragraph and it + + will ignore the newline. If you want to preserve the newlines, use the + + | prefix on the lines. For example: +

+ +
These lines will
+ + break exactly
+ + where we told them to.
+ +
+ +
+ +
    + +
  1. +
    + +

    This is footnote number one that would go at the bottom of the + + document.โ†ฉ๏ธŽ

    + +
    +
  2. + +
+ +
+ +
+ +
+ +

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_rst_format.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