From 128ff78d701ffe6bfb1844424ae5ce58f266519c Mon Sep 17 00:00:00 2001 From: Carson Sears <118226485+searscr@users.noreply.github.com> Date: Fri, 26 Apr 2024 17:10:32 -0500 Subject: [PATCH] Create Login (#2) * first cut * Docs and docstrings * add qtbot * update fixtures * fix pytest * Fix docs build * updates and fixes * cleanup and refactor * update docs * fix link --- .gitignore | 1 + .readthedocs.yaml | 2 +- conda.recipe/meta.yaml | 3 + docs/Makefile | 21 ++ docs/conf.py | 13 +- docs/index.rst | 33 +++ docs/make.bat | 37 +++ docs/source/reference.rst | 16 ++ docs/source/sample.rst | 148 +++++++++++ environment.yml | 3 + pyproject.toml | 28 +- scripts/sample_usage.py | 53 ++++ src/pyoncatqt/__init__.py | 15 -- src/pyoncatqt/configuration.ini | 5 + src/pyoncatqt/configuration.py | 80 +----- src/pyoncatqt/configuration_template.ini | 2 - src/pyoncatqt/help/help_model.py | 14 - src/pyoncatqt/home/home_model.py | 12 - src/pyoncatqt/home/home_presenter.py | 19 -- src/pyoncatqt/home/home_view.py | 13 - src/pyoncatqt/login.py | 323 +++++++++++++++++++++++ src/pyoncatqt/mainwindow.py | 58 ---- src/pyoncatqt/pyoncatqt.py | 66 ----- src/pyoncatqt/version.py | 12 +- tests/conftest.py | 21 ++ tests/data/configuration.ini | 5 + tests/data/token.json | 1 + tests/test_login_dialog.py | 145 ++++++++++ tests/test_version.py | 13 +- 29 files changed, 869 insertions(+), 293 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/source/reference.rst create mode 100644 docs/source/sample.rst create mode 100644 scripts/sample_usage.py create mode 100644 src/pyoncatqt/configuration.ini delete mode 100644 src/pyoncatqt/configuration_template.ini delete mode 100644 src/pyoncatqt/help/help_model.py delete mode 100644 src/pyoncatqt/home/home_model.py delete mode 100644 src/pyoncatqt/home/home_presenter.py delete mode 100644 src/pyoncatqt/home/home_view.py create mode 100644 src/pyoncatqt/login.py delete mode 100644 src/pyoncatqt/mainwindow.py delete mode 100644 src/pyoncatqt/pyoncatqt.py create mode 100644 tests/conftest.py create mode 100644 tests/data/configuration.ini create mode 100644 tests/data/token.json create mode 100644 tests/test_login_dialog.py diff --git a/.gitignore b/.gitignore index 942fc2f..5d718d3 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +tests/data/token.json # Translations *.mo diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f0d549b..c48508f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -12,7 +12,7 @@ python: sphinx: builder: html - configuration: docs/source/conf.py + configuration: docs/conf.py fail_on_warning: true conda: diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index 7c7fdad..b8be7e0 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -35,6 +35,9 @@ requirements: run: - python + - pyoncat + - oauthlib + - mantidqt about: home: {{ url }} diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..a2266d7 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,21 @@ +# Purpose: Convenience scripts to simplify some common Sphinx operations, such as rendering the content. + +# Minimal makefile for Sphinx documentation + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py index 2186bab..f0ff915 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -38,7 +38,16 @@ "sphinx.ext.githubpages", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", + "sphinxcontrib.mermaid", ] + +autodoc_mock_imports = [ + "qtpy", + "qtpy.uic", + "qtpy.QtWidgets", + "mantidqt", +] + templates_path = ["_templates"] # The suffix(es) of source filenames. @@ -69,7 +78,9 @@ napoleon_google_docstring = False napoleon_numpy_docstring = True -html_static_path = ["_static"] +# html_static_path = ["source/_static"] + +html_theme_options = {"page_width": "75%"} # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..ea40b44 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,33 @@ +.. Purpose: The root document of the project, which serves as welcome page and contains the root +.. of the "table of contents tree" (or toctree). + +PyONCatQt Documentation +======================= + +PyONCatQt is a Python package designed to provide a Qt-based GUI for authentication +and connection to the ONCat service, a data cataloging and management system developed +by Oak Ridge National Laboratory. It serves as a plugin for other applications, +offering a convenient PyQt-based GUI component, +ONCatLoginDialog, for securely inputting login credentials. Upon successful authentication, +the package establishes a connection to the ONCat service, granting access to various data +management functionalities. PyONCatQt aims to streamline the login process and enhance user +experience when interacting with ONCat within Python applications. + +Getting Started +--------------- +To install PyONCatQt, run the following command: + +.. code-block:: bash + + conda install -c neutrons pyoncatqt + +To use PyONCatQt please see the :ref:`sample` section for examples. + +Contents +-------- + +.. toctree:: + :maxdepth: 1 + + source/reference + source/sample diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..793c5ab --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,37 @@ +REM Purpose: Convenience scripts to simplify some common Sphinx operations, such as rendering the content. + +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/reference.rst b/docs/source/reference.rst new file mode 100644 index 0000000..4316368 --- /dev/null +++ b/docs/source/reference.rst @@ -0,0 +1,16 @@ +Reference +========= + +ONCatLogin +---------- + +.. module:: ONCatLogin +.. automodule:: pyoncatqt.login.ONCatLogin + :members: + +ONCatLoginDialog +---------------- + +.. module:: ONCatLoginDialog +.. automodule:: pyoncatqt.login.ONCatLoginDialog + :members: diff --git a/docs/source/sample.rst b/docs/source/sample.rst new file mode 100644 index 0000000..f99d9a5 --- /dev/null +++ b/docs/source/sample.rst @@ -0,0 +1,148 @@ +.. _sample: + +Sample Usage +============ + +ONCatLogin Widget +----------------- +The following is a simple example of how to use the ONCatLogin widget in a PyQt application. +This example creates a main window with an ONCatLogin widget and two QListWidgets to display +the instrument lists for the SNS and HFIR facilities. The instrument lists are updated when +the connection status changes. +The only required argument for the ``ONCatLogin`` widget is the client ID. The client ID is a unique identifier +for the application that is used to authenticate with the ONCat server. The client ID is provided by the ONCat support team. +and should exist in pyoncatqt configuration file. + +.. code:: python + + from pyoncatqt.login import ONCatLogin + from qtpy.QtWidgets import QApplication, QLabel, QListWidget, QVBoxLayout, QWidget + + class MainWindow(QWidget): + def __init__(self): + super().__init__() + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + # Create and add the Oncat widget + self.oncat_widget = ONCatLogin(key="", parent=self) + self.oncat_widget.connection_updated.connect(self.update_instrument_lists) + layout.addWidget(self.oncat_widget) + + # Add list widgets for the instrument lists + self.sns_list = QListWidget() + self.hfir_list = QListWidget() + + layout.addWidget(QLabel("SNS Instruments:")) + layout.addWidget(self.sns_list) + layout.addWidget(QLabel("HFIR Instruments:")) + layout.addWidget(self.hfir_list) + + self.setLayout(layout) + self.setWindowTitle("ONCat Application") + self.oncat_widget.update_connection_status() + + def update_instrument_lists(self, is_connected): + """Update the contents of the instrument lists based on the connection status.""" + self.sns_list.clear() + self.hfir_list.clear() + + if is_connected: + sns_instruments = self.oncat_widget.agent.Instrument.list(facility="SNS") + hfir_instruments = self.oncat_widget.agent.Instrument.list(facility="HFIR") + + for instrument in sns_instruments: + self.sns_list.addItem(instrument.get("name")) + + for instrument in hfir_instruments: + self.hfir_list.addItem(instrument.get("name")) + + if __name__ == "__main__": + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec_()) + + +ONCatLoginDialog +---------------- + +The `ONCatLoginDialog` can be imported and used separate from the `ONCatLogin` widget to give developers +the ability to customize the login process. In order to use the `ONCatLoginDialog`, you must have an +instance of the `pyoncat.ONCat` agent. +If you chose to use the `ONCatLogin` widget instead, the agent is already created for you. +The `ONCatLoginDialog` requires the agent to be passed in as an argument. +The agent is used to authenticate the user and manage the connection to the ONCat server. +At a minimum the agent must be initialized with the ONCat server URL, flow, and the client ID. +Additional callbacks can be passed in to handle token storage and retrieval. These would be simple functions to read and write +the token to a JSON file. + +.. code:: python + + import pyoncat + + # This is a temporary "client ID" intended for use in this tutorial **only**. + # For your own work, please contact ONCat Support to be issued your own credentials. + CLIENT_ID = "c0686270-e983-4c71-bd0e-bfa47243a47f" + + # We will use the testing version of ONCat for this sample. + ONCAT_URL = "https://oncat-testing.ornl.gov" + + oncat = pyoncat.ONCat( + ONCAT_URL, + client_id=CLIENT_ID, + flow=pyoncat.RESOURCE_OWNER_CREDENTIALS_FLOW, + ) + +The following example demonstrates how to use the `ONCatLoginDialog` in a PyQt application. + +- The application consists of a single button labeled "Login to ONCat". +- When the button is clicked, it triggers the opening of the `ONCatLoginDialog`, + allowing the user to input their ONCat login credentials securely. +- Upon successful login, the dialog closes, and the application can proceed with its functionality, + utilizing the authenticated ONCat connection for data management tasks. + +.. code:: python + + from pyoncatqt.login import ONCatLoginDialog + import pyoncat + from qtpy.QtWidgets import QApplication, QPushButton, QVBoxLayout, QWidget + import sys + + class MainWindow(QWidget): + def __init__(self): + super().__init__() + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + # Create a button to open the ONCat login dialog + self.login_button = QPushButton("Login to ONCat") + self.login_button.clicked.connect(self.open_oncat_login_dialog) + layout.addWidget(self.login_button) + self.setLayout(layout) + self.setWindowTitle("ONCat Login Example") + + # Create an instance of the pyoncat agent pyoncat.ONCat + CLIENT_ID = "c0686270-e983-4c71-bd0e-bfa47243a47f" + + ONCAT_URL = "https://oncat-testing.ornl.gov" + + self.agent = pyoncat.ONCat( + ONCAT_URL, + client_id=CLIENT_ID, + flow=pyoncat.RESOURCE_OWNER_CREDENTIALS_FLOW, + ) + + def open_oncat_login_dialog(self): + dialog = ONCatLoginDialog(agent=self.agent, parent=self) + dialog.exec_() + + if __name__ == "__main__": + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec_()) diff --git a/environment.yml b/environment.yml index a1e9bc8..e44a36c 100644 --- a/environment.yml +++ b/environment.yml @@ -15,5 +15,8 @@ dependencies: - python-build - pytest - pytest-cov + - pytest-qt - setuptools + - sphinx + - sphinxcontrib-mermaid - versioningit diff --git a/pyproject.toml b/pyproject.toml index 028ad73..cd49364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,29 +30,37 @@ file = "src/pyoncatqt/_version.py" [tool.setuptools.packages.find] where = ["src"] -exclude = ["tests*", "docs*"] +exclude = ["tests*", "docs*", "scripts*"] + +[project.gui-scripts] +pyoncatqt = "pyoncatqt.version:get_version" [tool.setuptools.package-data] "*" = ["*.yml","*.yaml","*.ini"] -# TODO: Define once the package is ready for distribution -#[project.scripts] -#packagename-cli = "packagenamepy.packagename:main" - -#[project.gui-scripts] -#packagenamepy = "packagenamepy.packagename:gui" - [tool.pytest.ini_options] -pythonpath = [".", "src", "scripts"] +pythonpath = [".", "src"] testpaths = ["tests"] python_files = ["test*.py"] norecursedirs = [".git", "tmp*", "_tmp*", "__pycache__", "*dataset*", "*data_set*"] +[tool.coverage.report] +exclude_lines = [ + "except ImportError:", + "except ModuleNotFoundError:", + "except pyoncat.LoginRequiredError:", + "except pyoncat.InvalidRefreshTokenError:", + "except Exception:", + "except json.JSONDecodeError:", + "except KeyError:", +] +omit = ["src/pyoncatqt/_version.py"] + [tool.ruff] line-length = 120 [tool.ruff.lint] # https://beta.ruff.rs/docs/rules/ -select = ["A", "ARG", "BLE", "E", "F", "I", "PT"] +select = ["A", "ARG", "BLE", "E", "F", "I", "PT", "ANN"] ignore = ["F403", "F405", "F401", # wild imports and unknown names ] diff --git a/scripts/sample_usage.py b/scripts/sample_usage.py new file mode 100644 index 0000000..1aeef88 --- /dev/null +++ b/scripts/sample_usage.py @@ -0,0 +1,53 @@ +import sys + +from pyoncatqt.login import ONCatLogin +from qtpy.QtWidgets import QApplication, QLabel, QListWidget, QVBoxLayout, QWidget + + +class MainWindow(QWidget): + """Main widget""" + + def __init__(self: QWidget, key: str = "shiver", parent: QWidget = None) -> None: + super().__init__(parent=parent) + + layout = QVBoxLayout() + + # Create and add the Oncat widget + self.oncat_widget = ONCatLogin(key=key, parent=self) + self.oncat_widget.connection_updated.connect(self.update_instrument_lists) + layout.addWidget(self.oncat_widget) + + # Add text input boxes for wavelength and run number + self.sns_list = QListWidget() + self.hfir_list = QListWidget() + + layout.addWidget(QLabel("SNS Instruments:")) + layout.addWidget(self.sns_list) + layout.addWidget(QLabel("HFIR Instruments:")) + layout.addWidget(self.hfir_list) + + self.setLayout(layout) + self.setWindowTitle("ONCat Application") + self.oncat_widget.update_connection_status() + + def update_instrument_lists(self: QWidget, is_connected: bool) -> None: + """Update the contents of the instrument lists based on the connection status.""" + self.sns_list.clear() + self.hfir_list.clear() + + if is_connected: + sns_instruments = self.oncat_widget.agent.Instrument.list(facility="SNS") + hfir_instruments = self.oncat_widget.agent.Instrument.list(facility="HFIR") + + for instrument in sns_instruments: + self.sns_list.addItem(instrument.get("name")) + + for instrument in hfir_instruments: + self.hfir_list.addItem(instrument.get("name")) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec_()) diff --git a/src/pyoncatqt/__init__.py b/src/pyoncatqt/__init__.py index 33a7635..e69de29 100644 --- a/src/pyoncatqt/__init__.py +++ b/src/pyoncatqt/__init__.py @@ -1,15 +0,0 @@ -""" -Contains the entry point for the application -""" - -try: - from ._version import __version__ # noqa: F401 -except ImportError: - __version__ = "unknown" - - -def PackageName(): # pylint: disable=invalid-name - """This is needed for backward compatibility because mantid workbench does "from shiver import Shiver" """ - from .pyoncatqt import PackageName as packagename # pylint: disable=import-outside-toplevel - - return packagename() diff --git a/src/pyoncatqt/configuration.ini b/src/pyoncatqt/configuration.ini new file mode 100644 index 0000000..145faf2 --- /dev/null +++ b/src/pyoncatqt/configuration.ini @@ -0,0 +1,5 @@ +[login.oncat] +#url to oncat portal +oncat_url = https://oncat.ornl.gov +#client id for on cat; it is unique for shiver +shiver_id = 99025bb3-ce06-4f4b-bcf2-36ebf925cd1d diff --git a/src/pyoncatqt/configuration.py b/src/pyoncatqt/configuration.py index e0de862..9c79266 100644 --- a/src/pyoncatqt/configuration.py +++ b/src/pyoncatqt/configuration.py @@ -1,83 +1,14 @@ -"""Module to load the the settings from SHOME/.packagename/configuration.ini file - -Will fall back to a default""" +"""Module to load the the settings from the configuration file""" import os -import shutil from configparser import ConfigParser -from pathlib import Path - -from mantid.kernel import Logger - -logger = Logger("PACKAGENAME") # configuration settings file path -CONFIG_PATH_FILE = os.path.join(Path.home(), ".packagename", "configuration.ini") - - -class Configuration: - """Load and validate Configuration Data""" - - def __init__(self): - """initialization of configuration mechanism""" - # capture the current state - self.valid = False - - # locate the template configuration file - project_directory = Path(__file__).resolve().parent - self.template_file_path = os.path.join(project_directory, "configuration_template.ini") - - # retrieve the file path of the file - self.config_file_path = CONFIG_PATH_FILE - logger.information(f"{self.config_file_path} will be used") - - # if template conf file path exists - if os.path.exists(self.template_file_path): - # file does not exist create it from template - if not os.path.exists(self.config_file_path): - # if directory structure does not exist create it - if not os.path.exists(os.path.dirname(self.config_file_path)): - os.makedirs(os.path.dirname(self.config_file_path)) - shutil.copy2(self.template_file_path, self.config_file_path) - - self.config = ConfigParser(allow_no_value=True, comment_prefixes="/") - # parse the file - try: - self.config.read(self.config_file_path) - # validate the file has the all the latest variables - self.validate() - except ValueError as err: - logger.error(str(err)) - logger.error(f"Problem with the file: {self.config_file_path}") - else: - logger.error(f"Template configuration file: {self.template_file_path} is missing!") - - def validate(self): - """validates that the fields exist at the config_file_path and writes any missing fields/data - using the template configuration file: configuration_template.ini as a guide""" - template_config = ConfigParser(allow_no_value=True, comment_prefixes="/") - template_config.read(self.template_file_path) - for section in template_config.sections(): - # if section is missing - if section not in self.config.sections(): - # copy the whole section - self.config.add_section(section) - - for item in template_config.items(section): - field, _ = item - if field not in self.config[section]: - # copy the field - self.config[section][field] = template_config[section][field] - with open(self.config_file_path, "w", encoding="utf8") as config_file: - self.config.write(config_file) - self.valid = True - - def is_valid(self): - """returns the configuration state""" - return self.valid +config_dir = os.path.dirname(os.path.abspath(__file__)) +CONFIG_PATH_FILE = os.path.join(config_dir, "configuration.ini") -def get_data(section, name=None): +def get_data(section: str, name: str = None) -> dict | str | bool | None: """retrieves the configuration data for a variable with name""" # default file path location config_file_path = CONFIG_PATH_FILE @@ -96,8 +27,7 @@ def get_data(section, name=None): return None return value return config[section] - except KeyError as err: + except KeyError: # requested section/field do not exist - logger.error(str(err)) return None return None diff --git a/src/pyoncatqt/configuration_template.ini b/src/pyoncatqt/configuration_template.ini deleted file mode 100644 index c8ea100..0000000 --- a/src/pyoncatqt/configuration_template.ini +++ /dev/null @@ -1,2 +0,0 @@ -[global.other] -help_url = https://github.com/neutrons/python_project_template/blob/main/README.md diff --git a/src/pyoncatqt/help/help_model.py b/src/pyoncatqt/help/help_model.py deleted file mode 100644 index bf95df0..0000000 --- a/src/pyoncatqt/help/help_model.py +++ /dev/null @@ -1,14 +0,0 @@ -"""single help module""" - -import webbrowser - -from pyoncatqt.configuration import get_data - - -def help_function(context): - """ - open a browser with the appropriate help page - """ - help_url = get_data("global.other", "help_url") - if context: - webbrowser.open(help_url) diff --git a/src/pyoncatqt/home/home_model.py b/src/pyoncatqt/home/home_model.py deleted file mode 100644 index e574925..0000000 --- a/src/pyoncatqt/home/home_model.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Model for the Main tab""" - -from mantid.kernel import Logger - -logger = Logger("PACKAGENAME") - - -class HomeModel: # pylint: disable=too-many-public-methods - """Main model""" - - def __init__(self): - return diff --git a/src/pyoncatqt/home/home_presenter.py b/src/pyoncatqt/home/home_presenter.py deleted file mode 100644 index 1b55b34..0000000 --- a/src/pyoncatqt/home/home_presenter.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Presenter for the Main tab""" - - -class HomePresenter: # pylint: disable=too-many-public-methods - """Main presenter""" - - def __init__(self, view, model): - self._view = view - self._model = model - - @property - def view(self): - """Return the view for this presenter""" - return self._view - - @property - def model(self): - """Return the model for this presenter""" - return self._model diff --git a/src/pyoncatqt/home/home_view.py b/src/pyoncatqt/home/home_view.py deleted file mode 100644 index 61a8e71..0000000 --- a/src/pyoncatqt/home/home_view.py +++ /dev/null @@ -1,13 +0,0 @@ -"""PyQt widget for the main tab""" - -from qtpy.QtWidgets import QHBoxLayout, QWidget - - -class Home(QWidget): # pylint: disable=too-many-public-methods - """Main widget""" - - def __init__(self, parent=None): - super().__init__(parent) - - layout = QHBoxLayout() - self.setLayout(layout) diff --git a/src/pyoncatqt/login.py b/src/pyoncatqt/login.py new file mode 100644 index 0000000..0be251c --- /dev/null +++ b/src/pyoncatqt/login.py @@ -0,0 +1,323 @@ +import json +import os +import sys +from typing import Any, Dict + +import oauthlib +import pyoncat +from qtpy.QtCore import QSize, Signal +from qtpy.QtWidgets import ( + QDialog, + QErrorMessage, + QFormLayout, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from pyoncatqt.configuration import get_data + + +class ONCatLoginDialog(QDialog): + """ + OnCat login dialog for handling authentication. + + Params + ------ + agent : pyoncat.ONCat, required + An instance of pyoncat.ONCat for handling authentication. + parent : QWidget, optional + The parent widget. + username_label : str, optional + The label text for the username field. Defaults to "UserId". + password_label : str, optional + The label text for the password field. Defaults to "Password". + login_title : str, optional + The title of the login dialog window. + Defaults to "Use U/XCAM to connect to OnCat". + password_echo : QLineEdit.EchoMode, optional + The echo mode for the password field. + Defaults to QLineEdit.Password. + + Attributes + ---------- + login_status : Signal + Signal emitted when the login status changes. + + Methods + ------- + show_message(msg: str) -> None: + Show an error dialog with the given message. + + accept() -> None: + Accept the login attempt. + """ + + login_status = Signal(bool) + + def __init__(self: QDialog, agent: pyoncat.ONCat = None, parent: QWidget = None, **kwargs: Dict[str, Any]) -> None: + super().__init__(parent) + username_label_text = kwargs.pop("username_label", "UserId") + password_label_text = kwargs.pop("password_label", "Password") + window_title_text = kwargs.pop("login_title", "Use U/XCAM to connect to OnCat") + pwd_echo = kwargs.pop("password_echo", QLineEdit.Password) + + self.setWindowTitle(window_title_text) + + username_label = QLabel(username_label_text) + self.user_name = QLineEdit(os.getlogin(), self) + + password_label = QLabel(password_label_text) + self.user_pwd = QLineEdit(self) + self.user_pwd.setEchoMode(pwd_echo) + + self.button_login = QPushButton("&Login") + self.button_cancel = QPushButton("Cancel") + self.button_login.setEnabled(False) + + self.setMinimumSize(QSize(400, 100)) + layout = QVBoxLayout() + self.setLayout(layout) + + input_layout = QFormLayout() + input_layout.addRow(username_label, self.user_name) + input_layout.addRow(password_label, self.user_pwd) + + button_layout = QHBoxLayout() + button_layout.addWidget(self.button_login) + button_layout.addWidget(self.button_cancel) + + layout.addLayout(input_layout) + layout.addLayout(button_layout) + + self.user_name.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.user_pwd.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + if agent: + self.agent = agent + else: + self.show_message("No Agent provided for login") + return + + # connect signals and slots + self.button_login.clicked.connect(self.accept) + self.button_cancel.clicked.connect(self.reject) + self.user_name.textChanged.connect(self.update_button_status) + self.user_pwd.textChanged.connect(self.update_button_status) + + self.user_pwd.setFocus() + + self.error = QErrorMessage(self) + + def show_message(self: QDialog, msg: str) -> None: + """Will show a error dialog with the given message""" + self.error.showMessage(msg) + + def update_button_status(self: QDialog) -> None: + """Update the button status""" + self.button_login.setEnabled(bool(self.user_name.text() and self.user_pwd.text())) + + def accept(self: QDialog) -> None: + """Accept""" + try: + self.agent.login( + self.user_name.text(), + self.user_pwd.text(), + ) + except oauthlib.oauth2.rfc6749.errors.InvalidGrantError: + self.show_message("Invalid username or password. Please try again.") + self.user_pwd.setText("") + return + except pyoncat.LoginRequiredError: + self.show_message("A username and/or password was not provided when logging in.") + self.user_pwd.setText("") + return + + self.login_status.emit(True) + # close dialog + self.close() + + +class ONCatLogin(QGroupBox): + """ + ONCatLogin widget for connecting to the ONCat service. + This widget provides a label and a button to call the login dialog. + + Params + ------ + key : str, required + The key used to retrieve ONCat client ID from configuration. Defaults to None. + parent : QWidget, optional + The parent widget. + kwargs : Dict[str, Any], optional + Additional keyword arguments. + + Attributes + ---------- + connection_updated : Signal + Signal emitted when the connection status is updated. + + Methods + ------- + update_connection_status() -> None: + Update the connection status. + is_connected() -> bool: + Check if connected to OnCat. + get_agent_instance() -> pyoncat.ONCat: + Get the OnCat agent instance. + connect_to_oncat() -> None: + Connect to OnCat. + read_token() -> dict: + Read token from file. + write_token(token: dict) -> None: + Write token to file. + """ + + connection_updated = Signal(bool) + + def __init__(self: QGroupBox, key: str = None, parent: QWidget = None, **kwargs: Dict[str, Any]) -> None: + """ + Initialize the ONCatLogin widget. + + Params + ------ + key : str, optional + The key used to retrieve ONCat client ID from configuration. Defaults to None. + parent : QWidget, optional + The parent widget. + **kwargs : Dict[str, Any], optional + Additional keyword arguments. + """ + super().__init__(parent) + self.oncat_options_layout = QGridLayout() + self.setLayout(self.oncat_options_layout) # Set the layout for the group box + + # Status indicator (disconnected: red, connected: green) + self.status_label = QLabel("") + self.status_label.setToolTip("ONCat connection status.") + self.oncat_options_layout.addWidget(self.status_label, 4, 0) + + # Connect to OnCat button + self.oncat_button = QPushButton("&Connect to ONCat") + self.oncat_button.setFixedWidth(300) + self.oncat_button.setToolTip("Connect to ONCat (requires login credentials).") + self.oncat_button.clicked.connect(self.connect_to_oncat) + self.oncat_options_layout.addWidget(self.oncat_button, 4, 1) + + self.error_message_callback = None + + # OnCat agent + + self.oncat_url = get_data("login.oncat", "oncat_url") + self.client_id = get_data("login.oncat", f"{key}_id") + if not self.client_id: + raise ValueError(f"Invalid module {key}. No OnCat client Id is found for this application.") + + self.token_path = os.path.abspath(f"{os.path.expanduser('~')}/.pyoncatqt/{key}_token.json") + + self.agent = pyoncat.ONCat( + self.oncat_url, + client_id=self.client_id, + # Pass in token getter/setter callbacks here: + token_getter=self.read_token, + token_setter=self.write_token, + flow=pyoncat.RESOURCE_OWNER_CREDENTIALS_FLOW, + ) + + self.login_dialog = ONCatLoginDialog(agent=self.agent, parent=self, **kwargs) + self.update_connection_status() + + def update_connection_status(self: QGroupBox) -> None: + """Update connection status""" + if self.is_connected: + self.status_label.setText("ONCat: Connected") + self.status_label.setStyleSheet("color: green") + else: + self.status_label.setText("ONCat: Disconnected") + self.status_label.setStyleSheet("color: red") + + self.connection_updated.emit(self.is_connected) + + @property + def is_connected(self: QGroupBox) -> bool: + """ + Check if connected to OnCat. + + Returns + ------- + bool + True if connected, False otherwise. + """ + try: + self.agent.Facility.list() + return True + except pyoncat.InvalidRefreshTokenError: + return False + except pyoncat.LoginRequiredError: + return False + except Exception: # noqa BLE001 + return False + + def get_agent_instance(self: QGroupBox) -> pyoncat.ONCat: + """ + Get OnCat agent instance. + + Returns + ------- + pyoncat.ONCat + The OnCat agent instance. + """ + return self.agent + + def connect_to_oncat(self: QGroupBox) -> None: + """Connect to OnCat""" + # Check if already connected to OnCat + if self.is_connected: + return + + self.login_dialog.exec_() + self.update_connection_status() + # self.parent.update_boxes() + + def read_token(self: QGroupBox) -> dict: + """ + Read token from file. + + Returns + ------- + dict + The token dictionary. + """ + # If there is not a token stored, return None + if not os.path.exists(self.token_path): + return None + + with open(self.token_path, encoding="UTF-8") as storage: + try: + return json.load(storage) + except json.JSONDecodeError: + return None + + def write_token(self: QGroupBox, token: dict) -> None: + """ + Write token to file. + + Params + ------ + token : dict + The token dictionary. + """ + # Check if directory exists + if not os.path.exists(os.path.dirname(self.token_path)): + os.makedirs(os.path.dirname(self.token_path)) + # Write token to file + with open(self.token_path, "w", encoding="UTF-8") as storage: + json.dump(token, storage) + # Change permissions to read-only by user + os.chmod(self.token_path, 0o600) diff --git a/src/pyoncatqt/mainwindow.py b/src/pyoncatqt/mainwindow.py deleted file mode 100644 index fd9acee..0000000 --- a/src/pyoncatqt/mainwindow.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Main Qt window -""" - -from qtpy.QtWidgets import QHBoxLayout, QPushButton, QTabWidget, QVBoxLayout, QWidget - -from pyoncatqt.help.help_model import help_function -from pyoncatqt.home.home_model import HomeModel -from pyoncatqt.home.home_presenter import HomePresenter -from pyoncatqt.home.home_view import Home - - -class MainWindow(QWidget): - """Main widget""" - - def __init__(self, parent=None): - super().__init__(parent) - - ### Create tabs here ### - - ### Main tab - self.tabs = QTabWidget() - home = Home(self) - home_model = HomeModel() - self.home_presenter = HomePresenter(home, home_model) - self.tabs.addTab(home, "Home") - - ### Set tab layout - layout = QVBoxLayout() - layout.addWidget(self.tabs) - - ### Create bottom interface here ### - - # Help button - help_button = QPushButton("Help") - help_button.clicked.connect(self.handle_help) - - # Set bottom interface layout - hor_layout = QHBoxLayout() - hor_layout.addWidget(help_button) - - layout.addLayout(hor_layout) - - self.setLayout(layout) - - # register child widgets to make testing easier - self.home = home - - def handle_help(self): - """ - get current tab type and open the corresponding help page - """ - open_tab = self.tabs.currentWidget() - if isinstance(open_tab, Home): - context = "home" - else: - context = "" - help_function(context=context) diff --git a/src/pyoncatqt/pyoncatqt.py b/src/pyoncatqt/pyoncatqt.py deleted file mode 100644 index 4be4947..0000000 --- a/src/pyoncatqt/pyoncatqt.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Main Qt application -""" - -import sys - -from mantid.kernel import Logger -from mantidqt.gui_helper import set_matplotlib_backend -from qtpy.QtWidgets import QApplication, QMainWindow - -# make sure matplotlib is correctly set before we import shiver -set_matplotlib_backend() - -# make sure the algorithms have been loaded so they are available to the AlgorithmManager -import mantid.simpleapi # noqa: F401, E402 pylint: disable=unused-import, wrong-import-position - -from pyoncatqt.configuration import Configuration # noqa: E402 pylint: disable=wrong-import-position -from pyoncatqt.mainwindow import MainWindow # noqa: E402 pylint: disable=wrong-import-position -from pyoncatqt.version import __version__ # noqa: E402 pylint: disable=wrong-import-position - -logger = Logger("PACKAGENAME") - - -class PackageName(QMainWindow): - """Main Package window""" - - __instance = None - - def __new__(cls): - if PackageName.__instance is None: - PackageName.__instance = QMainWindow.__new__(cls) # pylint: disable=no-value-for-parameter - return PackageName.__instance - - def __init__(self, parent=None): - super().__init__(parent) - logger.information(f"PackageName version: {__version__}") - config = Configuration() - - if not config.is_valid(): - msg = ( - "Error with configuration settings!", - f"Check and update your file: {config.config_file_path}", - "with the latest settings found here:", - f"{config.template_file_path} and start the application again.", - ) - - print(" ".join(msg)) - sys.exit(-1) - self.setWindowTitle(f"PACKAGENAME - {__version__}") - self.main_window = MainWindow(self) - self.setCentralWidget(self.main_window) - - -def gui(): - """ - Main entry point for Qt application - """ - input_flags = sys.argv[1::] - if "--v" in input_flags or "--version" in input_flags: - print(__version__) - sys.exit() - else: - app = QApplication(sys.argv) - window = PackageName() - window.show() - sys.exit(app.exec_()) diff --git a/src/pyoncatqt/version.py b/src/pyoncatqt/version.py index 6f674ca..b061e80 100644 --- a/src/pyoncatqt/version.py +++ b/src/pyoncatqt/version.py @@ -2,7 +2,11 @@ Will fall back to a default packagename is not installed""" -try: - from ._version import __version__ -except ModuleNotFoundError: - __version__ = "0.0.1" + +def get_version() -> None: + """Get the version of the package""" + try: + from ._version import __version__ + except ModuleNotFoundError: + __version__ = "0.0.1" + print(f"pyoncatqt version: {__version__}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d9a1549 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import os + +import pyoncatqt.configuration +import pytest + + +@pytest.fixture(autouse=True) +def _config_path(monkeypatch: pytest.fixture) -> None: + test_dir = os.path.dirname(os.path.abspath(__file__)) + configuration_path = os.path.join(test_dir, "data", "configuration.ini") + monkeypatch.setattr(pyoncatqt.configuration, "CONFIG_PATH_FILE", configuration_path) + + +@pytest.fixture(autouse=True) +def _get_login(monkeypatch: pytest.fixture) -> None: + monkeypatch.setattr(os, "getlogin", lambda: "test") + + +@pytest.fixture() +def token_path() -> str: + return "tests/data/token.json" diff --git a/tests/data/configuration.ini b/tests/data/configuration.ini new file mode 100644 index 0000000..2046067 --- /dev/null +++ b/tests/data/configuration.ini @@ -0,0 +1,5 @@ +[login.oncat] +#url to oncat portal +oncat_url = https://oncat.ornl.gov +#client id for on cat; it is unique for shiver +test_id = 0123456489 diff --git a/tests/data/token.json b/tests/data/token.json new file mode 100644 index 0000000..1b24540 --- /dev/null +++ b/tests/data/token.json @@ -0,0 +1 @@ +{"name": "token", "version": "1.0.0", "description": "fake token"} diff --git a/tests/test_login_dialog.py b/tests/test_login_dialog.py new file mode 100644 index 0000000..54e5ee9 --- /dev/null +++ b/tests/test_login_dialog.py @@ -0,0 +1,145 @@ +import functools +import json +import os +from unittest.mock import MagicMock, patch + +import oauthlib +import pyoncat +import pytest +from pyoncatqt.configuration import get_data +from pyoncatqt.login import ONCatLogin, ONCatLoginDialog +from qtpy import QtCore +from qtpy.QtWidgets import QApplication, QDialog, QLineEdit, QPushButton + + +def check_status(login_status: bool) -> None: + assert login_status + + +def test_login_dialog_creation() -> None: + application = QApplication([]) + dialog = ONCatLoginDialog(application) + assert isinstance(dialog, QDialog) + assert dialog.windowTitle() == "Use U/XCAM to connect to OnCat" + assert isinstance(dialog.user_name, QLineEdit) + assert dialog.user_name.text() == os.getlogin() + assert isinstance(dialog.user_pwd, QLineEdit) + assert dialog.user_pwd.echoMode() == QLineEdit.Password + assert isinstance(dialog.button_login, QPushButton) + assert isinstance(dialog.button_cancel, QPushButton) + + +def test_login(qtbot: pytest.fixture) -> None: + dialog = ONCatLogin(key="test") + dialog.login_dialog = ONCatLoginDialog(agent=MagicMock(), parent=dialog) + dialog.login_dialog.login_status.connect(check_status) + qtbot.addWidget(dialog) + dialog.show() + + completed = False + + def handle_dialog() -> None: + nonlocal completed + + qtbot.keyClicks(dialog.login_dialog.user_pwd, "password") + qtbot.wait(2000) + qtbot.mouseClick(dialog.login_dialog.button_login, QtCore.Qt.LeftButton) + completed = True + + def dialog_completed() -> None: + nonlocal completed + assert completed is True + + QtCore.QTimer.singleShot(500, functools.partial(handle_dialog)) + qtbot.mouseClick(dialog.oncat_button, QtCore.Qt.LeftButton) + + qtbot.waitUntil(dialog_completed, timeout=5000) + + +def test_get_agent() -> None: + dialog = ONCatLogin(key="test") + dialog.agent = MagicMock() + assert dialog.get_agent_instance() == dialog.agent + + +def test_is_connected() -> None: + dialog = ONCatLogin(key="test") + mock_agent = MagicMock() + dialog.agent = mock_agent + mock_agent.Instrument.list.return_value = [{"name": "Instrument1"}, {"name": "Instrument2"}] + dialog.login_dialog = MagicMock() + + assert dialog.is_connected + dialog.connect_to_oncat() + assert not dialog.login_dialog.exec_.called + + +def test_login_dialog_nominal(qtbot: pytest.fixture) -> None: + agent = MagicMock() + dialog = ONCatLoginDialog(agent=agent) + dialog.login_status.connect(check_status) + qtbot.addWidget(dialog) + dialog.show() + qtbot.keyClicks(dialog.user_pwd, "password") + qtbot.wait(2000) + assert dialog.user_pwd.text() == "password" + qtbot.mouseClick(dialog.button_login, QtCore.Qt.LeftButton) + assert agent.login.called_once_with(os.getlogin(), "password") + + +def test_login_dialog_no_password(qtbot: pytest.fixture) -> None: + mock_agent = MagicMock(spec=pyoncat.ONCat) + mock_agent.login.side_effect = pyoncat.LoginRequiredError + dialog = ONCatLoginDialog(agent=mock_agent) + dialog.show_message = MagicMock() + qtbot.addWidget(dialog) + dialog.show() + qtbot.wait(2000) + assert dialog.user_pwd.text() == "" + assert dialog.button_login.isEnabled() is False + qtbot.mouseClick(dialog.button_login, QtCore.Qt.LeftButton) + assert mock_agent.login.called_once_with(os.getlogin(), "") + assert dialog.show_message.called_once_with("A username and/or password was not provided when logging in.") + + +def test_login_dialog_bad_password(qtbot: pytest.fixture) -> None: + mock_agent = MagicMock(spec=pyoncat.ONCat) + mock_agent.login.side_effect = oauthlib.oauth2.rfc6749.errors.InvalidGrantError + dialog = ONCatLoginDialog(agent=mock_agent) + dialog.show_message = MagicMock() + qtbot.addWidget(dialog) + dialog.show() + qtbot.keyClicks(dialog.user_pwd, "bad_password") + qtbot.wait(2000) + assert dialog.user_pwd.text() == "bad_password" + qtbot.mouseClick(dialog.button_login, QtCore.Qt.LeftButton) + assert mock_agent.login.called_once_with(os.getlogin(), "bad_password") + assert dialog.show_message.called_once_with("Invalid username or password. Please try again.") + + +def test_login_dialog_no_agent(qtbot: pytest.fixture) -> None: + with patch("pyoncatqt.login.ONCatLoginDialog.show_message"): + dialog = ONCatLoginDialog() + qtbot.addWidget(dialog) + assert dialog.show_message.called_once_with("No Agent provided for login.") + + +def test_read_token(qtbot: pytest.fixture, token_path: pytest.fixture) -> None: + widget = ONCatLogin(key="test") + qtbot.addWidget(widget) + widget.token_path = token_path + test_token = widget.read_token() + with open(token_path, "r") as f: + actual_token = json.load(f) + assert test_token == actual_token + + +def test_write_token(qtbot: pytest.fixture, token_path: pytest.fixture) -> None: + widget = ONCatLogin(key="test") + qtbot.addWidget(widget) + widget.token_path = token_path + with open(token_path, "r") as f: + actual_token = json.load(f) + widget.write_token(actual_token) + with open(token_path, "r") as f: + assert f.read() == json.dumps(actual_token) diff --git a/tests/test_version.py b/tests/test_version.py index 5b09eca..fc3ea0c 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,5 +1,12 @@ -from packagenamepy import __version__ +from unittest.mock import patch +import pytest +from pyoncatqt.version import get_version -def test_version(): - assert __version__ == "unknown" + +@patch("pyoncatqt._version.__version__", "1.0.0") +def test_get_version_existing_version(capsys: patch) -> None: + """Test get_version when _version module exists""" + get_version() + captured = capsys.readouterr() + assert captured.out.strip() == "pyoncatqt version: 1.0.0"