diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ad7de7167..286e82340a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,17 +180,13 @@ jobs: ### Build documentation (if enabled) - - name: Install doc-building dependencies - if: ${{ matrix.docs }} - run: | - python -m pip install sphinx - - - name: Build sasmodels and bumps docs + - name: Build sasmodels, sasdata, and bumps docs if: ${{ matrix.docs }} run: | make -C ../bumps/doc html || true mkdir -p ~/.sasmodels/compiled_models make -j4 -C ../sasmodels/doc html || true + make -C ../sasdata/docs html || true - name: Build sasview docs if: ${{ matrix.docs }} @@ -226,25 +222,11 @@ jobs: iscc installers/installer.iss mv installers/Output/setupSasView.exe installers/dist - - name: Sign executable and create dmg (OSX) + - name: Build sasview installer dmg file (OSX) if: ${{ matrix.installer && startsWith(matrix.os, 'macos') }} - env: - MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} run: | - echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12 - security create-keychain -p DloaAcYP build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p DloaAcYP build.keychain - security import certificate.p12 -k build.keychain -P $MACOS_CERTIFICATE_PWD -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k DloaAcYP build.keychain - cd installers/dist - python ../../build_tools/fix_qt_folder_names_for_codesign.py SasView6.app - python ../../build_tools/code_sign_osx.py - codesign --verify --options=runtime --entitlements ../../build_tools/entitlements.plist --timestamp --deep --verbose=4 --force --sign "Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)" SasView6.app hdiutil create SasView6.dmg -srcfolder SasView6.app -ov -format UDZO - codesign -s "Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)" SasView6.dmg - name: Build sasview installer tarball (Linux) if: ${{ matrix.installer && startsWith(matrix.os, 'ubuntu') }} @@ -261,6 +243,26 @@ jobs: installers/dist/sasview-pyinstaller-dist.tar.gz if-no-files-found: ignore + - name: Sign executable and create dmg (OSX) + if: ${{ matrix.installer && startsWith(matrix.os, 'macos') }} + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + run: | + echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12 + security create-keychain -p DloaAcYP build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p DloaAcYP build.keychain + security import certificate.p12 -k build.keychain -P $MACOS_CERTIFICATE_PWD -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k DloaAcYP build.keychain + + cd installers/dist + python ../../build_tools/fix_qt_folder_names_for_codesign.py SasView6.app + python ../../build_tools/code_sign_osx.py + codesign --verify --options=runtime --entitlements ../../build_tools/entitlements.plist --timestamp --deep --verbose=4 --force --sign "Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)" SasView6.app + hdiutil create SasView6.dmg -srcfolder SasView6.app -ov -format UDZO + codesign -s "Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)" SasView6.dmg + - name: Publish installer package if: ${{ matrix.installer }} uses: actions/upload-artifact@v3 @@ -272,7 +274,6 @@ jobs: installers/dist/sasview6.tar.gz if-no-files-found: error - test-installer: needs: [ build-matrix ] diff --git a/INSTALL.txt b/INSTALL.txt index 5a825d6171..4fbe8e541c 100644 --- a/INSTALL.txt +++ b/INSTALL.txt @@ -1,7 +1,8 @@ Quick Intro for Building Sasview ================================ -Note - at the current time sasview will only run in gui form under Python 3. +Note - at the current time sasview will only run in gui form under Python 3.11 +and later. Before trying to install and run sasview you'll need to check what dependencies are required: diff --git a/build_tools/code_sign_osx.py b/build_tools/code_sign_osx.py index ed92de5759..7f7d4469ef 100644 --- a/build_tools/code_sign_osx.py +++ b/build_tools/code_sign_osx.py @@ -15,13 +15,44 @@ "SasView*.app/Contents/Resources/zmq/.dylibs/*.dylib", recursive=True ) +pyside_QtWebEngineProcessApp = glob.glob( + "SasView*.app/Contents/Resources/PySide6/Qt/lib/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app", recursive=True +) + +pyside_QtWebEngineCore = glob.glob( + "SasView*.app/Contents/Resources/PySide6/Qt/lib/QtWebEngineCore.framework/Versions/A/QtWebEngineCore", recursive=True +) + +pyside_QtWebEngineProcess_Helpers = glob.glob( + "SasView*.app/Contents/Resources/PySide6/Qt/lib/QtWebEngineCore.framework/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess", recursive=True +) + +pyside_Qtlibs = glob.glob( + "SasView*.app/Contents/Resources/PySide6/Qt/lib/Qt*.framework/Versions/A/Qt*", recursive=True +) + +#pyside_libs = pyside_QtWebEngineCore + pyside_QtWebEngineProcess + sign_command = ['codesign', '--timestamp', '--options=runtime', '--verify', '--verbose=4', '--force', - '--sign', 'Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)'] + '--sign', 'Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)'] +sign_deep_command = ['codesign', '--timestamp', '--deep', '--options=runtime', '--verify', '--verbose=4', '--force', + '--sign', 'Developer ID Application: European Spallation Source Eric (W2AG9MPZ43)'] -#TODO: Check if it is necesarry to do it per file (one long list maybe enough) -for sfile in itertools.chain(so_list, dylib_list, dylib_list_resources, - zmq_dylib_list_resources): +#Signing QtWebEngineProcess.app first as it is a helper app +for sfile in itertools.chain(pyside_QtWebEngineProcessApp): + sign_deep_command.append(sfile) + subprocess.check_call(sign_deep_command) + sign_deep_command.pop() + +for sfile in itertools.chain(so_list, dylib_list, + dylib_list_resources, + zmq_dylib_list_resources, + pyside_QtWebEngineCore, + pyside_QtWebEngineProcess_Helpers, + pyside_Qtlibs): sign_command.append(sfile) subprocess.check_call(sign_command) sign_command.pop() + + diff --git a/build_tools/release_notes/6.0.0_notes.txt b/build_tools/release_notes/6.0.0_notes.txt new file mode 100644 index 0000000000..b84a4edaf9 --- /dev/null +++ b/build_tools/release_notes/6.0.0_notes.txt @@ -0,0 +1,101 @@ +## New features +- Orientation viewer +- Corfunc refactored +- Simultaneous fitting allows for weighting scheme +- Preferences panel with display and plotting options (polydispersity and residuals plots can be hidden). +- Improved label handling on plots +- Residuals plots refactored +- PDB reader refactored +- Wedge slicer added +- Sasdata package separated +- Move to PySide6 +- Python 3.11 support +- Required documentation (https://github.com/SasView/sasview/issues/2641) +- Improved documentation +- New Tutorials + +## Major bug fixes: +- Handling of constraints for polydisperse parameters +- Binning and FitPage plotting of SESANS data +- Fixed 1D slit-smearing function +- Start-up speed improved +- Magnetic SLD? +- Multiplicity model? + +## New models +- New broad peak model + +## Anticipated for beta version +- PDB-based model saved to custom model (for S(q) calculations)? +- Batch Processing and 2D data slicing and processing for P(r) +- Local documentation generator +- Log explorer +- Send To button with replacement options + + +## What's Changed (This section has to be improved) +* Fix wrong number of parameters on restore in slicer module by @butlerpd in https://github.com/SasView/sasview/pull/2462 +* Update README.md by @lucas-wilkins in https://github.com/SasView/sasview/pull/2466 +* More changes to corfunc by @lucas-wilkins in https://github.com/SasView/sasview/pull/2463 +* Modify the way perspectives are closed by @rozyczko in https://github.com/SasView/sasview/pull/2469 +* Testing nightly build by @wpotrzebowski in https://github.com/SasView/sasview/pull/2465 +* Add argument to convertUI that forces full UI rebuild by @krzywon in https://github.com/SasView/sasview/pull/2483 +* Pyside6 merge by @rozyczko in https://github.com/SasView/sasview/pull/2478 +* Remove UI conversion from run.py by @krzywon in https://github.com/SasView/sasview/pull/2511 +* Reinstate math import to Plotter.py by @krzywon in https://github.com/SasView/sasview/pull/2517 +* 2111 name changes in corfunc by @lucas-wilkins in https://github.com/SasView/sasview/pull/2485 +* Import pytest in density calculator GUI tests by @krzywon in https://github.com/SasView/sasview/pull/2523 +* 2389: Validate text/int/float inputs within the preferences panel by @krzywon in https://github.com/SasView/sasview/pull/2476 +* C&S fitting widget fixes for PySide6 by @rozyczko in https://github.com/SasView/sasview/pull/2528 +* Reparent QAction from QtWidget to QtGui (PySide6) by @rozyczko in https://github.com/SasView/sasview/pull/2532 +* Fix for save dataset error #2533 by @rozyczko in https://github.com/SasView/sasview/pull/2534 +* Rog and beta q by @smalex-z in https://github.com/SasView/sasview/pull/2535 +* Lowercase PySide6 executables for Linux compatability #2542 by @ehewins in https://github.com/SasView/sasview/pull/2543 +* 2541 nightly build artifact doesnt start on mac by @wpotrzebowski in https://github.com/SasView/sasview/pull/2544 +* Polydisperse parameter check on model load by @rozyczko in https://github.com/SasView/sasview/pull/2553 +* Log explorer fix by @smalex-z in https://github.com/SasView/sasview/pull/2545 +* Syntax highlighting in Pyside6 by @rozyczko in https://github.com/SasView/sasview/pull/2562 +* Avoid parenting mess by calling the widget directly by @rozyczko in https://github.com/SasView/sasview/pull/2559 +* Added model reload signal on data swap by @rozyczko in https://github.com/SasView/sasview/pull/2567 +* Two options to disable residuals and polydispersity distribution plots by @lozanodorian in https://github.com/SasView/sasview/pull/2558 +* 2550 wedge slicer by @ehewins in https://github.com/SasView/sasview/pull/2566 +* Post-v5.0.6 Release Update by @krzywon in https://github.com/SasView/sasview/pull/2536 +* Use a regex for version validity check rather than integer coercion by @krzywon in https://github.com/SasView/sasview/pull/2572 +* Wedge slicer minor upgrages by @ehewins in https://github.com/SasView/sasview/pull/2570 +* Strip debug messages from production version of Qt console by @pkienzle in https://github.com/SasView/sasview/pull/2557 +* unit conversion for gui is missing by @rozyczko in https://github.com/SasView/sasview/pull/2568 +* Adjusts scale and angular range in 1D plots from WedgeSlicer by @butlerpd in https://github.com/SasView/sasview/pull/2580 +* Bump scipy from 1.7.3 to 1.10.0 in /build_tools by @dependabot in https://github.com/SasView/sasview/pull/2547 +* Plot2D instances are now of `Plotter2DWidget` type. Fixes #2586 by @rozyczko in https://github.com/SasView/sasview/pull/2587 +* fix dialog sizes for some calculators. #2437 by @rozyczko in https://github.com/SasView/sasview/pull/2581 +* Fix for getting directory name by @rozyczko in https://github.com/SasView/sasview/pull/2596 +* Remove Unused Dependency: h5py by @gdrosos in https://github.com/SasView/sasview/pull/2585 +* 2577 orientation viewer doesnt work from nigthly build on mac by @lucas-wilkins in https://github.com/SasView/sasview/pull/2600 +* Fixed unmatched method signatures in Box&Wedge Interactor child classes by @ehewins in https://github.com/SasView/sasview/pull/2589 +* Particle editor by @lucas-wilkins in https://github.com/SasView/sasview/pull/2520 +* Pass the Data1D/2D object, not its `data` attribute by @rozyczko in https://github.com/SasView/sasview/pull/2592 +* Bump reportlab from 3.6.6 to 3.6.13 in /build_tools by @dependabot in https://github.com/SasView/sasview/pull/2597 +* Added 3.11, removed 3.8 by @rozyczko in https://github.com/SasView/sasview/pull/2582 +* Fix doc build errors by @smk78 in https://github.com/SasView/sasview/pull/2607 +* Doc toctree fixes by @smk78 in https://github.com/SasView/sasview/pull/2609 +* 2603: Numeric coercion in preferences by @krzywon in https://github.com/SasView/sasview/pull/2605 +* What's new dialog by @lucas-wilkins in https://github.com/SasView/sasview/pull/2608 +* created submenu for slicers being part of #2604 by @astellhorn in https://github.com/SasView/sasview/pull/2610 +* Squish squashed by @lucas-wilkins in https://github.com/SasView/sasview/pull/2616 +* Update sas_gen.py by @timsnow in https://github.com/SasView/sasview/pull/2617 +* Killed Zombie Python Test by @lucas-wilkins in https://github.com/SasView/sasview/pull/2627 +* Remove model.png by @lucas-wilkins in https://github.com/SasView/sasview/pull/2629 +* Populate whats new with last version by @smk78 in https://github.com/SasView/sasview/pull/2625 +* Fix errors while running convertUI by @krzywon in https://github.com/SasView/sasview/pull/2623 +* 2618: Fix GPU and Optimizer Preferences by @krzywon in https://github.com/SasView/sasview/pull/2622 +* Moving OSX signing to nightly by @wpotrzebowski in https://github.com/SasView/sasview/pull/2631 +* Empty lines in data explorer by @rozyczko in https://github.com/SasView/sasview/pull/2643 + +## New Contributors +* @smalex-z made their first contribution in https://github.com/SasView/sasview/pull/2535 +* @ehewins made their first contribution in https://github.com/SasView/sasview/pull/2543 +* @lozanodorian made their first contribution in https://github.com/SasView/sasview/pull/2558 +* @gdrosos made their first contribution in https://github.com/SasView/sasview/pull/2585 +* @astellhorn made their first contribution in https://github.com/SasView/sasview/pull/2610 + +**Full Changelog**: https://github.com/SasView/sasview/compare/nightly-build...v6.0.0-alpha diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt index 98c18fe0b8..cf0d957232 100644 --- a/build_tools/requirements.txt +++ b/build_tools/requirements.txt @@ -33,3 +33,4 @@ zope superqt pyopengl pyopengl_accelerate +sphinx diff --git a/docs/sphinx-docs/build_sphinx.py b/docs/sphinx-docs/build_sphinx.py index dd01712243..ce354840ba 100755 --- a/docs/sphinx-docs/build_sphinx.py +++ b/docs/sphinx-docs/build_sphinx.py @@ -19,8 +19,7 @@ from distutils.util import get_platform from distutils.spawn import find_executable -from shutil import copy -from os import listdir +from sas.system.user import get_user_dir platform = '.%s-%s'%(get_platform(),sys.version[:3]) @@ -39,6 +38,8 @@ SASVIEW_BUILD = joinpath(SASVIEW_ROOT, "build", "lib") SASVIEW_MEDIA_SOURCE = joinpath(SASVIEW_ROOT, "src", "sas") SASVIEW_DOC_TARGET = joinpath(SASVIEW_BUILD, "doc") +SASVIEW_DOC_SOURCE = joinpath(SASVIEW_DOC_TARGET, "source-temp") +SASVIEW_DOC_BUILD = joinpath(SASVIEW_DOC_TARGET, "build") SASVIEW_API_TARGET = joinpath(SPHINX_SOURCE, "dev", "sasview-api") # sasmodels paths @@ -57,6 +58,15 @@ "index.rst", "install.rst", "intro.rst", ] +# sasdata paths +SASDATA_ROOT = joinpath(SASVIEW_ROOT, "..", "sasdata") +SASDATA_DOCS = joinpath(SASDATA_ROOT, "docs") +SASDATA_BUILD = joinpath(SASDATA_ROOT, "build", "lib") +SASDATA_DEV_SOURCE = joinpath(SASDATA_DOCS, "source", "dev") +SASDATA_DEV_TARGET = joinpath(SPHINX_SOURCE, "dev", "sasdata-dev") +SASDATA_GUIDE_SOURCE = joinpath(SASDATA_DOCS, "source", "user") +SASDATA_GUIDE_TARGET = joinpath(SPHINX_SOURCE, "user", "data") + # bumps paths BUMPS_DOCS = joinpath(SASVIEW_ROOT, "..", "bumps", "doc") BUMPS_SOURCE = joinpath(BUMPS_DOCS, "guide") @@ -154,6 +164,15 @@ def retrieve_user_docs(): inplace_change(joinpath(catdir, filename), "../../model/", "/user/models/") +def retrieve_sasdata_docs(): + """ + Copies select files from the bumps documentation into fitting perspective + """ + print("=== Sasdata Docs ===") + copy_tree(SASDATA_DEV_SOURCE, SASDATA_DEV_TARGET) + copy_tree(SASDATA_GUIDE_SOURCE, SASDATA_GUIDE_TARGET) + + def retrieve_bumps_docs(): """ Copies select files from the bumps documentation into fitting perspective @@ -247,27 +266,31 @@ def build(): """ Runs sphinx-build. Reads in all .rst files and spits out the final html. """ + copy_tree(SPHINX_SOURCE, SASVIEW_DOC_SOURCE) print("=== Build HTML Docs from ReST Files ===") - subprocess.check_call([ - "sphinx-build", - "-v", - "-b", "html", # Builder name. TODO: accept as arg to setup.py. - "-d", joinpath(SPHINX_BUILD, "doctrees"), - "-W", "--keep-going", - SPHINX_SOURCE, - joinpath(SPHINX_BUILD, "html") - ]) + try: + subprocess.check_call([ + "sphinx-build", + "-v", + "-b", "html", # Builder name. TODO: accept as arg to setup.py. + "-d", joinpath(SPHINX_BUILD, "doctrees"), + "-W", "--keep-going", + SPHINX_SOURCE, + joinpath(SPHINX_BUILD, "html") + ]) + except Exception as e: + print(e) print("=== Copy HTML Docs to Build Directory ===") html = joinpath(SPHINX_BUILD, "html") - copy_tree(html, SASVIEW_DOC_TARGET) - + copy_tree(html, SASVIEW_DOC_BUILD) def rebuild(): clean() setup_source_temp() retrieve_user_docs() retrieve_bumps_docs() + retrieve_sasdata_docs() apidoc() build() if find_executable('latex'): diff --git a/docs/sphinx-docs/source/conf.py b/docs/sphinx-docs/source/conf.py index 63e0ea8ae7..98de920e70 100644 --- a/docs/sphinx-docs/source/conf.py +++ b/docs/sphinx-docs/source/conf.py @@ -37,7 +37,12 @@ 'sphinx.ext.mathjax', #'mathjax', # replacement mathjax that allows a list of paths 'dollarmath', - 'sphinx.ext.viewcode'] + #'sphinx.ext.viewcode', + ] + +no_highlight = os.environ.get('SAS_NO_HIGHLIGHT', '0') # Check if sphinx highlighting is disabled in this environment +if no_highlight == '1': + extensions.append('sphinx.ext.viewcode') mathjax_path = ( 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?' diff --git a/docs/sphinx-docs/source/dev/dev.rst b/docs/sphinx-docs/source/dev/dev.rst index 04e9e0f09b..98dfb11066 100644 --- a/docs/sphinx-docs/source/dev/dev.rst +++ b/docs/sphinx-docs/source/dev/dev.rst @@ -12,6 +12,7 @@ Contents SasView API sasmodels overview sasmodels API + sasdata overview OpenGL subsystem Indices and Search diff --git a/docs/sphinx-docs/source/user/working.rst b/docs/sphinx-docs/source/user/working.rst index 2da9c0c4e0..19b919dd68 100644 --- a/docs/sphinx-docs/source/user/working.rst +++ b/docs/sphinx-docs/source/user/working.rst @@ -6,13 +6,13 @@ Working with SasView .. toctree:: :maxdepth: 1 - Data Formats + Data Formats Loading Data Plotting Data/Models - Test Data + Example Data Tutorials diff --git a/installers/sasview.spec b/installers/sasview.spec index 2d528e3b31..73216492c9 100644 --- a/installers/sasview.spec +++ b/installers/sasview.spec @@ -18,12 +18,14 @@ datas = [ ('../src/sas/qtgui/Utilities/WhatsNew/messages', 'sas/qtgui/Utilities/WhatsNew/messages'), ('../src/sas/system/log.ini', 'sas/system/'), ('../../sasmodels/sasmodels','sasmodels'), - ('../docs/sphinx-docs/build/html','doc') + ('../docs/sphinx-docs/build','doc/build'), + ('../docs/sphinx-docs/source-temp','doc/source') ] #TODO: Hopefully we can get away from version specific packages datas.append((os.path.join(PYTHON_PACKAGES, 'debugpy'), 'debugpy')) datas.append((os.path.join(PYTHON_PACKAGES, 'jedi'), 'jedi')) datas.append((os.path.join(PYTHON_PACKAGES, 'zmq'), 'zmq')) +# datas.append((os.path.join(PYTHON_PACKAGES, 'freetype'), 'freetype')) def add_data(data): for component in data: diff --git a/run.py b/run.py index 960fc9ccd8..fe042c33bc 100644 --- a/run.py +++ b/run.py @@ -54,14 +54,20 @@ def prepare(): # Find the directories for the source and build root = abspath(dirname(realpath(__file__))) - # TODO: Do we prioritize the sibling repo or the installed package? - # TODO: Can we use sasview/run.py from a distributed sasview.exe? # Put supporting packages on the path if they are not already available. for sibling in ('periodictable', 'bumps', 'sasdata', 'sasmodels'): - try: - import_module(sibling) - except: - addpath(joinpath(root, '..', sibling)) + # run.py is only used by developers. The sibling directory should be the priority over installed packages. + # This allows developers to modify and use separate packages simultaneously. + devel_path = joinpath(root, '..', sibling) + if os.path.exists(devel_path): + addpath(devel_path) + else: + try: + import_module(sibling) + except ImportError: + raise ImportError(f"The {sibling} module is not available. Either pip install it in your environment or" + f" clone the repository into a directory level with the sasview directory.") + # Put the source trees on the path addpath(joinpath(root, 'src')) diff --git a/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py b/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py index 7867002987..b6ca232a96 100644 --- a/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py +++ b/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py @@ -25,8 +25,6 @@ def __init__(self, parent=None): self.setupUi(self) self.manager = parent self.communicator = self.manager.communicator() - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) # To store input datafiles self.filenames = None diff --git a/src/sas/qtgui/Calculators/DensityPanel.py b/src/sas/qtgui/Calculators/DensityPanel.py index d8f642774e..e015073c10 100644 --- a/src/sas/qtgui/Calculators/DensityPanel.py +++ b/src/sas/qtgui/Calculators/DensityPanel.py @@ -9,7 +9,6 @@ from sas.qtgui.Utilities.GuiUtils import FormulaValidator from sas.qtgui.UI import main_resources_rc -from sas.qtgui.Utilities.GuiUtils import HELP_DIRECTORY_LOCATION # Local UI from sas.qtgui.Calculators.UI.DensityPanel import Ui_DensityPanel @@ -47,8 +46,6 @@ def __init__(self, parent=None): self.mode = None self.manager = parent self.setupUi() - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setupModel() self.setupMapper() diff --git a/src/sas/qtgui/Calculators/GenericScatteringCalculator.py b/src/sas/qtgui/Calculators/GenericScatteringCalculator.py index 31c963b785..12da7d7226 100644 --- a/src/sas/qtgui/Calculators/GenericScatteringCalculator.py +++ b/src/sas/qtgui/Calculators/GenericScatteringCalculator.py @@ -52,8 +52,6 @@ class GenericScatteringCalculator(QtWidgets.QDialog, Ui_GenericScatteringCalcula def __init__(self, parent=None): super(GenericScatteringCalculator, self).__init__() self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setFixedSize(self.minimumSizeHint()) self.manager = parent diff --git a/src/sas/qtgui/Calculators/KiessigPanel.py b/src/sas/qtgui/Calculators/KiessigPanel.py index 7677234ded..fe00be1205 100644 --- a/src/sas/qtgui/Calculators/KiessigPanel.py +++ b/src/sas/qtgui/Calculators/KiessigPanel.py @@ -14,9 +14,6 @@ class KiessigPanel(QtWidgets.QDialog, Ui_KiessigPanel): def __init__(self, parent=None): super(KiessigPanel, self).__init__() self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) - self.setWindowTitle("Kiessig Thickness Calculator") self.manager = parent diff --git a/src/sas/qtgui/Calculators/ResolutionCalculatorPanel.py b/src/sas/qtgui/Calculators/ResolutionCalculatorPanel.py index 3e2f2cf9c3..2b073dbd72 100644 --- a/src/sas/qtgui/Calculators/ResolutionCalculatorPanel.py +++ b/src/sas/qtgui/Calculators/ResolutionCalculatorPanel.py @@ -40,8 +40,6 @@ class ResolutionCalculatorPanel(QtWidgets.QDialog, Ui_ResolutionCalculatorPanel) def __init__(self, parent=None): super(ResolutionCalculatorPanel, self).__init__() self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setFixedSize(self.minimumSizeHint()) self.manager = parent diff --git a/src/sas/qtgui/Calculators/SldPanel.py b/src/sas/qtgui/Calculators/SldPanel.py index 1b657e4c96..54fc5c0917 100644 --- a/src/sas/qtgui/Calculators/SldPanel.py +++ b/src/sas/qtgui/Calculators/SldPanel.py @@ -95,8 +95,6 @@ def __init__(self, parent=None): self.manager = parent self.setupUi() - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setFixedSize(self.minimumSizeHint()) self.setupModel() diff --git a/src/sas/qtgui/Calculators/SlitSizeCalculator.py b/src/sas/qtgui/Calculators/SlitSizeCalculator.py index 65b3a3da4f..b72541ff9b 100644 --- a/src/sas/qtgui/Calculators/SlitSizeCalculator.py +++ b/src/sas/qtgui/Calculators/SlitSizeCalculator.py @@ -24,8 +24,6 @@ class SlitSizeCalculator(QtWidgets.QDialog, Ui_SlitSizeCalculator): def __init__(self, parent=None): super(SlitSizeCalculator, self).__init__() self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setWindowTitle("Slit Size Calculator") self._parent = parent @@ -116,7 +114,7 @@ def calculateSlitSize(self, data=None): try: xdata = data.x ydata = data.y - if xdata.size == 0 or xdata is None or ydata.size == 0 or ydata is None: + if xdata is None or len(xdata) == 0 or ydata is None or len(ydata) == 0: msg = "The current data is empty please check x and y" logging.error(msg) return diff --git a/src/sas/qtgui/MainWindow/AboutBox.py b/src/sas/qtgui/MainWindow/AboutBox.py index 7136ef984c..fc431acf71 100644 --- a/src/sas/qtgui/MainWindow/AboutBox.py +++ b/src/sas/qtgui/MainWindow/AboutBox.py @@ -12,8 +12,6 @@ class AboutBox(QtWidgets.QDialog, Ui_AboutUI): def __init__(self, parent=None): super(AboutBox, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setWindowTitle("About") diff --git a/src/sas/qtgui/MainWindow/Acknowledgements.py b/src/sas/qtgui/MainWindow/Acknowledgements.py index 07cadb5b6f..1ee42dc187 100644 --- a/src/sas/qtgui/MainWindow/Acknowledgements.py +++ b/src/sas/qtgui/MainWindow/Acknowledgements.py @@ -12,8 +12,6 @@ class Acknowledgements(QtWidgets.QDialog, Ui_Acknowledgements): def __init__(self, parent=None): super(Acknowledgements, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.addText() diff --git a/src/sas/qtgui/MainWindow/CategoryManager.py b/src/sas/qtgui/MainWindow/CategoryManager.py index fa9ceabb5d..bbf990dd0c 100644 --- a/src/sas/qtgui/MainWindow/CategoryManager.py +++ b/src/sas/qtgui/MainWindow/CategoryManager.py @@ -135,9 +135,6 @@ def __init__(self, parent=None, manager=None): super(CategoryManager, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) - self.communicator = manager.communicator() self.setWindowTitle("Category Manager") @@ -330,8 +327,6 @@ class ChangeCategory(QtWidgets.QDialog, Ui_ChangeCategoryUI): def __init__(self, parent=None, categories=None, model=None): super(ChangeCategory, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.model = model self.parent = parent diff --git a/src/sas/qtgui/MainWindow/DataExplorer.py b/src/sas/qtgui/MainWindow/DataExplorer.py index cf67948fbc..9775aed9ec 100644 --- a/src/sas/qtgui/MainWindow/DataExplorer.py +++ b/src/sas/qtgui/MainWindow/DataExplorer.py @@ -688,17 +688,11 @@ def deleteFile(self, event): # Figure out which rows are checked ind = -1 # Use 'while' so the row count is forced at every iteration - deleted_items = [] - deleted_names = [] while ind < self.model.rowCount(): ind += 1 item = self.model.item(ind) if item and item.isCheckable() and item.checkState() == QtCore.Qt.Checked: - # Delete these rows from the model - deleted_names.append(str(self.model.item(ind).text())) - deleted_items.append(item) - # Delete corresponding open plots self.closePlotsForItem(item) # Close result panel if results represent the deleted data item @@ -706,16 +700,15 @@ def deleteFile(self, event): # => QStandardItems must still exist for direct comparison self.closeResultPanelOnDelete(GuiUtils.dataFromItem(item)) + # Let others know we deleted data, before we delete it + self.communicator.dataDeletedSignal.emit([item]) + # update stored_data + self.manager.update_stored_data([item]) + self.model.removeRow(ind) # Decrement index since we just deleted it ind -= 1 - # Let others know we deleted data - self.communicator.dataDeletedSignal.emit(deleted_items) - - # update stored_data - self.manager.update_stored_data(deleted_names) - def deleteTheory(self, event): """ Delete selected rows from the theory model @@ -734,29 +727,22 @@ def deleteTheory(self, event): # Figure out which rows are checked ind = -1 - - deleted_items = [] - deleted_names = [] while ind < self.theory_model.rowCount(): ind += 1 item = self.theory_model.item(ind) if item and item.isCheckable() and item.checkState() == QtCore.Qt.Checked: # Delete these rows from the model - deleted_names.append(str(self.theory_model.item(ind).text())) - deleted_items.append(item) self.closePlotsForItem(item) + # Let others know we deleted data + self.communicator.dataDeletedSignal.emit([item]) + # update stored_data + self.manager.update_stored_data([item]) self.theory_model.removeRow(ind) # Decrement index since we just deleted it ind -= 1 - # Let others know we deleted data - self.communicator.dataDeletedSignal.emit(deleted_items) - - # update stored_data - self.manager.update_stored_data(deleted_names) - def selectedItems(self): """ Returns the selected items from the current view diff --git a/src/sas/qtgui/MainWindow/GuiManager.py b/src/sas/qtgui/MainWindow/GuiManager.py index 655a7f628e..3f205827a0 100644 --- a/src/sas/qtgui/MainWindow/GuiManager.py +++ b/src/sas/qtgui/MainWindow/GuiManager.py @@ -6,6 +6,7 @@ import traceback from typing import Optional, Dict +from pathlib import Path from PySide6.QtWidgets import * from PySide6.QtGui import * @@ -15,7 +16,7 @@ import sas.system.version -mpl.use("Qt5Agg") +#mpl.use("Qt5Agg") from sas.system.version import __version__ as SASVIEW_VERSION, __release_date__ as SASVIEW_RELEASE_DATE @@ -33,6 +34,8 @@ from sas.qtgui.Utilities.ResultPanel import ResultPanel from sas.qtgui.Utilities.OrientationViewer.OrientationViewer import show_orientation_viewer from sas.qtgui.Utilities.HidableDialog import hidable_dialog +from sas.qtgui.Utilities.DocViewWidget import DocViewWindow +from sas.qtgui.Utilities.DocRegenInProgess import DocRegenProgress from sas.qtgui.Utilities.Reports.ReportDialog import ReportDialog from sas.qtgui.Utilities.Preferences.PreferencesPanel import PreferencesPanel @@ -74,6 +77,7 @@ import sas from sas import config from sas.system import web +from sas.sascalc.doc_regen.makedocumentation import HELP_DIRECTORY_LOCATION, create_user_files_if_needed logger = logging.getLogger(__name__) @@ -95,6 +99,9 @@ def __init__(self, parent=None): # Redefine exception hook to not explicitly crash the app. sys.excepthook = self.info + # Ensure the user directory has all required documentation files for future doc regen purposes + create_user_files_if_needed() + # Add signal callbacks self.addCallbacks() @@ -131,7 +138,7 @@ def __init__(self, parent=None): self.statusBarSetup() # Current tutorial location - self._tutorialLocation = os.path.abspath(os.path.join(GuiUtils.HELP_DIRECTORY_LOCATION, + self._tutorialLocation = os.path.abspath(os.path.join(HELP_DIRECTORY_LOCATION, "_downloads", "Tutorial.pdf")) @@ -205,6 +212,7 @@ def addWidgets(self): self.DataOperation = DataOperationUtilityPanel(self) self.FileConverter = FileConverterWidget(self) self.WhatsNew = WhatsNew(self) + self.regenProgress = DocRegenProgress(self) def loadAllPerspectives(self): """ Load all the perspectives""" @@ -368,7 +376,19 @@ def showHelp(self, url): """ Open a local url in the default browser """ - GuiUtils.showHelp(url) + # Remove leading forward slashes from relative paths to allow easy Path building + if isinstance(url, str): + url = url.lstrip("//") + url = Path(url) + if str(HELP_DIRECTORY_LOCATION.resolve()) not in str(url.absolute()): + url_abs = HELP_DIRECTORY_LOCATION / url + else: + url_abs = Path(url) + try: + # Help window shows itself + self.helpWindow = DocViewWindow(parent=self, source=url_abs) + except Exception as ex: + logging.warning("Cannot display help. %s" % ex) def workspace(self): """ @@ -402,7 +422,7 @@ def perspectiveChanged(self, new_perspective_name: str): self.loadedPerspectives[self._current_perspective.name] = self._current_perspective self._workspace.workspace.removeSubWindow(self._current_perspective) - self._workspace.workspace.closeActiveSubWindow() + self._workspace.workspace.removeSubWindow(self.subwindow) # Get new perspective - note that _current_perspective is of type Optional[Perspective], # but new_perspective is of type Perspective, thus call to Perspective members are safe diff --git a/src/sas/qtgui/MainWindow/MainWindow.py b/src/sas/qtgui/MainWindow/MainWindow.py index 82c362f6ac..3eb19234bc 100644 --- a/src/sas/qtgui/MainWindow/MainWindow.py +++ b/src/sas/qtgui/MainWindow/MainWindow.py @@ -72,7 +72,10 @@ def run_sasview(): # Make the event loop interruptable quickly import signal signal.signal(signal.SIGINT, signal.SIG_DFL) - + + from PySide6.QtQuick import QSGRendererInterface, QQuickWindow + QGuiApplication.setAttribute(Qt.AA_ShareOpenGLContexts) + QQuickWindow.setGraphicsApi(QSGRendererInterface.OpenGLRhi) # Note: Qt environment variables are initialized in sas.system.lib.setup_qt_env # Main must have reference to the splash screen, so making it explicit app = QApplication([]) @@ -84,7 +87,7 @@ def run_sasview(): splash.show() # Main application style. - #app.setStyle('Fusion') + # app.setStyle('Fusion') # fix for pyinstaller packages app to avoid ReactorAlreadyInstalledError if 'twisted.internet.reactor' in sys.modules: diff --git a/src/sas/qtgui/MainWindow/NameChanger.py b/src/sas/qtgui/MainWindow/NameChanger.py index 06d98f69f2..1479481a43 100644 --- a/src/sas/qtgui/MainWindow/NameChanger.py +++ b/src/sas/qtgui/MainWindow/NameChanger.py @@ -15,7 +15,6 @@ def __init__(self, parent=None): self._model_item = None self.setupUi(self) - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setModal(True) self.parent = parent diff --git a/src/sas/qtgui/MainWindow/media/data_formats_help.rst b/src/sas/qtgui/MainWindow/media/data_formats_help.rst deleted file mode 100755 index a02b636815..0000000000 --- a/src/sas/qtgui/MainWindow/media/data_formats_help.rst +++ /dev/null @@ -1,222 +0,0 @@ -.. data_formats.rst - -.. This is a port of the original SasView html help file to ReSTructured text -.. by S King, ISIS, during SasView CodeCamp-III in Feb 2015. -.. WG Bouwman, DUT, added during CodeCamp-V in Oct 2016 the SESANS data format -.. WG Bouwman, DUT, updated during CodeCamp-VI in Apr 2017 the SESANS data format -.. J Krzywon, P Butler, S King, overhauled during PR Hackathon in Oct 2021 - -.. _Formats: - -Data Formats -============ - -SasView recognizes 1D SAS (*I(Q) vs Q*), 2D SAS(*I(Qx,Qy) vs (Qx,Qy)*) and 1D -SESANS (*P(z) vs z*) data in several different file formats. It will also read -and analyse other data adhering to the same file formats (e.g. DLS or NR data) -but not necessarily recognise what those data represent (e.g. plot axes may be -mislabelled). - -.. note:: From SasView 4.1 onwards (but not versions 5.0.0 or 5.0.1), the - :ref:`File_Converter_Tool` allows some legacy formats to be converted - into either the canSAS SASXML format or the NeXus NXcanSAS format. - These legacy formats include 1D/2D BSL/OTOKO, 1D output from FIT2D - and some other SAXS-oriented software, and the ISIS COLETTE (or - 'RKH') 2D format. - -1D SAS Formats --------------- - -SasView recognizes 1D data supplied in a number of specific formats, as identified -by the file extensions below. It also incorporates a 'generic loader' which is -called if all else fails. The generic loader will attempt to read data files of -any extension *provided* the file is in ASCII ('text') format (i.e. not binary). -So this includes, for example, comma-separated variable (CSV) files from a -spreadsheet. - -The file extensions (which are not case sensitive) with specific meaning are: - -* .ABS (NIST format) -* .ASC (NIST format) -* .COR (in canSAS XML v1.0 and v1.1 formats *only*) -* .DAT (NIST format) -* .H5, .NXS, .HDF, or .HDF5 (in NXcanSAS v1.0 and v1.1 formats *only*) -* .PDH (Anton Paar SAXSess format) -* .XML (in canSAS XML v1.0 and v1.1 formats *only*) - -The CanSAS & NXcanSAS standard formats are both output by the -`Mantid data reduction framework `_ and the -`NIST Igor data reduction routines `_. - -The ASCII formats can be viewed in any text editor (Notepad, vi, etc) but the -HDF formats require a viewer, such as `HDFView `_. - -The ASCII ('text') files are expected to have 2, 3, or 4 columns of values, -separated by whitespaces or commas or semicolons, in the following order: - - *Q, I(Q), ( dI(Q), dQ(Q) )* - -where *Q* is assumed to have units of 1/Angstrom, *I(Q)* is assumed to have -units of 1/cm, *dI(Q)* is the uncertainty on the intensity value (also as 1/cm), -and *dQ(Q)* **is the one-sigma FWHM Gaussian instrumental resolution in** *Q*, -**assumed to have arisen from pinhole geometry**. If the data are slit-smeared, -see `Slit-Smeared Data`_. - -There must be a minimum of 5 lines of data in the file, and each line of data -**must** contain the same number of entries (i.e. columns of data values). - -As a general rule, SasView will provide better fits when it is provided with -more information (i.e. more columns) about each observation (i.e. data point). - -If using CSV output, ensure that it is not using commas as delimiters for the -thousands. - -**Examples of these formats can be found in the \\test\\1d_data sub-folder -in the SasView installation folder.** - -.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ - -2D SAS Formats --------------- - -SasView recognizes 2D data only when supplied in ASCII ('text') files in the -NIST 2D format (with the extensions .ASC or .DAT) or HDF files in the NeXus -NXcanSAS (HDF5) format (with the extension .H5, .NXS, .HDF, or .HDF5). The file -extensions are not case-sensitive. Data in the old ISIS 2D format must be -converted using the :ref:`File_Converter_Tool`. - -The NXcanSAS standard format is output by the -`Mantid data reduction framework `_ and the -`NIST Igor data reduction routines `_. - -Most of the header lines in the `NIST 2D format `_ -can be removed *except the last line*, and only the first three columns -(*Qx, Qy,* and *I(Qx,Qy)*) are actually required. - -Data values have the same meanings and units as for `1D SAS Formats`_. - -*2D image data* can be translated into 2D 'pseudo-data' using the -:ref:`Image_Viewer_Tool`, but this should only be done with an abundance of -caution. - -**Examples of these formats can be found in the \\test\\2d_data sub-folder -in the SasView installation folder.** - -.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ - -1D SESANS Format ----------------- - -SasView version 4.1 onwards will read ASCII ('text') files in a prototype SESANS -standard format with the extensions .SES or .SESANS (which are not -case-sensitive). - -The file format starts with a list of name-value pairs which detail the general -experimental parameters necessary for fitting and analyzing the data. This list -should contain all the information necessary for the file to be 'portable' -between users. - -Following the header are up to 8 space-delimited columns of experimental -variables of which the first 4 columns are required. In order, these are: - -- Spin-echo length (z, in Angstroms) -- Depolarization (:math:`log(P/P_0)/(lambda^2 * thickness)`, in Angstrom :sup:`-1` cm :sup:`-1`\ ) -- Depolarization error (also in in Angstrom :sup:`-1` cm :sup:`-1`\ ) (i.e. the measurement error) -- Spin-echo length error (:math:`\Delta`\ z, in Angstroms) (i.e. the experimental resolution) -- Neutron wavelength (:math:`\lambda`, in Angstroms) -- Neutron wavelength error (:math:`\Delta \lambda`, in Angstroms) -- Normalized polarization (:math:`P/P_0`, unitless) -- Normalized polarization error (:math:`\Delta(P/P_0)`, unitless) (i.e. the measurement error) - -**Examples of this format can be found in the \\test\\sesans_data sub-folder -in the SasView installation folder.** - -.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ - -Coordinate Formats ------------------- - -The :ref:`SANS_Calculator_Tool` in SasView recognises ASCII ('text') files -containing coordinate data (a grid of 'voxels') with the following extensions -(which are not case-sensitive): - -* .PDB (`Protein Data Bank format `_) -* .OMF (`OOMMF micromagnetic simulation format `_) -* .SLD (Spin-Lattice Dynamics simulation format) - -In essence, coordinate formats specify a location and one or more properties of -that location (e.g. what it represents, its volume, or magnetisation, etc). The -PDB/OMF/SLD formats all use a rectangular grid of voxels. - -The .STL coordinate format is not currently supported by SasView. - -**Examples of these formats can be found in the \\test\\coordinate_data -sub-folder in the SasView installation folder.** - -.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ - -Slit-Smeared Data ------------------ - -SasView will only account for slit smearing if the data being processed are -recognized as slit-smeared. - -Currently, only the canSAS \*.XML, NIST \*.ABS and NXcanSAS formats facilitate -slit-smeared data. The easiest way to include $\Delta q_v$ in a way -recognizable by SasView is to mimic the \*.ABS format. The data must follow -the normal rules for general ASCII files **but include 6 columns**, not 4 -columns. The SasView general ASCII loader assumes the first four columns are -*Q*, *I(Q)*, *dI(Q)*, and *dQ(Q)*. If the data does not contain any *dI(Q)* -information, these can be faked by making them ~1% (or less) of the *I(Q)* -data. The fourth column **must** then contain the the $\Delta q_v$ value, -in |Ang^-1|, but as a **negative number**. Each row of data should have the -same value. The 5th column **must** be a duplicate of column 1. **Column 6 -can have any value but cannot be empty**. Finally, the line immediately -preceding the actual columnar data **must** begin with: "The 6 columns". - -**For an example of a 6 column file with slit-smeared data, see the example data -set 1umSlitSmearSphere.ABS in the \\test\\1d sub-folder in the SasView -installation folder.** - -.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ - -Further Information -------------------- - -ASCII - -- https://en.wikipedia.org/wiki/ASCII - -HDF - -- https://en.wikipedia.org/wiki/Hierarchical_Data_Format - -NXS - -- https://en.wikipedia.org/wiki/Nexus_(data_format) - -- https://www.nexusformat.org/ - -For a description of the CanSAS SASXML 1D format see: - -- http://www.cansas.org/formats/canSAS1d/1.1/doc/ - -For a description of the NXcanSAS format see: - -- http://cansas-org.github.io/NXcanSAS/classes/contributed_definitions/NXcanSAS.html - -For descriptions of the NIST 1D & 2D formats see: - -- https://github.com/sansigormacros/ncnrsansigormacros/wiki - -For descriptions of the ISIS COLETTE (or 'RKH') 1D & 2D formats see: - -- https://www.isis.stfc.ac.uk/Pages/colette-ascii-file-format-descriptions.pdf - -For a description of the BSL/OTOKO format see: - -- http://www.diamond.ac.uk/Beamlines/Soft-Condensed-Matter/small-angle/SAXS-Software/CCP13/BSL.html - -.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ - -.. note:: This help document was last changed by Steve King, 29Oct2021 diff --git a/src/sas/qtgui/MainWindow/media/preferences_help.rst b/src/sas/qtgui/MainWindow/media/preferences_help.rst index 587cf293d6..3d211445d9 100644 --- a/src/sas/qtgui/MainWindow/media/preferences_help.rst +++ b/src/sas/qtgui/MainWindow/media/preferences_help.rst @@ -1,7 +1,7 @@ .. preferences_help.rst .. Initial Draft: J Krzywon, August 2022 -.. Last Updated: J Krzywon, December 2, 2022 +.. Last Updated: J Krzywon, Nov. 22, 2023 .. _Preferences: @@ -32,6 +32,12 @@ an ellipsis and whitespace. *persistent* **Legend entry line length**: This defines the maximum number of characters to display in a single line of a plot legend before wrapping to the next line. *persistent* +**Disable Residuals Display**: When selected, residual plots are not automatically displayed when fits are completed. The plots +can still be accessed in the data explorer under the data set the fit was performed on. *persistent* + +**Disable Polydispersity Plot Display**: When selected, polydispersity plots are not automatically displayed when fits +are completed. The plots can still be accessed in the data explorer under the data set the fit was performed on. *persistent* + .. _Display_Preferences: Display Preferences diff --git a/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py b/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py index 7f25ca4797..ef1d8f7c1d 100644 --- a/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py +++ b/src/sas/qtgui/Perspectives/Corfunc/CorfuncPerspective.py @@ -765,6 +765,8 @@ def updateFromParameters(self, params): msg = "Corfunc.updateFromParameters expects a dictionary" raise TypeError(f"{msg}: {c_name} received") # Assign values to 'Invariant' tab inputs - use defaults if not found + # don't raise model_changed signal for a while + self.model.itemChanged.disconnect(self.model_changed) self.model.setItem( WIDGETS.W_GUINIERA, QtGui.QStandardItem(params.get('guinier_a', '0.0'))) self.model.setItem( @@ -799,6 +801,8 @@ def updateFromParameters(self, params): WIDGETS.W_QCUTOFF, QtGui.QStandardItem(params.get('upper_q_max', '0.22'))) self.model.setItem(WIDGETS.W_BACKGROUND, QtGui.QStandardItem( params.get('background', '0'))) + # reconnect model + self.model.itemChanged.connect(self.model_changed) self.cmdSave.setEnabled(params.get('guinier_a', '0.0') != '0.0') self.cmdExtract.setEnabled(params.get('long_period', '0') != '0') diff --git a/src/sas/qtgui/Perspectives/Fitting/ComplexConstraint.py b/src/sas/qtgui/Perspectives/Fitting/ComplexConstraint.py index c7ceeb271f..f25e513296 100644 --- a/src/sas/qtgui/Perspectives/Fitting/ComplexConstraint.py +++ b/src/sas/qtgui/Perspectives/Fitting/ComplexConstraint.py @@ -30,10 +30,6 @@ def __init__(self, parent=None, tabs=None): self.setupUi(self) self.setModal(True) - # disable the context help icon - windowFlags = self.windowFlags() - self.setWindowFlags(windowFlags & ~QtCore.Qt.WindowContextHelpButtonHint) - # Useful globals self.tabs = tabs self.params = None @@ -361,8 +357,8 @@ def onSetAll(self): # loop over parameters in constrained model index1 = self.cbModel1.currentIndex() index2 = self.cbModel2.currentIndex() - items1 = self.tabs[index1].kernel_module.params - items2 = self.params[index2] + items1 = self.tabs[index1].main_params_to_fit + self.tabs[index1].poly_params_to_fit + items2 = self.tabs[index2].main_params_to_fit + self.tabs[index2].poly_params_to_fit # create an empty list to store redefined constraints redefined_constraints = [] for item in items1: diff --git a/src/sas/qtgui/Perspectives/Fitting/ConstraintWidget.py b/src/sas/qtgui/Perspectives/Fitting/ConstraintWidget.py index 0e8343c25c..453e35fd5f 100644 --- a/src/sas/qtgui/Perspectives/Fitting/ConstraintWidget.py +++ b/src/sas/qtgui/Perspectives/Fitting/ConstraintWidget.py @@ -1213,7 +1213,10 @@ def createPageForParameters(self, parameters=None): if model_name != model: continue # check/uncheck item - self.tblConstraints.item(row,0).setCheckState(int(check_state)) + if 'Unchecked' in check_state: + self.tblConstraints.item(row, 0).setCheckState(QtCore.Qt.Unchecked) + else: + self.tblConstraints.item(row, 0).setCheckState(QtCore.Qt.Checked) # fit/batch radio isBatch = parameters['current_type'][0] == 'BatchPage' diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py index 76efdc69b7..039f4a3afc 100644 --- a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py +++ b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py @@ -3,6 +3,7 @@ import sys from collections import defaultdict from typing import Any, Tuple, Optional +from pathlib import Path import copy import logging @@ -25,6 +26,7 @@ from sas import config from sas.sascalc.fit.BumpsFitting import BumpsFit as Fit from sas.sascalc.fit import models +from sas.sascalc.doc_regen.makedocumentation import IMAGES_DIRECTORY_LOCATION, HELP_DIRECTORY_LOCATION import sas.qtgui.Utilities.GuiUtils as GuiUtils from sas.qtgui.Utilities.CategoryInstaller import CategoryInstaller @@ -97,6 +99,7 @@ def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): return QtGui.QStandardItemModel.headerData(self, section, orientation, role) + class FittingWidget(QtWidgets.QWidget, Ui_FittingWidgetUI): """ Main widget for selecting form and structure factor models @@ -116,7 +119,8 @@ def __init__(self, parent=None, data=None, tab_id=1): super(FittingWidget, self).__init__() # Necessary globals - self.parent = parent + self.parent = parent + self.process = None # Default empty value # Which tab is this widget displayed in? self.tab_id = tab_id @@ -366,7 +370,7 @@ def initializeWidgets(self): # Magnetic angles explained in one picture self.magneticAnglesWidget = QtWidgets.QWidget() labl = QtWidgets.QLabel(self.magneticAnglesWidget) - pixmap = QtGui.QPixmap(GuiUtils.IMAGES_DIRECTORY_LOCATION + '/M_angles_pic.png') + pixmap = QtGui.QPixmap(IMAGES_DIRECTORY_LOCATION / 'M_angles_pic.png') labl.setPixmap(pixmap) self.magneticAnglesWidget.setFixedSize(pixmap.width(), pixmap.height()) @@ -709,6 +713,7 @@ def showModelContextMenu(self, position): current_list = self.tabToList[self.tabFitting.currentIndex()] rows = [s.row() for s in current_list.selectionModel().selectedRows() if self.isCheckable(s.row())] + menu = self.showModelDescription() if not rows else self.modelContextMenu(rows) try: menu.exec_(current_list.viewport().mapToGlobal(position)) @@ -799,7 +804,7 @@ def showMultiConstraint(self, current_list=None): # but let's check the correctness. assert len(selected_rows) == 2 - params_list = [s.data(role=QtCore.Qt.UserRole) for s in selected_rows] + params_list = [s.data() for s in selected_rows] # Create and display the widget for param1 and param2 mc_widget = MultiConstraint(self, params=params_list) # Check if any of the parameters are polydisperse @@ -980,6 +985,8 @@ def addConstraintToRow(self, constraint=None, row=0, model_key="standard"): # First, get a list of constraints and symbols constraint_list = self.parent.perspective().getActiveConstraintList() symbol_dict = self.parent.perspective().getSymbolDictForConstraints() + if model_key == 'poly' and 'Distribution' in constraint.param: + constraint.param = self.polyNameToParam(constraint.param) constraint_list.append((self.modelName() + '.' + constraint.param, constraint.func)) # Call the error checking function @@ -1034,6 +1041,7 @@ def addSimpleConstraint(self): max_t = model.item(row, max_col).text() # Create a Constraint object constraint = Constraint(param=param, value=value, min=min_t, max=max_t) + constraint.active = False # Create a new item and add the Constraint object as a child item = QtGui.QStandardItem() item.setData(constraint) @@ -1061,7 +1069,7 @@ def editConstraint(self): current_list = self.tabToList[self.tabFitting.currentIndex()] model_key = self.tabToKey[self.tabFitting.currentIndex()] - params_list = [s.data(role=QtCore.Qt.UserRole) for s in current_list.selectionModel().selectedRows() + params_list = [s.data() for s in current_list.selectionModel().selectedRows() if self.isCheckable(s.row(), model_key=model_key)] assert len(params_list) == 1 row = current_list.selectionModel().selectedRows()[0].row() @@ -1094,6 +1102,8 @@ def editConstraint(self): constraint.validate = mc_widget.validate # Which row is the constrained parameter in? + if model_key == 'poly' and 'Distribution' in constraint.param: + constraint.param = self.polyNameToParam(constraint.param) row = self.getRowFromName(constraint.param) # Create a new item and add the Constraint object as a child @@ -1105,9 +1115,11 @@ def deleteConstraint(self): """ current_list = self.tabToList[self.tabFitting.currentIndex()] model_key = self.tabToKey[self.tabFitting.currentIndex()] - params = [s.data(role=QtCore.Qt.UserRole) for s in current_list.selectionModel().selectedRows() + params = [s.data() for s in current_list.selectionModel().selectedRows() if self.isCheckable(s.row(), model_key=model_key)] for param in params: + if model_key == 'poly': + param = self.polyNameToParam(param) self.deleteConstraintOnParameter(param=param, model_key=model_key) def deleteConstraintOnParameter(self, param=None, model_key="standard"): @@ -1720,6 +1732,7 @@ def onSelectCategory(self): self.cbModel.blockSignals(False) self.enableModelCombo() self.disableStructureCombo() + self.kernel_module = None self._previous_category_index = self.cbCategory.currentIndex() # Retrieve the list of models @@ -1861,43 +1874,34 @@ def onHelp(self): """ Show the "Fitting" section of help """ - tree_location = "/user/qtgui/Perspectives/Fitting/" + regen_in_progress = False + help_location = self.getHelpLocation(HELP_DIRECTORY_LOCATION) + if regen_in_progress is False: + self.parent.showHelp(help_location) + def getHelpLocation(self, tree_base) -> Path: # Actual file will depend on the current tab tab_id = self.tabFitting.currentIndex() - helpfile = "fitting.html" - if tab_id == 0: - # Look at the model and if set, pull out its help page - if self.kernel_module is not None and hasattr(self.kernel_module, 'name'): - # See if the help file is there - # This breaks encapsulation a bit, though. - full_path = GuiUtils.HELP_DIRECTORY_LOCATION - sas_path = os.path.abspath(os.path.dirname(sys.argv[0])) - location = sas_path + "/" + full_path - location += "/user/models/" + self.kernel_module.id + ".html" - if os.path.isfile(location): - # We have HTML for this model - show it - tree_location = "/user/models/" - helpfile = self.kernel_module.id + ".html" - else: - helpfile = "fitting_help.html" - elif tab_id == 1: - helpfile = "residuals_help.html" - elif tab_id == 2: - helpfile = "resolution.html" - elif tab_id == 3: - helpfile = "pd/polydispersity.html" - elif tab_id == 4: - helpfile = "magnetism/magnetism.html" - help_location = tree_location + helpfile - - self.showHelp(help_location) - - def showHelp(self, url): - """ - Calls parent's method for opening an HTML page - """ - self.parent.showHelp(url) + tree_location = tree_base / "user" / "qtgui" / "Perspectives" / "Fitting" + + match tab_id: + case 0: + # Look at the model and if set, pull out its help page + if self.kernel_module is not None and hasattr(self.kernel_module, 'name'): + tree_location = tree_base / "user" / "models" + return tree_location / f"{self.kernel_module.id}.html" + else: + return tree_location / "fitting_help.html" + case 1: + return tree_location / "residuals_help.html" + case 2: + return tree_location / "resolution.html" + case 3: + return tree_location / "pd/polydispersity.html" + case 4: + return tree_location / "magnetism/magnetism.html" + case _: + return tree_location / "fitting.html" def onDisplayMagneticAngles(self): """ diff --git a/src/sas/qtgui/Perspectives/Fitting/GPUOptions.py b/src/sas/qtgui/Perspectives/Fitting/GPUOptions.py index a034aad02c..f46da0d427 100644 --- a/src/sas/qtgui/Perspectives/Fitting/GPUOptions.py +++ b/src/sas/qtgui/Perspectives/Fitting/GPUOptions.py @@ -55,8 +55,6 @@ def __init__(self): self.radio_buttons = [] - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.add_options() self.progressBar.setVisible(False) self.progressBar.setFormat(" Test %v / %m") diff --git a/src/sas/qtgui/Perspectives/Fitting/MultiConstraint.py b/src/sas/qtgui/Perspectives/Fitting/MultiConstraint.py index fe0eb54ddd..6079d4cca7 100644 --- a/src/sas/qtgui/Perspectives/Fitting/MultiConstraint.py +++ b/src/sas/qtgui/Perspectives/Fitting/MultiConstraint.py @@ -30,10 +30,6 @@ def __init__(self, parent=None, params=None, constraint=None): self.setupUi(self) self.setModal(True) - # disable the context help icon - windowFlags = self.windowFlags() - self.setWindowFlags(windowFlags & ~QtCore.Qt.WindowContextHelpButtonHint) - self.params = params self.parent = parent # Text of the constraint @@ -106,6 +102,9 @@ def validateFormula(self): """ Add visual cues when formula is incorrect """ + # temporarily disable validation, as not yet fully operational + return + # Don't validate if requested if not self.validate: return diff --git a/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py b/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py index b82b854234..ee9c31a7ae 100644 --- a/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py +++ b/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py @@ -439,7 +439,7 @@ def showBatchOutput(self): """ if self.batchResultsWindow is None: self.batchResultsWindow = BatchInversionOutputPanel( - parent=self, output_data=self.batchResults) + parent=self._parent, output_data=self.batchResults) else: self.batchResultsWindow.setupTable(self.batchResults) self.batchResultsWindow.show() diff --git a/src/sas/qtgui/Plotting/AddText.py b/src/sas/qtgui/Plotting/AddText.py index 6267370bbe..3f0646ecf0 100644 --- a/src/sas/qtgui/Plotting/AddText.py +++ b/src/sas/qtgui/Plotting/AddText.py @@ -11,8 +11,6 @@ class AddText(QtWidgets.QDialog, Ui_AddText): def __init__(self, parent=None): super(AddText, self).__init__() self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self._font = QtGui.QFont() self._color = "black" diff --git a/src/sas/qtgui/Plotting/ColorMap.py b/src/sas/qtgui/Plotting/ColorMap.py index 9701f807e8..86f61a50a3 100644 --- a/src/sas/qtgui/Plotting/ColorMap.py +++ b/src/sas/qtgui/Plotting/ColorMap.py @@ -27,8 +27,6 @@ def __init__(self, parent=None, cmap=None, vmin=0.0, vmax=100.0, data=None): self.setupUi(self) assert(isinstance(data, Data2D)) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.data = data self._cmap_orig = self._cmap = cmap if cmap is not None else DEFAULT_MAP diff --git a/src/sas/qtgui/Plotting/LinearFit.py b/src/sas/qtgui/Plotting/LinearFit.py index 39ef9a121f..fa3b246128 100644 --- a/src/sas/qtgui/Plotting/LinearFit.py +++ b/src/sas/qtgui/Plotting/LinearFit.py @@ -33,8 +33,6 @@ def __init__(self, parent=None, super(LinearFit, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) assert(isinstance(max_range, tuple)) assert(isinstance(fit_range, tuple)) diff --git a/src/sas/qtgui/Plotting/MaskEditor.py b/src/sas/qtgui/Plotting/MaskEditor.py index f11d23b3f0..bc9b3135a7 100644 --- a/src/sas/qtgui/Plotting/MaskEditor.py +++ b/src/sas/qtgui/Plotting/MaskEditor.py @@ -23,8 +23,6 @@ def __init__(self, parent=None, data=None): assert isinstance(data, Data2D) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.data = data self.parent = parent diff --git a/src/sas/qtgui/Plotting/PlotLabelProperties.py b/src/sas/qtgui/Plotting/PlotLabelProperties.py index ca7b7a29cc..3434c377bb 100644 --- a/src/sas/qtgui/Plotting/PlotLabelProperties.py +++ b/src/sas/qtgui/Plotting/PlotLabelProperties.py @@ -64,8 +64,6 @@ def __init__(self, super(PlotLabelProperties, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setFixedSize(self.minimumSizeHint()) diff --git a/src/sas/qtgui/Plotting/PlotProperties.py b/src/sas/qtgui/Plotting/PlotProperties.py index cc9674529a..9deb83ea64 100644 --- a/src/sas/qtgui/Plotting/PlotProperties.py +++ b/src/sas/qtgui/Plotting/PlotProperties.py @@ -14,8 +14,6 @@ def __init__(self, legend=""): super(PlotProperties, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.setFixedSize(self.minimumSizeHint()) diff --git a/src/sas/qtgui/Plotting/Plotter.py b/src/sas/qtgui/Plotting/Plotter.py index dc18d9b915..12266ab6ae 100644 --- a/src/sas/qtgui/Plotting/Plotter.py +++ b/src/sas/qtgui/Plotting/Plotter.py @@ -394,7 +394,7 @@ def addPlotsToContextMenu(self): if plot.is_data: self.actionHideError = plot_menu.addAction("Hide Error Bar") - if plot.dy is not None and plot.dy != []: + if plot.dy is not None and len(plot.dy)>0: if plot.hide_error: self.actionHideError.setText('Show Error Bar') else: diff --git a/src/sas/qtgui/Plotting/Plotter2D.py b/src/sas/qtgui/Plotting/Plotter2D.py index 4529b1cfc3..818fa1d732 100644 --- a/src/sas/qtgui/Plotting/Plotter2D.py +++ b/src/sas/qtgui/Plotting/Plotter2D.py @@ -40,7 +40,7 @@ class Plotter2DWidget(PlotterBase): """ def __init__(self, parent=None, manager=None, quickplot=False, dimension=2): self.dimension = dimension - super(Plotter2DWidget, self).__init__(parent, manager=manager, quickplot=quickplot) + super(Plotter2DWidget, self).__init__(manager=manager, quickplot=quickplot) self.cmap = DEFAULT_CMAP.name # Default scale diff --git a/src/sas/qtgui/Plotting/ScaleProperties.py b/src/sas/qtgui/Plotting/ScaleProperties.py index ce55f5b603..7d2734cad1 100644 --- a/src/sas/qtgui/Plotting/ScaleProperties.py +++ b/src/sas/qtgui/Plotting/ScaleProperties.py @@ -23,8 +23,6 @@ class ScaleProperties(QtWidgets.QDialog, Ui_scalePropertiesUI): def __init__(self, parent=None, init_scale_x='x', init_scale_y='y'): super(ScaleProperties, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) # Set up comboboxes self.cbX.addItems(x_values) diff --git a/src/sas/qtgui/Plotting/SetGraphRange.py b/src/sas/qtgui/Plotting/SetGraphRange.py index 7a1c078e86..4246eb4520 100644 --- a/src/sas/qtgui/Plotting/SetGraphRange.py +++ b/src/sas/qtgui/Plotting/SetGraphRange.py @@ -16,8 +16,6 @@ def __init__(self, parent=None, x_range=(0.0, 0.0), y_range=(0.0, 0.0)): super(SetGraphRange, self).__init__() self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) assert(isinstance(x_range, tuple)) assert(isinstance(y_range, tuple)) diff --git a/src/sas/qtgui/Plotting/SlicerParameters.py b/src/sas/qtgui/Plotting/SlicerParameters.py index de33f8721d..a09311b54c 100644 --- a/src/sas/qtgui/Plotting/SlicerParameters.py +++ b/src/sas/qtgui/Plotting/SlicerParameters.py @@ -38,12 +38,14 @@ def __init__(self, parent=None, active_plots=None, validate_method=None, communicator=None): - super(SlicerParameters, self).__init__() + super(SlicerParameters, self).__init__(parent.manager) self.setupUi(self) self.parent = parent + self.manager = parent.manager + self.model = model self.validate_method = validate_method self.active_plots = active_plots @@ -425,7 +427,7 @@ def onHelp(self): Display generic data averaging help """ url = "/user/qtgui/MainWindow/graph_help.html#d-data-averaging" - GuiUtils.showHelp(url) + self.manager.parent.showHelp(url) class ProxyModel(QtCore.QIdentityProxyModel): diff --git a/src/sas/qtgui/Plotting/UnitTesting/PlotterBaseTest.py b/src/sas/qtgui/Plotting/UnitTesting/PlotterBaseTest.py index e6e7df305b..fde48eb739 100644 --- a/src/sas/qtgui/Plotting/UnitTesting/PlotterBaseTest.py +++ b/src/sas/qtgui/Plotting/UnitTesting/PlotterBaseTest.py @@ -49,7 +49,7 @@ def testDefaults(self, plotter): assert isinstance(plotter.canvas, FigureCanvas) assert isinstance(plotter.properties, ScaleProperties) - assert plotter._data == [] + assert len(plotter._data) == 0 assert plotter._xscale == 'log' assert plotter._yscale == 'log' assert plotter.scale == 'linear' diff --git a/src/sas/qtgui/Plotting/WindowTitle.py b/src/sas/qtgui/Plotting/WindowTitle.py index c26f4b982e..2f492ef747 100644 --- a/src/sas/qtgui/Plotting/WindowTitle.py +++ b/src/sas/qtgui/Plotting/WindowTitle.py @@ -11,8 +11,6 @@ class WindowTitle(QtWidgets.QDialog, Ui_WindowTitle): def __init__(self, parent=None, new_title=""): super(WindowTitle, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.txtTitle.setText(new_title) diff --git a/src/sas/qtgui/Utilities/AddMultEditor.py b/src/sas/qtgui/Utilities/AddMultEditor.py index 7eb47ee38d..da161e1cb7 100644 --- a/src/sas/qtgui/Utilities/AddMultEditor.py +++ b/src/sas/qtgui/Utilities/AddMultEditor.py @@ -56,8 +56,6 @@ def __init__(self, parent=None): self.parent = parent self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) # uncheck self.chkOverwrite self.chkOverwrite.setChecked(False) diff --git a/src/sas/qtgui/Utilities/DocRegenInProgess.py b/src/sas/qtgui/Utilities/DocRegenInProgess.py new file mode 100644 index 0000000000..192fe9a2ac --- /dev/null +++ b/src/sas/qtgui/Utilities/DocRegenInProgess.py @@ -0,0 +1,40 @@ +from PySide6 import QtWidgets, QtCore + +from sas.qtgui.Utilities.UI.DocRegenInProgress import Ui_DocRegenProgress +from sas.sascalc.doc_regen.makedocumentation import DOC_LOG + + +class DocRegenProgress(QtWidgets.QWidget, Ui_DocRegenProgress): + def __init__(self, parent=None): + """The DocRegenProgress class is a window to display the progress of the documentation regeneration process. + + :param parent: Any Qt object with a communicator that can trigger events. + """ + super(DocRegenProgress, self).__init__() + self.setupUi(self) + self.parent = parent + + self.setWindowTitle("Documentation Regenerating") + self.label.setText("Regeneration In Progress") + self.textBrowser.setText("Placeholder Text.") + self.file_watcher = QtCore.QFileSystemWatcher() + self.addSignals() + + def addSignals(self): + """Adds triggers and signals to the window to ensure proper behavior.""" + if self.parent: + self.parent.communicate.documentationRegenInProgressSignal.connect(self.show) + self.parent.communicate.documentationRegeneratedSignal.connect(self.close) + # Trigger the file watcher when the documentation log changes on disk. + self.file_watcher.addPath(str(DOC_LOG.absolute())) + self.file_watcher.fileChanged.connect(self.updateLog) + + def updateLog(self): + """This method is triggered whenever the file associated with the file_watcher object is changed.""" + self.textBrowser.setText("") + with open(DOC_LOG, 'r') as f: + self.textBrowser.append(f.read()) + + def close(self): + """Override the close behavior to ensure the window always exists in memory.""" + self.hide() diff --git a/src/sas/qtgui/Utilities/DocViewWidget.py b/src/sas/qtgui/Utilities/DocViewWidget.py new file mode 100644 index 0000000000..0db82315c4 --- /dev/null +++ b/src/sas/qtgui/Utilities/DocViewWidget.py @@ -0,0 +1,214 @@ +import sys +import os +import logging +from pathlib import Path +from typing import Union + +from PySide6 import QtCore, QtWidgets, QtWebEngineCore +from twisted.internet import threads + +from .UI.DocViewWidgetUI import Ui_DocViewerWindow +from sas.qtgui.Utilities.TabbedModelEditor import TabbedModelEditor +from sas.sascalc.fit import models +from sas.sascalc.doc_regen.makedocumentation import make_documentation, HELP_DIRECTORY_LOCATION, MAIN_PY_SRC, MAIN_DOC_SRC + + +class DocViewWindow(QtWidgets.QDialog, Ui_DocViewerWindow): + """ + Instantiates a window to view documentation using a QWebEngineViewer widget + """ + + def __init__(self, parent=None, source: Path = None): + """The DocViewWindow class is an HTML viewer built into SasView. + + :param parent: Any Qt object with a communicator that can trigger events. + :param source: The Path to the html file. + """ + super(DocViewWindow, self).__init__(parent._parent) + self.parent = parent + self.setupUi(self) + self.setWindowTitle("Documentation Viewer") + + # Necessary globals + self.source: Path = Path(source) + self.regen_in_progress: bool = False + + self.initializeSignals() # Connect signals + + self.regenerateIfNeeded() + + def initializeSignals(self): + """Initialize all external signals that will trigger events for the window.""" + self.editButton.clicked.connect(self.onEdit) + self.closeButton.clicked.connect(self.onClose) + self.parent.communicate.documentationRegeneratedSignal.connect(self.refresh) + + def onEdit(self): + """Open editor (TabbedModelEditor) window.""" + from re import findall, sub + # Extract path from QUrl + path = findall(r"(?<=file:\/\/\/).+\.html", str(self.webEngineViewer.url())) + # Test to see if we're dealing with a model html file or other html file + if "models" in path[0]: + py_base_file_name = os.path.splitext(os.path.basename(path[0]))[0] + self.editorWindow = TabbedModelEditor(parent=self.parent, + edit_only=True, + load_file=py_base_file_name, + model=True) + else: + # Remove everything before /user/ (or \user\) + file = sub(r"^.*?(?=[\/\\]user)", "", path[0]) + + # index.html is the only rst file outside the /user/ folder-- set it manually + if "index.html" in file: + file = "/index.html" + self.editorWindow = TabbedModelEditor(parent=self.parent, + edit_only=True, + load_file=file, + model=False) + self.editorWindow.show() + + def onClose(self): + """ + Close window + Keep as a separate method to allow for additional functionality when closing + """ + self.close() + + def onShow(self): + """ + Show window + Keep as a separate method to allow for additional functionality when opening + """ + self.show() + + def regenerateIfNeeded(self): + """ + Determines whether a file needs to be regenerated. + If it does, it will regenerate based off whether it is detected as SasView docs or a model. + The documentation window will open after the process of regeneration is completed. + Otherwise, simply triggers a load of the documentation window with loadHtml() + """ + user_models = Path(models.find_plugins_dir()) + html_path = HELP_DIRECTORY_LOCATION + rst_path = MAIN_DOC_SRC + rst_py_path = MAIN_PY_SRC + base_path = self.source.parent.parts + url_str = str(self.source) + + if "models" in base_path: + model_name = self.source.name.replace("html", "py") + regen_string = rst_py_path / model_name + user_model_name = user_models / model_name + + # Test if this is a user defined model, and if its HTML does not exist or is older than python source file + if os.path.isfile(user_model_name): + if self.newer(regen_string, user_model_name): + self.regenerateHtml(model_name) + + # Test to see if HTML does not exist or is older than python file + elif self.newer(regen_string, self.source): + self.regenerateHtml(model_name) + # Regenerate RST then HTML if no model file found OR if HTML is older than equivalent .py + + elif "index" in url_str: + # Regenerate if HTML is older than RST -- for index.html, which gets passed in differently because it is located in a different folder + regen_string = rst_path / str(self.source.name).replace(".html", ".rst") + # Test to see if HTML does not exist or is older than python file + if self.newer(regen_string, self.source.absolute()): + self.regenerateHtml(regen_string) + + else: + # Regenerate if HTML is older than RST + from re import sub + # Ensure that we are only using everything after and including /user/ + model_local_path = sub(r"^.*?user", "user", url_str) + html_path = html_path / model_local_path.split('#')[0] # Remove jump links + regen_string = rst_path / model_local_path.replace('.html', '.rst').split('#')[0] #Remove jump links + # Test to see if HTML does not exist or is older than python file + if self.newer(regen_string, html_path): + self.regenerateHtml(regen_string) + + if self.regen_in_progress is False: + self.loadHtml() #loads the html file specified in the source url to the QWebViewer + + @staticmethod + def newer(src: Union[Path, os.path, str], html: Union[Path, os.path, str]) -> bool: + """Compare two files to determine if a file regeneration is required. + + :param src: The ReST file that might need regeneration. + :param html: The HTML file built from the ReST file. + :return: Is the ReST file newer than the HTML file? Returned as a boolean. + """ + try: + return not os.path.exists(html) or os.path.getmtime(src) > os.path.getmtime(html) + except Exception as e: + # Catch exception for debugging + return True + + def loadHtml(self): + """Loads the HTML file specified when this python is called from another part of the program.""" + # Ensure urls are properly processed before passing into widget + url = self.processUrl() + settings = self.webEngineViewer.settings() + # Allows QtWebEngine to access MathJax and code highlighting APIs + settings.setAttribute(QtWebEngineCore.QWebEngineSettings.LocalContentCanAccessRemoteUrls, True) + settings.setAttribute(QtWebEngineCore.QWebEngineSettings.LocalContentCanAccessFileUrls, True) + self.webEngineViewer.load(url) + + # Show widget + self.onShow() + + def refresh(self): + self.webEngineViewer.reload() + + def processUrl(self) -> QtCore.QUrl: + """Process path into proper QUrl for use in QWebViewer. + + :return: A QtCore.QUrl object built using self.source. + """ + url = self.source + # Check to see if path is absolute or relative, accommodating urls from many different places + if not os.path.exists(url): + location = HELP_DIRECTORY_LOCATION / url + sas_path = Path(os.path.dirname(sys.argv[0])) + url = sas_path / location + + # Check if the URL string contains a fragment (jump link) + if '#' in url.name: + url, fragment = url.name.split('#', 1) + # Convert path to a QUrl needed for QWebViewerEngine + abs_url = QtCore.QUrl.fromLocalFile(url) + abs_url.setFragment(fragment) + else: + # Convert path to a QUrl needed for QWebViewerEngine + abs_url = QtCore.QUrl.fromLocalFile(url) + return abs_url + + def regenerateHtml(self, file_name: Union[Path, os.path, str]): + """Regenerate the documentation for the file passed to the method + + :param file_name: A file-path like object that needs regeneration. + """ + logging.info("Starting documentation regeneration...") + self.parent.communicate.documentationRegenInProgressSignal.emit() + d = threads.deferToThread(self.regenerateDocs, target=file_name) + d.addCallback(self.docRegenComplete) + self.regen_in_progress = True + + @staticmethod + def regenerateDocs(target: Union[Path, os.path, str] = None): + """Regenerates documentation for a specific file (target) in a subprocess + + :param target: A file-path like object that needs regeneration. + """ + make_documentation(target) + + def docRegenComplete(self, return_val): + """Tells Qt that regeneration of docs is done and emits signal tied to opening documentation viewer window. + This method is likely called as a thread call back, but no value is used from that callback return. + """ + self.loadHtml() + self.parent.communicate.documentationRegeneratedSignal.emit() + logging.info("Documentation regeneration completed.") + self.regen_in_progress = False diff --git a/src/sas/qtgui/Utilities/FileConverter.py b/src/sas/qtgui/Utilities/FileConverter.py index b0758040e5..34af6cef63 100644 --- a/src/sas/qtgui/Utilities/FileConverter.py +++ b/src/sas/qtgui/Utilities/FileConverter.py @@ -42,10 +42,6 @@ def __init__(self, parent=None): self.parent = parent self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) - - self.setWindowTitle("File Converter") # i,q file fields are not editable diff --git a/src/sas/qtgui/Utilities/GridPanel.py b/src/sas/qtgui/Utilities/GridPanel.py index 2cf0ae89b7..033ea34971 100644 --- a/src/sas/qtgui/Utilities/GridPanel.py +++ b/src/sas/qtgui/Utilities/GridPanel.py @@ -18,7 +18,7 @@ class BatchOutputPanel(QtWidgets.QMainWindow, Ui_GridPanelUI): ERROR_COLUMN_CAPTION = " (Err)" IS_WIN = (sys.platform == 'win32') windowClosedSignal = QtCore.Signal() - def __init__(self, parent = None, output_data=None): + def __init__(self, parent=None, output_data=None): super(BatchOutputPanel, self).__init__(parent._parent) self.setupUi(self) @@ -179,15 +179,13 @@ def addFitResults(self, results): model_name = results[0][0].model.id self.tabWidget.setTabToolTip(self.tabWidget.count()-1, model_name) self.data_dict[page_name] = results - - @classmethod - def onHelp(cls): + + def onHelp(self): """ Open a local url in the default browser """ url = "/user/qtgui/Perspectives/Fitting/fitting_help.html#batch-fit-mode" - GuiUtils.showHelp(url) - + self.parent.showHelp(url) def onPlot(self): """ @@ -257,7 +255,7 @@ def actionSaveFile(self): time_str = time.strftime("%b %d %H %M of %Y", t) default_name = "Batch_Fitting_"+time_str+".csv" - wildcard = "CSV files (*.csv);;" + wildcard = "CSV files (*.csv)" caption = 'Save As' directory = default_name filter = wildcard @@ -443,6 +441,7 @@ def __init__(self, parent = None, output_data=None): super(BatchInversionOutputPanel, self).__init__(parent._parent, output_data) _translate = QtCore.QCoreApplication.translate self.setWindowTitle(_translate("GridPanelUI", "Batch P(r) Results")) + self.parent = parent def setupTable(self, widget=None, data=None): """ @@ -492,17 +491,12 @@ def setupTable(self, widget=None, data=None): self.tblParams.resizeColumnsToContents() - @classmethod - def onHelp(cls): + def onHelp(self): """ Open a local url in the default browser """ - location = GuiUtils.HELP_DIRECTORY_LOCATION url = "/user/qtgui/Perspectives/Fitting/fitting_help.html#batch-fit-mode" - try: - webbrowser.open('file://' + os.path.realpath(location + url)) - except webbrowser.Error as ex: - logging.warning("Cannot display help. %s" % ex) + self.parent.showHelp(url) def closeEvent(self, event): """Tell the parent window the window closed""" diff --git a/src/sas/qtgui/Utilities/GuiUtils.py b/src/sas/qtgui/Utilities/GuiUtils.py index ad58761fd3..b04db50d16 100644 --- a/src/sas/qtgui/Utilities/GuiUtils.py +++ b/src/sas/qtgui/Utilities/GuiUtils.py @@ -13,6 +13,7 @@ import types import numpy from io import BytesIO +from pathlib import Path import numpy as np @@ -47,12 +48,6 @@ from sasdata.dataloader.loader import Loader -if os.path.splitext(sys.argv[0])[1].lower() != ".py": - HELP_DIRECTORY_LOCATION = "doc" -else: - HELP_DIRECTORY_LOCATION = "docs/sphinx-docs/build/html" -IMAGES_DIRECTORY_LOCATION = HELP_DIRECTORY_LOCATION + "/_images" - # This matches the ID of a plot created using FittingLogic._create1DPlot, e.g. # "5 [P(Q)] modelname" # or @@ -182,6 +177,10 @@ class Communicate(QtCore.QObject): # Update the masked ranges in fitting updateMaskedDataSignal = QtCore.Signal() + # Triggers refresh of all documentation windows + documentationRegenInProgressSignal = QtCore.Signal() + documentationRegeneratedSignal = QtCore.Signal() + def updateModelItemWithPlot(item, update_data, name="", checkbox_state=None): """ Adds a checkboxed row named "name" to QStandardItem @@ -528,19 +527,6 @@ def openLink(url): msg = "Attempt at opening an invalid URL" raise AttributeError(msg) -def showHelp(url): - """ - Open a local url in the default browser - """ - location = HELP_DIRECTORY_LOCATION + url - #WP: Added to handle OSX bundle docs - if os.path.isdir(location) == False: - sas_path = os.path.abspath(os.path.dirname(sys.argv[0])) - location = sas_path+"/"+location - try: - webbrowser.open('file://' + os.path.realpath(location)) - except webbrowser.Error as ex: - logging.warning("Cannot display help. %s" % ex) def retrieveData1d(data): """ diff --git a/src/sas/qtgui/Utilities/ModelEditor.py b/src/sas/qtgui/Utilities/ModelEditor.py index 9fa8cba822..0f2c102c7b 100644 --- a/src/sas/qtgui/Utilities/ModelEditor.py +++ b/src/sas/qtgui/Utilities/ModelEditor.py @@ -14,8 +14,6 @@ class ModelEditor(QtWidgets.QDialog, Ui_ModelEditor): def __init__(self, parent=None, is_python=True): super(ModelEditor, self).__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.is_python = is_python diff --git a/src/sas/qtgui/Utilities/PluginManager.py b/src/sas/qtgui/Utilities/PluginManager.py index 7b24e1ffbf..c1880d37dd 100644 --- a/src/sas/qtgui/Utilities/PluginManager.py +++ b/src/sas/qtgui/Utilities/PluginManager.py @@ -21,9 +21,6 @@ def __init__(self, parent=None): super(PluginManager, self).__init__(parent._parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) - self.parent = parent self.cmdDelete.setEnabled(False) self.cmdEdit.setEnabled(False) diff --git a/src/sas/qtgui/Utilities/Reports/ReportDialog.py b/src/sas/qtgui/Utilities/Reports/ReportDialog.py index 4c15d2fd0b..5c5b128070 100644 --- a/src/sas/qtgui/Utilities/Reports/ReportDialog.py +++ b/src/sas/qtgui/Utilities/Reports/ReportDialog.py @@ -23,8 +23,6 @@ def __init__(self, report_data: ReportData, parent: Optional[QtCore.QObject]=Non super().__init__(parent) self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) self.report_data = report_data @@ -64,7 +62,7 @@ def onPrint(self): try: # pylint chokes on this line with syntax-error # pylint: disable=syntax-error doesn't seem to help - document.print(printer) + document.print_(printer) except Exception as ex: # Printing can return various exceptions, let's catch them all logging.error("Print report failed with: " + str(ex)) diff --git a/src/sas/qtgui/Utilities/TabbedModelEditor.py b/src/sas/qtgui/Utilities/TabbedModelEditor.py index 8cce2613b6..1764c3b455 100644 --- a/src/sas/qtgui/Utilities/TabbedModelEditor.py +++ b/src/sas/qtgui/Utilities/TabbedModelEditor.py @@ -8,8 +8,10 @@ import traceback from PySide6 import QtWidgets, QtCore, QtGui +from pathlib import Path from sas.sascalc.fit import models +from sas.sascalc.fit.models import find_plugins_dir import sas.qtgui.Utilities.GuiUtils as GuiUtils from sas.qtgui.Utilities.UI.TabbedModelEditor import Ui_TabbedModelEditor @@ -23,28 +25,31 @@ class TabbedModelEditor(QtWidgets.QDialog, Ui_TabbedModelEditor): Once the model is defined, it can be saved as a plugin. """ # Signals for intertab communication plugin -> editor - def __init__(self, parent=None, edit_only=False): + def __init__(self, parent=None, edit_only=False, model=False, load_file=None): super(TabbedModelEditor, self).__init__(parent._parent) self.parent = parent self.setupUi(self) - # disable the context help icon - self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint) - # globals self.filename = "" + self.is_python = True self.window_title = self.windowTitle() self.edit_only = edit_only + self.load_file = load_file.lstrip("//") if load_file else None + self.model = model self.is_modified = False self.label = None - self.builtinmodels = self.allBuiltinModels() + self.file_to_regenerate = "" self.addWidgets() self.addSignals() + if self.load_file is not None: + self.onLoad(at_launch=True) + def addWidgets(self): """ Populate tabs with widgets @@ -70,6 +75,9 @@ def addWidgets(self): self.plugin_widget.blockSignals(True) # and hide the tab/widget itself self.tabWidget.removeTab(0) + + if self.model is not None: + self.cmdLoad.setText("Load file...") def addSignals(self): """ @@ -113,24 +121,43 @@ def closeEvent(self, event): return event.accept() - def onLoad(self): + def onLoad(self, at_launch=False): """ - Loads a model plugin file + Loads a model plugin file. at_launch is value of whether to attempt a load of a file from launch of the widget or not """ + self.is_python = True # By default assume the file you load is python + if self.is_modified: saveCancelled = self.saveClose() if saveCancelled: return self.is_modified = False - plugin_location = models.find_plugins_dir() - filename = QtWidgets.QFileDialog.getOpenFileName( - self, - 'Open Plugin', - plugin_location, - 'SasView Plugin Model (*.py)', - None, - QtWidgets.QFileDialog.DontUseNativeDialog)[0] + # If we are loading in a file at the launch of the editor instead of letting the user pick, we need to process the HTML location from + # the documentation viewer into the filepath for its corresponding RST + if at_launch: + from sas.sascalc.doc_regen.makedocumentation import MAIN_DOC_SRC + user_models = find_plugins_dir() + user_model_name = user_models + self.load_file + ".py" + + if self.model is True: + # Find location of model .py files and load from that location + if os.path.isfile(user_model_name): + filename = user_model_name + else: + filename = MAIN_DOC_SRC / "user" / "models" / "src" / (self.load_file + ".py") + else: + filename = MAIN_DOC_SRC / self.load_file.replace(".html", ".rst") + self.is_python = False + else: + plugin_location = models.find_plugins_dir() + filename = QtWidgets.QFileDialog.getOpenFileName( + self, + 'Open Plugin', + plugin_location, + 'SasView Plugin Model (*.py)', + None, + QtWidgets.QFileDialog.DontUseNativeDialog)[0] # Load the file if not filename: @@ -140,7 +167,8 @@ def onLoad(self): # remove c-plugin tab, if present. if self.tabWidget.count()>1: self.tabWidget.removeTab(1) - self.loadFile(filename) + self.file_to_regenerate = filename + self.loadFile(str(filename)) def allBuiltinModels(self): """ @@ -163,31 +191,35 @@ def loadFile(self, filename): self.editor_widget.setEnabled(True) self.editor_widget.blockSignals(False) self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True) - self.filename = filename - display_name, _ = os.path.splitext(os.path.basename(filename)) - self.setWindowTitle(self.window_title + " - " + display_name) + self.filename = Path(filename) + display_name = self.filename.stem + if not self.model: + self.setWindowTitle(self.window_title + " - " + display_name) + else: + self.setWindowTitle("Documentation Editor" + " - " + display_name) # Name the tab with .py filename - display_name = os.path.basename(filename) self.tabWidget.setTabText(0, display_name) - # Check the validity of loaded model - error_line = self.checkModel(plugin_text) - if error_line > 0: - # select bad line - cursor = QtGui.QTextCursor(self.editor_widget.txtEditor.document().findBlockByLineNumber(error_line-1)) - self.editor_widget.txtEditor.setTextCursor(cursor) - return + + # Check the validity of loaded model if the model is python + if self.is_python is True: + error_line = self.checkModel(plugin_text) + if error_line > 0: + # select bad line + cursor = QtGui.QTextCursor(self.editor_widget.txtEditor.document().findBlockByLineNumber(error_line-1)) + self.editor_widget.txtEditor.setTextCursor(cursor) + return # In case previous model was incorrect, change the frame colours back self.editor_widget.txtEditor.setStyleSheet("") self.editor_widget.txtEditor.setToolTip("") # See if there is filename.c present - c_path = self.filename.replace(".py", ".c") - if not os.path.isfile(c_path): return + c_path = self.filename.parent / self.filename.name.replace(".py", ".c") + if not c_path.exists() or ".rst" in c_path.name: return # add a tab with the same highlighting - display_name = os.path.basename(c_path) + c_display_name = c_path.name self.c_editor_widget = ModelEditor(self, is_python=False) - self.tabWidget.addTab(self.c_editor_widget, display_name) + self.tabWidget.addTab(self.c_editor_widget, c_display_name) # Read in the file and set in on the widget with open(c_path, 'r', encoding="utf-8") as plugin: self.c_editor_widget.txtEditor.setPlainText(plugin.read()) @@ -345,8 +377,8 @@ def checkModel(self, model_str): logging.error(traceback_to_show) # Set the status bar message + # GuiUtils.Communicate.statusBarUpdateSignal.emit("Model check failed") self.parent.communicate.statusBarUpdateSignal.emit("Model check failed") - # Put a thick, red border around the mini-editor self.tabWidget.currentWidget().txtEditor.setStyleSheet("border: 5px solid red") # last_lines = traceback.format_exc().split('\n')[-4:] @@ -413,7 +445,7 @@ def updateFromEditor(self): assert(filename != "") # Retrieve model string model_str = self.getModel()['text'] - if w.is_python: + if w.is_python and self.is_python: error_line = self.checkModel(model_str) if error_line > 0: # select bad line @@ -433,9 +465,17 @@ def updateFromEditor(self): self.parent.communicate.customModelDirectoryChanged.emit() # notify the user - msg = filename + " successfully saved." + msg = str(filename) + " successfully saved." self.parent.communicate.statusBarUpdateSignal.emit(msg) logging.info(msg) + self.regenerateDocumentation() + + def regenerateDocumentation(self): + """ + Defer to subprocess the documentation regeneration process + """ + if self.parent.helpWindow: + self.parent.helpWindow.regenerateHtml(self.filename) def canWriteModel(self, model=None, full_path=""): """ @@ -516,7 +556,7 @@ def generateModel(self, model, fname): 'name': name, 'title': 'User model for ' + name, 'description': desc_str, - 'date': datetime.datetime.now().strftime('%YYYY-%mm-%dd'), + 'date': datetime.datetime.now().strftime('%Y-%m-%d'), } # Write out parameters diff --git a/src/sas/qtgui/Utilities/UI/DocRegenInProgress.ui b/src/sas/qtgui/Utilities/UI/DocRegenInProgress.ui new file mode 100644 index 0000000000..e96b131d03 --- /dev/null +++ b/src/sas/qtgui/Utilities/UI/DocRegenInProgress.ui @@ -0,0 +1,34 @@ + + + DocRegenProgress + + + + 0 + 0 + 378 + 100 + + + + Form + + + + + + TextLabel + + + Qt::AlignCenter + + + + + + + + + + + diff --git a/src/sas/qtgui/Utilities/UI/DocViewWidgetUI.ui b/src/sas/qtgui/Utilities/UI/DocViewWidgetUI.ui new file mode 100644 index 0000000000..a201dcefa2 --- /dev/null +++ b/src/sas/qtgui/Utilities/UI/DocViewWidgetUI.ui @@ -0,0 +1,97 @@ + + + DocViewerWindow + + + Qt::NonModal + + + + 0 + 0 + 983 + 832 + + + + + 0 + 0 + + + + + 30 + 30 + + + + docViewerWindow + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 100 + 0 + + + + Edit + + + + + + + + 100 + 0 + + + + Close + + + + + + + + + + 0 + 0 + + + + + + + + + QWebEngineView + QWidget +
qwebengineview.h
+ 1 +
+
+ + +
diff --git a/src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py b/src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py index 46ccc67c06..1f11b58df8 100644 --- a/src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py +++ b/src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py @@ -51,7 +51,7 @@ def testDefaults(self, widget): def testOnPrint(self, widget, mocker): ''' Printing the report ''' document = widget.txtBrowser.document() - mocker.patch.object(document, 'print') + mocker.patch.object(document, 'print_') # test rejected dialog mocker.patch.object(QtPrintSupport.QPrintDialog, 'exec_', return_value=QtWidgets.QDialog.Rejected) @@ -60,7 +60,7 @@ def testOnPrint(self, widget, mocker): widget.onPrint() # Assure printing was not done - assert not document.print.called + assert not document.print_.called # test accepted dialog mocker.patch.object(QtPrintSupport.QPrintDialog, 'exec_', return_value=QtWidgets.QDialog.Accepted) diff --git a/src/sas/sascalc/doc_regen/__init__.py b/src/sas/sascalc/doc_regen/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/sascalc/doc_regen/makedocumentation.py b/src/sas/sascalc/doc_regen/makedocumentation.py new file mode 100644 index 0000000000..f7941912f6 --- /dev/null +++ b/src/sas/sascalc/doc_regen/makedocumentation.py @@ -0,0 +1,221 @@ +""" +Creates documentation from .py files +""" +import os +import sys +import subprocess +import shutil + +from os.path import join, abspath, dirname, basename +from pathlib import Path +from typing import Union + +from sas.sascalc.fit import models +from sas.system.version import __version__ +from sas.system.user import get_user_dir + +# Path constants related to the directories and files used in documentation regeneration processes +USER_DIRECTORY = Path(get_user_dir()) +USER_DOC_BASE = USER_DIRECTORY / "doc" +USER_DOC_SRC = USER_DOC_BASE / str(__version__) +USER_DOC_LOG = USER_DOC_SRC / 'log' +DOC_LOG = USER_DOC_LOG / 'output.log' +MAIN_DOC_SRC = USER_DOC_SRC / "source-temp" +MAIN_BUILD_SRC = USER_DOC_SRC / "build" +MAIN_PY_SRC = MAIN_DOC_SRC / "user" / "models" / "src" +ABSOLUTE_TARGET_MAIN = Path(MAIN_DOC_SRC) +PLUGIN_PY_SRC = Path(models.find_plugins_dir()) + +HELP_DIRECTORY_LOCATION = MAIN_BUILD_SRC / "html" +RECOMPILE_DOC_LOCATION = HELP_DIRECTORY_LOCATION +IMAGES_DIRECTORY_LOCATION = HELP_DIRECTORY_LOCATION / "_images" +SAS_DIR = Path(sys.argv[0]).parent + +# Find the original documentation location, depending on where the files originate from +if os.path.exists(SAS_DIR / "doc"): + # This is the directory structure for the installed version of SasView (primary for times when both exist) + BASE_DIR = SAS_DIR / "doc" + ORIGINAL_DOCS_SRC = BASE_DIR / "source" +else: + # This is the directory structure for developers + BASE_DIR = SAS_DIR / "docs" / "sphinx-docs" + ORIGINAL_DOCS_SRC = BASE_DIR / "source-temp" + +ORIGINAL_DOC_BUILD = BASE_DIR / "build" + + +def create_user_files_if_needed(): + """Create user documentation directories if necessary and copy built docs there.""" + if not USER_DOC_BASE.exists(): + os.mkdir(USER_DOC_BASE) + if not USER_DOC_SRC.exists(): + os.mkdir(USER_DOC_SRC) + if not USER_DOC_LOG.exists(): + os.mkdir(USER_DOC_LOG) + if not DOC_LOG.exists(): + with open(DOC_LOG, "w") as f: + # Write an empty file to eliminate any potential future file creation conflicts + pass + if not MAIN_DOC_SRC.exists(): + shutil.copytree(ORIGINAL_DOCS_SRC, MAIN_DOC_SRC) + if not MAIN_BUILD_SRC.exists(): + shutil.copytree(ORIGINAL_DOC_BUILD, MAIN_BUILD_SRC) + + +def get_py(directory: Union[Path, os.path, str]) -> list[Union[Path, os.path, str]]: + """Find all python files within a directory that are meant for sphinx and return those file-paths as a list. + + :param directory: A file path-like object to find all python files contained there-in. + :return: A list of python files found. + """ + for root, dirs, files in os.walk(directory): + # Only include python files not starting in '_' (pycache not included) + PY_FILES = [join(directory, string) for string in files if not string.startswith("_") and string.endswith(".py")] + return PY_FILES + + +def get_main_docs() -> list[Union[Path, os.path, str]]: + """Generates a list of all .py files to be passed into compiling functions found in the main source code, as well as + in the user plugin model directory. + + :return: A list of python files """ + # The order in which these are added is important. if ABSOLUTE_TARGET_PLUGINS goes first, then we're not compiling the .py file stored in .sasview/plugin_models + TARGETS = get_py(ABSOLUTE_TARGET_MAIN) + get_py(PLUGIN_PY_SRC) + base_targets = [basename(string) for string in TARGETS] + + # Removes duplicate instances of the same file copied from plugins folder to source-temp/user/models/src/ + for file in TARGETS: + if base_targets.count(basename(file)) >= 2: + TARGETS.remove(file) + base_targets.remove(basename(file)) + + return TARGETS + + +def call_regenmodel(filepath: Union[Path, os.path, str, list], regen_py: str): + """Runs regenmodel.py or regentoc.py (specified in parameter regen_py) with all found PY_FILES. + + :param filepath: A file-path like object or list of file-path like objects to regenerate. + :param regen_py: The regeneration python file to call (regenmodel.py or regentoc.py) + """ + REGENMODEL = abspath(dirname(__file__)) + "/" + regen_py + # Initialize command to be executed + command = [ + sys.executable, + REGENMODEL, + ] + # Append each filepath to command individually if passed in many files + if isinstance(filepath, list): + for string in filepath: + command.append(string) + else: + command.append(filepath) + subprocess.run(command) + + +def generate_html(single_file: Union[Path, os.path, str, list] = "", rst: bool = False): + """Generates HTML from an RST using a subprocess. Based off of syntax provided in Makefile found in /sasmodels/doc/ + + :param single_file: A file name that needs the html regenerated. + :param rst: Boolean to declare the rile an rst-like file. + """ + # Clear existing log file + if DOC_LOG.exists(): + with open(DOC_LOG, "r+") as f: + f.truncate(0) + DOCTREES = MAIN_BUILD_SRC / "doctrees" + if rst is False: + single_rst = USER_DOC_SRC / "user" / "models" / single_file.replace('.py', '.rst') + else: + single_rst = Path(single_file) + rst_path = list(single_rst.parts) + rst_str = "/".join(rst_path) + if rst_str.endswith("models/") or rst_str.endswith("user/"): + # (re)sets value to empty string if nothing was entered + single_rst = "" + os.environ['SAS_NO_HIGHLIGHT'] = '1' + command = [ + sys.executable, + "-m", + "sphinx", + "-d", + DOCTREES, + "-D", + "latex_elements.papersize=letter", + MAIN_DOC_SRC, + HELP_DIRECTORY_LOCATION, + single_rst, + ] + try: + # Try removing empty arguments + command.remove("") + except: + pass + try: + with open(DOC_LOG, "w") as f: + subprocess.check_call(command, stdout=f) + except Exception as e: + # Logging debug + print(e) + + +def call_all_files(): + """A master method to regenerate all known documentation.""" + from sas.sascalc.doc_regen.regentoc import generate_toc + TARGETS = get_main_docs() + for file in TARGETS: + # easiest for regenmodel.py if files are passed in individually + call_regenmodel(file, "regenmodel.py") + # regentoc.py requires files to be passed in bulk or else LOTS of unexpected behavior + generate_toc(TARGETS) + + +def call_one_file(file: Union[Path, os.path, str]): + """A master method to regenerate a single file that is passed to the method. + + :param file: A file name that needs the html regenerated. + """ + from sas.sascalc.doc_regen.regentoc import generate_toc + TARGETS = get_main_docs() + NORM_TARGET = join(ABSOLUTE_TARGET_MAIN, file) + MODEL_TARGET = join(MAIN_PY_SRC, file) + # Determines if a model's source .py file from /user/models/src/ should be used or if the file from /plugin-models/ should be used + if os.path.exists(NORM_TARGET) and os.path.exists(MODEL_TARGET): + if os.path.getmtime(NORM_TARGET) < os.path.getmtime(MODEL_TARGET): + file_call_path = MODEL_TARGET + else: + file_call_path = NORM_TARGET + elif not os.path.exists(NORM_TARGET): + file_call_path = MODEL_TARGET + else: + file_call_path = NORM_TARGET + call_regenmodel(file_call_path, "regenmodel.py") # There might be a cleaner way to do this but this approach seems to work and is fairly minimal + generate_toc(TARGETS) + + +def make_documentation(target: Union[Path, os.path, str] = "."): + """Similar to call_one_file, but will fall back to calling all files and regenerating everything if an error occurs. + + :param target: A file name that needs the html regenerated. + """ + # Ensure target is a path object + if target: + target = Path(target) + try: + if ".rst" in target.name: + # Generate only HTML if passed in file is an RST + generate_html(target, rst=True) + else: + # Tries to generate reST file for only one doc, if no doc is specified then will try to regenerate all reST + # files. Time saving measure. + call_one_file(target) + generate_html(target) + except Exception as e: + call_all_files() # Regenerate all RSTs + generate_html() # Regenerate all HTML + + +if __name__ == "__main__": + create_user_files_if_needed() + target = sys.argv[1] + make_documentation(target) diff --git a/src/sas/sascalc/doc_regen/regenmodel.py b/src/sas/sascalc/doc_regen/regenmodel.py new file mode 100644 index 0000000000..6a3774cf61 --- /dev/null +++ b/src/sas/sascalc/doc_regen/regenmodel.py @@ -0,0 +1,462 @@ +""" +Generate ReST docs with figures for each model. + +usage: python genmodels.py path/to/model1.py ... + +Output is placed in the directory model, with *model1.py* producing +*model/model1.rst* and *model/img/model1.png*. + +Set the environment variable SASMODELS_BUILD_CACHE to the path for the +build cache. That way the figure will only be regenerated if the kernel +code has changed. This is useful on a build server where the environment +is created from scratch each time. Be sure to clear the cache from time +to time so it doesn't get too large. Also, the cache will need to be +cleared if the image generation is updated, either because matplotib +is upgraded or because this file changes. To accomodate both these +conditions set the path as the following in your build script:: + + SASMODELS_BUILD_CACHE=/tmp/sasbuild_$(shasum genmodel.py | cut -f 1 -d" ") + +Putting the cache in /tmp allows temp-reaper to clean it up automatically. +Putting the sha1 hash of this file in the cache directory name means that +a new cache will be created whenever the text of this file has changed, +even if it is downloaded from a different branch of the repo. + +The release build should not use any caching. + +This program uses multiprocessing to build the jobs in parallel. Use +the following:: + + SASMODELS_BUILD_CPUS=0 # one per processor + SASMODELS_BUILD_CPUS=1 # don't use multiprocessing + SASMODELS_BUILD_CPUS=n # use n processes + +Note that sasmodels with OpenCL is very good at using all the processors +when generating the model plot, so you should only use a small amount +of parallelism (maybe 2-4 processes), allowing matplotlib to run in +parallel. More parallelism won't help, and may overwhelm the GPU if you +have one. +""" + +import sys +import os +from os.path import basename, dirname, realpath, join as joinpath, exists +import math +import re +import shutil +import argparse +import subprocess + +import matplotlib.axes +from os import makedirs + +import numpy as np + +from sasmodels import generate, core +from sasmodels.direct_model import DirectModel, call_profile +from sasmodels.data import empty_data1D, empty_data2D + +from sas.sascalc.doc_regen.makedocumentation import MAIN_DOC_SRC + +from typing import Dict, Any +from sasmodels.kernel import KernelModel +from sasmodels.modelinfo import ModelInfo + +# Destination directory for model docs +TARGET_DIR = MAIN_DOC_SRC / "user" / "models" + + +def plot_1d(model: KernelModel, opts: Dict[str, Any], ax: matplotlib.axes.Axes): + """Create a 1-D image based on the model.""" + q_min, q_max, nq = opts['q_min'], opts['q_max'], opts['nq'] + q_min = math.log10(q_min) + q_max = math.log10(q_max) + q = np.logspace(q_min, q_max, nq) + data = empty_data1D(q) + calculator = DirectModel(data, model) + Iq1D = calculator() + + ax.plot(q, Iq1D, color='blue', lw=2, label=model.info.name) + ax.set_xlabel(r'$Q \/(\AA^{-1})$') + ax.set_ylabel(r'$I(Q) \/(\mathrm{cm}^{-1})$') + ax.set_xscale(opts['xscale']) + ax.set_yscale(opts['yscale']) + + +def plot_2d(model: KernelModel, opts: Dict[str, Any], ax: matplotlib.axes.Axes): + """Create a 2-D image based on the model.""" + qx_max, nq2d = opts['qx_max'], opts['nq2d'] + q = np.linspace(-qx_max, qx_max, nq2d) # type: np.ndarray + data2d = empty_data2D(q, resolution=0.0) + calculator = DirectModel(data2d, model) + Iq2D = calculator() #background=0) + Iq2D = Iq2D.reshape(nq2d, nq2d) + if opts['zscale'] == 'log': + Iq2D = np.log(np.clip(Iq2D, opts['vmin'], np.inf)) + ax.imshow(Iq2D, interpolation='nearest', aspect=1, origin='lower', + extent=[-qx_max, qx_max, -qx_max, qx_max], cmap=opts['colormap']) + ax.set_xlabel(r'$Q_x \/(\AA^{-1})$') + ax.set_ylabel(r'$Q_y \/(\AA^{-1})$') + + +def plot_profile_inset(model_info: ModelInfo, ax: matplotlib.axes.Axes): + """Plot 1D radial profile as inset plot.""" + import matplotlib.pyplot as plt + p = ax.get_position() + width, height = 0.4*(p.x1-p.x0), 0.4*(p.y1-p.y0) + left, bottom = p.x1-width, p.y1-height + inset = plt.gcf().add_axes([left, bottom, width, height]) + x, y, labels = call_profile(model_info) + inset.plot(x, y, '-') + inset.locator_params(nbins=4) + #inset.set_xlabel(labels[0]) + #inset.set_ylabel(labels[1]) + inset.text(0.99, 0.99, "profile", + horizontalalignment="right", + verticalalignment="top", + transform=inset.transAxes) + + +def figfile(model_info: ModelInfo) -> str: + """Generates a standard file name for generated model figures based on the model info.""" + return model_info.id + '_autogenfig.png' + + +def make_figure(model_info: ModelInfo, opts: Dict[str, Any]): + """Generate the figure file to include in the docs.""" + import matplotlib.pyplot as plt + + print("Build model") + model = core.build_model(model_info) + + print("Set up figure") + fig_height = 3.0 # in + fig_left = 0.6 # in + fig_right = 0.5 # in + fig_top = 0.6*0.25 # in + fig_bottom = 0.6*0.75 + if model_info.parameters.has_2d: + plot_height = fig_height - (fig_top+fig_bottom) + plot_width = plot_height + fig_width = 2*(plot_width + fig_left + fig_right) + aspect = (fig_width, fig_height) + ratio = aspect[0]/aspect[1] + ax_left = fig_left/fig_width + ax_bottom = fig_bottom/fig_height + ax_height = plot_height/fig_height + ax_width = ax_height/ratio # square axes + fig = plt.figure(figsize=aspect) + ax2d = fig.add_axes([0.5+ax_left, ax_bottom, ax_width, ax_height]) + print("2D plot") + plot_2d(model, opts, ax2d) + ax1d = fig.add_axes([ax_left, ax_bottom, ax_width, ax_height]) + print("1D plot") + plot_1d(model, opts, ax1d) + #ax.set_aspect('square') + else: + plot_height = fig_height - (fig_top+fig_bottom) + plot_width = (1+np.sqrt(5))/2*fig_height + fig_width = plot_width + fig_left + fig_right + ax_left = fig_left/fig_width + ax_bottom = fig_bottom/fig_height + ax_width = plot_width/fig_width + ax_height = plot_height/fig_height + aspect = (fig_width, fig_height) + fig = plt.figure(figsize=aspect) + ax1d = fig.add_axes([ax_left, ax_bottom, ax_width, ax_height]) + print("1D plot") + plot_1d(model, opts, ax1d) + + if model_info.profile: + print("Profile inset") + plot_profile_inset(model_info, ax1d) + + print("Save") + # Save image in model/img + makedirs(joinpath(TARGET_DIR, 'img'), exist_ok=True) + path = joinpath(TARGET_DIR, 'img', figfile(model_info)) + plt.savefig(path, bbox_inches='tight') + plt.close(fig) + #print("figure saved in",path) + + +def newer(src: str, dst: str) -> bool: + """Return a boolean whether the src file is newer than the dst file.""" + return not exists(dst) or os.path.getmtime(src) > os.path.getmtime(dst) + + +def copy_if_newer(src: str, dst: str): + """Copy from *src* to *dst* if *src* is newer or *dst* doesn't exist.""" + if newer(src, dst): + makedirs(dirname(dst), exist_ok=True) + shutil.copy2(src, dst) + + +def link_sources(model_info: ModelInfo) -> str: + """Add link to model sources from the doc tree.""" + # List source files in order of dependency. + sources = generate.model_sources(model_info) if model_info.source else [] + sources.append(model_info.basefile) + + # Copy files to src dir under models directory. Need to do this + # because sphinx can't link to an absolute path. + dst = joinpath(TARGET_DIR, "src") + for path in sources: + copy_if_newer(path, joinpath(dst, basename(path))) + + # Link to local copy of the files in reverse order of dependency + targets = [basename(path) for path in sources] + downloads = [":download:`%s `"%(filename, filename) + for filename in reversed(targets)] + + # Could do syntax highlighting on the model files by creating a rst file + # beside each source file named containing source file with + # + # src/path.rst: + # + # .. {{ path.replace('/','_') }}: + # + # .. literalinclude:: {{ src/path }} + # :language: {{ "python" if path.endswith('.py') else "c" }} + # :linenos: + # + # and link to it using + # + # colors = [":ref:`%s`"%(path.replace('/','_')) for path in sources] + # + # Probably need to dump all the rst files into an index.rst to build them. + + # Link to github repo (either the tagged sasmodels version or master) + #url = "https://github.com/SasView/sasmodels/blob/v%s"%sasmodels.__version__ + #url = "https://github.com/SasView/sasmodels/blob/master"%sasmodels.__version__ + #links = ["`%s <%s/sasmodels/models/%s>`_"%(path, url, path) for path in sources] + + sep = "\n$\\ \\star\\ $ " # bullet + body = "\n**Source**\n" + #body += "\n" + sep.join(links) + "\n\n" + body += "\n" + sep.join(downloads) + "\n\n" + return body + + +def gen_docs(model_info: ModelInfo, outfile: str): + """Generate the doc string with the figure inserted before the references.""" + # Load the doc string from the module definition file and store it in rst + docstr = generate.make_doc(model_info) + + # Auto caption for figure + captionstr = '\n' + captionstr += '.. figure:: img/' + figfile(model_info) + '\n' + captionstr += '\n' + if model_info.parameters.has_2d: + captionstr += ' 1D and 2D plots corresponding to the default parameters of the model.\n' + else: + captionstr += ' 1D plot corresponding to the default parameters of the model.\n' + captionstr += '\n' + + # Add figure reference and caption to documentation (at end, before References) + pattern = r'\*\*REFERENCE' + match = re.search(pattern, docstr.upper()) + + sources = link_sources(model_info) + + insertion = captionstr + sources + + if match: + docstr1 = docstr[:match.start()] + docstr2 = docstr[match.start():] + docstr = docstr1 + insertion + docstr2 + else: + print('------------------------------------------------------------------') + print('References NOT FOUND for model: ', model_info.id) + print('------------------------------------------------------------------') + docstr += insertion + + with open(outfile, 'w', encoding='utf-8') as fid: + fid.write(docstr) + + +def make_figure_cached(model_info: ModelInfo, opts: dict[str, Any]): + """Cache sasmodels figures between independent builds. + + To enable caching, set *SASMODELS_BUILD_CACHE* in the environment. + A (mostly) unique key will be created based on model source and opts. If + the png file matching that key exists in the cache it will be copied into + the documentation tree, otherwise a new png file will be created and copied + into the cache. + + Be sure to clear the cache from time to time. Even though the model + source + """ + import hashlib + + # check if we are caching + cache_dir = os.environ.get('SASMODELS_BUILD_CACHE', None) + if cache_dir is None: + print("Nothing cashed, creating...") + make_figure(model_info, opts) + print("Made a figure") + return + + # TODO: changing default parameters won't trigger a rebuild. + # build cache identifier + if callable(model_info.Iq): + with open(model_info.filename) as fid: + source = fid.read() + else: + source = generate.make_source(model_info)["dll"] + pars = str(sorted(model_info.parameters.defaults.items())) + code = source + pars + str(sorted(opts.items())) + hash_id = hashlib.sha1(code.encode('utf-8')).hexdigest() + + # copy from cache or generate and copy to cache + png_name = figfile(model_info) + target_fig = joinpath(TARGET_DIR, 'img', png_name) + cache_fig = joinpath(cache_dir, ".".join((png_name, hash_id, "png"))) + if exists(cache_fig): + copy_file(cache_fig, target_fig) + else: + #print(f"==>regenerating png {model_info.id}") + make_figure(model_info, opts) + copy_file(target_fig, cache_fig) + + +def copy_file(src: str, dst: str): + """Copy from *src* to *dst*, making the destination directory if needed. + + :param src: The original file name to be copied. + :param dst: The destination to copy the file. + """ + if not exists(dst): + path = dirname(dst) + makedirs(path, exist_ok=True) + shutil.copy2(src, dst) + elif os.path.getmtime(src) > os.path.getmtime(dst): + shutil.copy2(src, dst) + + +def process_model(py_file: str, force=False) -> str: + """Generate doc file and image file for the given model definition file. + + Does nothing if the corresponding rst file is newer than *py_file*. + Also checks the timestamp on the *genmodel.py* program (*__file__*), + since we want to rerun the generator on all files if we change this + code. + + If *force* then generate the rst file regardless of time stamps. + + :param py_file: The python model file that will be processed into ReST using sphinx. + :param force: Regardless of the ReST file age, relative to the python file, force the regeneration. + """ + rst_file = joinpath(TARGET_DIR, basename(py_file).replace('.py', '.rst')) + if not (force or newer(py_file, rst_file) or newer(__file__, rst_file)): + #print("skipping", rst_file) + return rst_file + + # Load the model file + model_info = core.load_model_info(py_file) + if model_info.basefile is None: + model_info.basefile = py_file + + # Plotting ranges and options + PLOT_OPTS = { + 'xscale' : 'log', + 'yscale' : 'log' if not model_info.structure_factor else 'linear', + 'zscale' : 'log' if not model_info.structure_factor else 'linear', + 'q_min' : 0.001, + 'q_max' : 1.0, + 'nq' : 1000, + 'nq2d' : 1000, + 'vmin' : 1e-3, # floor for the 2D data results + 'qx_max' : 0.5, + #'colormap' : 'gist_ncar', + 'colormap' : 'nipy_spectral', + #'colormap' : 'jet', + } + + # Generate the RST file and the figure. Order doesn't matter. + print("generating rst", rst_file) + print("1: docs") + gen_docs(model_info, rst_file) + print("2: figure", end='') + if force: + print() + make_figure(model_info, PLOT_OPTS) + else: + print(" (cached)") + make_figure_cached(model_info, PLOT_OPTS) + print("Done process_model") + + return rst_file + + +def run_sphinx(rst_files: list[str], output: list[str]): + """Use sphinx to build *rst_files*, storing the html in *output*. + + :param rst_files: A list of ReST file names/paths to be processed into HTML. + :param output: A list of HTML names/paths mapping to the ReST file names. + """ + + print("Building index...") + conf_dir = dirname(realpath(__file__)) + with open(joinpath(TARGET_DIR, 'index.rst'), 'w') as fid: + fid.write(".. toctree::\n\n") + for path in rst_files: + fid.write(" %s\n"%basename(path)) + + print("Running sphinx command...") + command = [ + sys.executable, + "-m", "sphinx", + "-c", conf_dir, + TARGET_DIR, + output, + ] + process = subprocess.Popen(command, shell=False, stdout=subprocess.PIPE) + + # Make sure we can see process output in real time + while True: + output = process.stdout.readline() + if process.poll() is not None: + break + if output: + print(output.strip()) + + +def main(): + """Process files listed on the command line via :func:`process_model`.""" + import matplotlib + matplotlib.use('Agg') + global TARGET_DIR + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--cpus", type=int, default=-1, metavar="n", + help="number of cpus to use in build") + parser.add_argument("-f", "--force", action="store_true", + help="force rebuild (serial only)") + parser.add_argument("-r", "--rst", default=TARGET_DIR, metavar="path", + help="path for the rst files") + parser.add_argument("-b", "--build", default="html", metavar="path", + help="path for the html files (if sphinx build)") + parser.add_argument("-s", "--sphinx", action="store_true", + help="build html docs for the model files") + parser.add_argument("files", nargs="+", + help="model files ") + args = parser.parse_args() + + TARGET_DIR = os.path.expanduser(args.rst) + if not os.path.exists(TARGET_DIR) and not args.sphinx: + print("build directory %r does not exist"%TARGET_DIR) + sys.exit(1) + makedirs(TARGET_DIR, exist_ok=True) + + print("** 'Normal' processing **") + rst_files = [process_model(py_file, args.force) + for py_file in args.files] + print("normal .rst file processing complete") + + if args.sphinx: + print("running sphinx") + run_sphinx(rst_files, args.build) + + +if __name__ == "__main__": + main() diff --git a/src/sas/sascalc/doc_regen/regentoc.py b/src/sas/sascalc/doc_regen/regentoc.py new file mode 100644 index 0000000000..677eb506cd --- /dev/null +++ b/src/sas/sascalc/doc_regen/regentoc.py @@ -0,0 +1,148 @@ +from __future__ import print_function + +import sys +# make sure sasmodels is on the path +sys.path.append('..') + +from os import mkdir +from os.path import basename, exists, join as joinpath +from sasmodels.core import load_model_info +from sas.sascalc.doc_regen.makedocumentation import MAIN_DOC_SRC + +try: + from typing import Optional, IO, BinaryIO, List, Dict +except ImportError: + pass +else: + from sasmodels.modelinfo import ModelInfo + +TEMPLATE = """\ +.. + Generated from src/sas/sascalc/doc-regen/regentoc.py -- DO NOT EDIT -- + +.. _%(label)s: + +%(bar)s +%(title)s +%(bar)s + +.. toctree:: + +""" +MODEL_TOC_PATH = MAIN_DOC_SRC / "user/qtgui/Perspectives/Fitting/models" + + +def _make_category(category_name: str, label: str, title: str, parent: Optional[BinaryIO] = None) -> IO: + """Generate the base ReST file for a specific model category.""" + file = open(joinpath(MODEL_TOC_PATH, category_name+".rst"), "w") + file.write(TEMPLATE % {'label': label, 'title': title, 'bar': '*'*len(title)}) + if parent: + _add_subcategory(category_name, parent) + return file + + +def _add_subcategory(category_name: str, parent: BinaryIO): + """Add a subcategory to a ReST file.""" + parent.write(" %s.rst\n"%category_name) + + +def _add_model(file: IO[str], model_name: str): + """Add a model file name to an already open file.""" + file.write(" /user/models/%s.rst\n"%model_name) + + +def _maybe_make_category(category: str, models: list[str], cat_files: dict[str, BinaryIO], model_toc: BinaryIO): + """Add a category to the list of categories, assuming it isn't already in the list.""" + if category not in cat_files: + print("Unexpected category %s containing"%category, models, file=sys.stderr) + title = category.capitalize()+" Functions" + cat_files[category] = _make_category(category, category, title, model_toc) + +def generate_toc(model_files: list[str]): + """Generate the doc tree and ReST files from a list of model files.""" + if not model_files: + print("gentoc needs a list of model files", file=sys.stderr) + + # find all categories + category = {} # type: Dict[str, List[str]] + for item in model_files: + # assume model is in sasmodels/models/name.py, and ignore the full path + # NOTE: No longer use shortened pathname for model because we also pull in models from .sasview/plugin_models + model_name = basename(item)[:-3] + if model_name.startswith('_'): + continue + model_info = load_model_info(item) + if model_info.category is None: + print("Missing category for", item, file=sys.stderr) + else: + category.setdefault(model_info.category, []).append(model_name) + + # Check category names + for k, v in category.items(): + if len(v) == 1: + print("Category %s contains only %s"%(k, v[0]), file=sys.stderr) + + # Generate category files for the table of contents. + # Initially we had "shape functions" as an additional TOC level, but we + # have revised it so that the individual shape categories now go at + # the top level. Judicious rearrangement of comments will make the + # "shape functions" level reappear. + # We are forcing shape-independent, structure-factor and custom-models + # to come at the end of the TOC. All other categories will come in + # alphabetical order before them. + + if not exists(MODEL_TOC_PATH): + mkdir(MODEL_TOC_PATH) + model_toc = _make_category( + 'index', 'Models', 'Model Functions') + #shape_toc = _make_category( + # 'shape', 'Shapes', 'Shape Functions', model_toc) + free_toc = _make_category( + 'shape-independent', 'Shape-independent', + 'Shape-Independent Functions') + struct_toc = _make_category( + 'structure-factor', 'Structure-factor', 'Structure Factors') + #custom_toc = _make_category( + # 'custom-models', 'Custom-models', 'Custom Models') + + # remember to top level categories + cat_files = { + #'shape':shape_toc, + 'shape':model_toc, + 'shape-independent':free_toc, + 'structure-factor': struct_toc, + #'custom': custom_toc, + } + + # Process the model lists + for k, v in sorted(category.items()): + if ':' in k: + cat, subcat = k.split(':') + _maybe_make_category(cat, v, cat_files, model_toc) + cat_file = cat_files[cat] + label = "-".join((cat, subcat)) + filename = label + title = subcat.capitalize() + " Functions" + sub_toc = _make_category(filename, label, title, cat_file) + for model in sorted(v): + _add_model(sub_toc, model) + sub_toc.close() + else: + _maybe_make_category(k, v, cat_files, model_toc) + cat_file = cat_files[k] + for model in sorted(v): + _add_model(cat_file, model) + + #_add_subcategory('shape', model_toc) + _add_subcategory('shape-independent', model_toc) + _add_subcategory('structure-factor', model_toc) + #_add_subcategory('custom-models', model_toc) + + # Close the top-level category files + #model_toc.close() + for f in cat_files.values(): + f.close() + + +if __name__ == "__main__": + generate_toc(sys.argv[1:])