Skip to content

Commit

Permalink
Merge pull request #6 from int-brain-lab/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
bimac authored Jan 17, 2025
2 parents 39fcfb3 + 40d9276 commit d9e8ff7
Show file tree
Hide file tree
Showing 12 changed files with 1,227 additions and 566 deletions.
4 changes: 0 additions & 4 deletions .github/workflows/testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ jobs:
name: ruff format
with:
args: 'format --check'
# - name: mypy
# run: |
# pip install mypy
# mypy --package mypackage $(qtpy mypy-args)

Testing:
needs: [Ruff]
Expand Down
21 changes: 18 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.0] - 2025-01-17

### Added

- core.QAlyx: wrapper for one.webclient.AlyxClient
- widgets.AlyxWidget: widget for logging in to Alyx

### Changed

- widgets.StatefulButton: keep track of different labels for active and
inactive states

## [0.3.2] - 2024-12-03

Expand Down Expand Up @@ -35,15 +46,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- core.DataFrameTableModel: stop use of QVariant
- core.DataFrameTableModel: set dataFrame _after_ connecting signals in initialization
- core.DataFrameTableModel: set dataFrame _after_ connecting signals in
initialization
- core.DataFrameTableModel: default to horizontal orientation

## [0.1.2] - 2024-10-01

### Changed

- core.DataFrameTableModel: reverted data() to return Any instead of QVariant
- core.DataFrameTableModel: setData() returns bool indicating the outcome of the operation
- core.DataFrameTableModel: setData() returns bool indicating the outcome of
the operation

## [0.1.1] - 2024-10-01

Expand All @@ -64,9 +77,11 @@ _First release._
### Added

- core.DataFrameTableModel: A Qt TableModel for Pandas DataFrames.
- core.ColoredDataFrameTableModel: An extension of DataFrameTableModel providing color-mapped numerical data.
- core.ColoredDataFrameTableModel: An extension of DataFrameTableModel
providing color-mapped numerical data.
- widgets.StatefulButton: A QPushButton that maintains an active/inactive state.

[0.4.0]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.4.0
[0.3.1]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.3.1
[0.3.0]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.3.0
[0.2.0]: https://github.com/int-brain-lab/iblqt/releases/tag/v0.2.0
Expand Down
1 change: 0 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@

html_theme = 'sphinx_rtd_theme'
html_theme_options = {
'display_version': True,
'collapse_navigation': True,
'sticky_navigation': True,
'navigation_depth': 4,
Expand Down
2 changes: 1 addition & 1 deletion iblqt/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""A collection of extensions to the Qt framework."""

__version__ = '0.3.2'
__version__ = '0.4.0'
167 changes: 158 additions & 9 deletions iblqt/core.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
"""Non-GUI functionality, including event handling, data types, and data management."""

import logging
import warnings
from pathlib import Path
from typing import Any
from typing import Any, cast

import numpy as np
import numpy.typing as npt
import pandas as pd
from pandas import DataFrame
from pyqtgraph import ColorMap, colormap # type: ignore
from qtpy.QtCore import (
Property,
QAbstractTableModel,
QFileSystemWatcher,
Qt,
QModelIndex,
QObject,
Property,
Qt,
Signal,
Slot,
)
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QMessageBox, QWidget
from requests import HTTPError

import pandas as pd
from pandas import DataFrame
import numpy as np
import numpy.typing as npt
from one.webclient import AlyxClient # type: ignore

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -474,8 +478,7 @@ def __init__(self, parent: QObject, paths: list[Path] | list[str]):
Paths or directories to be watched.
"""
super().__init__(parent)
self._watcher = QFileSystemWatcher([], parent=self)
self.addPaths(paths)
self._watcher = QFileSystemWatcher([str(p) for p in paths], parent=self)
self._watcher.fileChanged.connect(lambda f: self.fileChanged.emit(Path(f)))
self._watcher.directoryChanged.connect(
lambda d: self.directoryChanged.emit(Path(d))
Expand Down Expand Up @@ -615,3 +618,149 @@ def removePaths(self, paths: list[Path | str]) -> list[Path]:
"""
out = self._watcher.removePaths([str(p) for p in paths])
return [Path(x) for x in out]


class QAlyx(QObject):
"""A Qt wrapper for :class:`one.webclient.AlyxClient`."""

tokenMissing = Signal(str)
"""Emitted when a login attempt failed due to a missing cache token."""

authenticationFailed = Signal(str)
"""Emitted when a login attempt failed due to incorrect credentials."""

connectionFailed = Signal(Exception)
"""Emitted when a login attempt failed due to connection issues."""

loggedIn = Signal(str)
"""Emitted when successfully logged in."""

loggedOut = Signal()
"""Emitted when logged out."""

statusChanged = Signal(bool)
"""Emitted when the login status has changed."""

def __init__(self, base_url: str, parent: QObject | None = None):
super().__init__(parent)
self._client = AlyxClient(base_url=base_url, silent=True)
self._parentWidget = (
cast(QWidget, self.parent()) if isinstance(self.parent(), QWidget) else None
)
self.connectionFailed.connect(self._onConnectionFailed)

@property
def client(self) -> AlyxClient:
"""Get the wrapped client.
Returns
-------
:class:`~one.webclient.AlyxClient`
The wrapped client.
"""
return self._client

def login(
self, username: str, password: str | None = None, cache_token: bool = False
) -> None:
"""
Try to log into Alyx.
Parameters
----------
username : str
Alyx username.
password : str, optional
Alyx password.
cache_token : bool
If true, the token is cached for subsequent auto-logins. Default: False.
"""
if self._client.is_logged_in and self._client.user == username:
return

# try to authenticate. upgrade warnings to exceptions so we can catch them.
try:
with warnings.catch_warnings():
warnings.simplefilter('error')
self._client.authenticate(
username=username,
password=password,
cache_token=cache_token,
force=password is not None,
)

# catch missing password / token
except UserWarning as e:
if 'No password or cached token' in e.args[0]:
self.tokenMissing.emit(username)
return

# catch connection issues: display a message box
except ConnectionError as e:
self.connectionFailed.emit(e)
return

# catch authentication errors
except HTTPError as e:
if e.errno == 400:
self.authenticationFailed.emit(username)
return
else:
raise e

# emit signals
if self._client.is_logged_in and self._client.user == username:
self.statusChanged.emit(True)
self.loggedIn.emit(username)

def rest(self, *args, **kwargs) -> Any:
"""Query Alyx rest API.
A wrapper for :meth:`one.webclient.AlyxClient.rest`.
Parameters
----------
*args : Any
Positional arguments passed to :meth:`AlyxClient.rest() <one.webclient.AlyxClient.rest>`.
**args : Any
Keyword arguments passed to :meth:`AlyxClient.rest() <one.webclient.AlyxClient.rest>`.
Returns
-------
Any
The response received from Alyx.
"""
if not self._client.is_logged_in:
QMessageBox.critical(
self._parentWidget,
'Authentication Error',
'Cannot complete query without authentication.\nPlease log in to Alyx and try again.',
)
try:
return self._client.rest(*args, **kwargs)
except HTTPError as e:
self.connectionFailed.emit(e)

def _onConnectionFailed(self, e: Exception) -> None:
if (isinstance(e, ConnectionError) and "Can't connect" in e.args[0]) or (
isinstance(e, HTTPError) and e.errno not in (404, 400)
):
pass
QMessageBox.critical(
self._parentWidget,
'Connection Error',
f"Can't connect to {self._client.base_url}.\n"
f'Check your internet connection and availability of the Alyx instance.',
)
elif isinstance(e, HTTPError) and e.errno == 400:
self.authenticationFailed.emit(self._client.user)
else:
raise e

def logout(self):
"""Log out of Alyx."""
if not self._client.is_logged_in:
return
self._client.logout()
self.statusChanged.emit(False)
self.loggedOut.emit()
89 changes: 89 additions & 0 deletions iblqt/resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-

# Resource object code
#
# Created by: The Resource Compiler for PyQt5 (Qt v5.15.14)
#
# WARNING! All changes made in this file will be lost!

from PyQt5 import QtCore

qt_resource_data = b"\
\x00\x00\x02\x0e\
\x3c\
\x73\x76\x67\x20\x66\x69\x6c\x6c\x3d\x22\x6e\x6f\x6e\x65\x22\x20\
\x68\x65\x69\x67\x68\x74\x3d\x22\x31\x36\x22\x20\x76\x69\x65\x77\
\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x2e\x34\x38\x20\x2e\x34\x38\
\x22\x20\x77\x69\x64\x74\x68\x3d\x22\x31\x36\x22\x20\x78\x6d\x6c\
\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\
\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x3e\
\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x2e\x34\x35\x33\x34\x31\
\x35\x30\x35\x2e\x30\x36\x36\x35\x30\x34\x32\x33\x61\x2e\x30\x32\
\x36\x36\x39\x34\x31\x32\x2e\x30\x32\x36\x36\x39\x34\x31\x32\x20\
\x30\x20\x30\x20\x30\x20\x2d\x2e\x30\x31\x38\x39\x32\x36\x37\x2e\
\x30\x30\x37\x37\x36\x37\x6c\x2d\x2e\x32\x37\x36\x38\x34\x37\x32\
\x37\x2e\x32\x37\x34\x39\x31\x38\x34\x32\x2d\x2e\x31\x31\x32\x36\
\x31\x36\x30\x34\x2d\x2e\x31\x31\x31\x37\x38\x31\x35\x61\x2e\x30\
\x32\x36\x36\x39\x34\x31\x32\x2e\x30\x32\x36\x36\x39\x34\x31\x32\
\x20\x30\x20\x30\x20\x30\x20\x2d\x2e\x30\x33\x37\x37\x34\x37\x30\
\x36\x2e\x30\x30\x30\x31\x30\x33\x39\x32\x2e\x30\x32\x36\x36\x39\
\x34\x31\x32\x2e\x30\x32\x36\x36\x39\x34\x31\x32\x20\x30\x20\x30\
\x20\x30\x20\x2e\x30\x30\x30\x31\x30\x34\x33\x36\x2e\x30\x33\x37\
\x37\x34\x37\x34\x6c\x2e\x31\x33\x31\x34\x33\x37\x33\x2e\x31\x33\
\x30\x34\x39\x38\x39\x32\x61\x2e\x30\x32\x36\x36\x39\x36\x37\x39\
\x2e\x30\x32\x36\x36\x39\x36\x37\x39\x20\x30\x20\x30\x20\x30\x20\
\x2e\x30\x33\x37\x35\x39\x30\x38\x20\x30\x6c\x2e\x32\x39\x35\x37\
\x32\x30\x37\x35\x2d\x2e\x32\x39\x33\x36\x33\x35\x33\x39\x61\x2e\
\x30\x32\x36\x36\x39\x34\x31\x32\x2e\x30\x32\x36\x36\x39\x34\x31\
\x32\x20\x30\x20\x30\x20\x30\x20\x2e\x30\x30\x30\x31\x30\x34\x33\
\x36\x2d\x2e\x30\x33\x37\x37\x34\x37\x31\x2e\x30\x32\x36\x36\x39\
\x34\x31\x32\x2e\x30\x32\x36\x36\x39\x34\x31\x32\x20\x30\x20\x30\
\x20\x30\x20\x2d\x2e\x30\x31\x38\x38\x32\x30\x35\x2d\x2e\x30\x30\
\x37\x38\x37\x31\x37\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x30\
\x30\x38\x30\x30\x30\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\x69\
\x6e\x65\x63\x61\x70\x3d\x22\x72\x6f\x75\x6e\x64\x22\x20\x73\x74\
\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\x6a\x6f\x69\x6e\x3d\x22\x72\
\x6f\x75\x6e\x64\x22\x2f\x3e\x3c\x2f\x73\x76\x67\x3e\
"

qt_resource_name = b"\
\x00\x04\
\x00\x06\xfa\x5e\
\x00\x69\
\x00\x63\x00\x6f\x00\x6e\
\x00\x09\
\x0b\x9e\x89\x07\
\x00\x63\
\x00\x68\x00\x65\x00\x63\x00\x6b\x00\x2e\x00\x73\x00\x76\x00\x67\
"

qt_resource_struct_v1 = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
"

qt_resource_struct_v2 = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x92\xae\x5a\x49\x66\
"

qt_version = [int(v) for v in QtCore.qVersion().split('.')]
if qt_version < [5, 8, 0]:
rcc_version = 1
qt_resource_struct = qt_resource_struct_v1
else:
rcc_version = 2
qt_resource_struct = qt_resource_struct_v2

def qInitResources():
QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)

def qCleanupResources():
QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)

qInitResources()
Loading

0 comments on commit d9e8ff7

Please sign in to comment.