Skip to content
Commits on Source (41)
......@@ -9,20 +9,24 @@ workflow:
- if: '$CI_COMMIT_BRANCH == "master"'
- if: '$CI_COMMIT_BRANCH =~ /^\d+\.\d+\.x$/'
variables:
PIP_CACHE_DIR: /opt/cache/pip
default:
image: condaforge/mambaforge
before_script:
# /dev/random is super slow
# https://www.tango-controls.org/community/forum/c/platforms/gnu-linux/device-server-gets-stuck-then-works-as-expected/
# https://stackoverflow.com/questions/26021181/not-enough-entropy-to-support-dev-random-in-docker-containers-running-in-boot2d
- rm /dev/random
- ln -s /dev/urandom /dev/random
# set pip cache to the Docker volume
- echo ${CI_PROJECT_DIR}
- export PIP_CACHE_DIR="/opt/cache/pip"
- export CONDA_ALWAYS_YES=1
- export MAMBA_NO_BANNER=1
- /opt/conda/bin/mamba init && source /root/.bashrc
- . /opt/conda/etc/profile.d/conda.sh
- . /opt/conda/etc/profile.d/mamba.sh
- conda config --set always_yes true --set quiet true
- conda config --append channels esrf-bcu
# Create empty env
- mamba create --name default_env
- mamba activate default_env
- >
if [[ -z $CI_MERGE_REQUEST_TARGET_BRANCH_NAME ]];then
export COMPARE_BRANCH_NAME="master"
......@@ -39,29 +43,24 @@ stages:
check_style:
stage: style
image: condaforge/mambaforge
except:
- master
script:
- mamba install --file requirements-dev.txt
# run black
- LC_ALL=C.UTF-8 black --check .
check_style_master:
stage: style
image: condaforge/mambaforge
only:
- master
tags:
- bliss_master
script:
- mamba install --file requirements-dev.txt
# run black
- LC_ALL=C.UTF-8 black --check .
check_lint:
stage: style
image: condaforge/mambaforge
script:
- mamba install --file requirements-dev.txt
# run flake8 on diff between current branch and last common ancestor with master
......@@ -71,7 +70,6 @@ check_lint:
.template_test_source:
stage: tests
image: condaforge/mambaforge
script:
- echo ${CHANGES}
- >
......@@ -85,19 +83,20 @@ check_lint:
fi
# install Xvfb and opengl libraries (needed for test_flint)
- apt-get update && apt-get -y install xvfb libxi6
# create test env and install BLISS
- mamba create --quiet --name testenv --file requirements.txt --file requirements-test.txt
- source activate testenv
- mamba install pytest-profiling
# install conda packages and Bliss
- mamba install --file requirements.txt --file requirements-test.txt
- pip install . --no-deps
- pip install -e blissdata/ --no-deps
# create separated env for lima
- mamba create --name $LIMA_SIMULATOR_CONDA_ENV --file requirements-test-lima.txt
# Make sure python will not reach source from git
- mv bliss bliss_
# run tests on source
- echo ${PYTEST_ARGS}
- pytest $PYTEST_ARGS
- echo ${PYTEST_CMD}
- $PYTEST_CMD
variables:
CHANGES: '\.(py|cfg)$|requirements|gitlab-ci|^(bin|extensions|scripts|spec|tests)/'
LIMA_SIMULATOR_CONDA_ENV: limasimulatorenv
test_bliss:
# Run bliss tests without coverage for any branches except the master
......@@ -105,15 +104,24 @@ test_bliss:
except:
- master
variables:
PYTEST_ARGS: 'tests --ignore tests/nexus_writer --ignore tests/qt'
PYTEST_CMD: 'pytest tests --ignore tests/nexus_writer --ignore tests/qt'
test_bliss_data:
test_bliss_data_threading:
# Run bliss data tests without coverage for any branches except the master
extends: .template_test_source
except:
- master
variables:
PYTEST_ARGS: 'blissdata'
PYTEST_CMD: 'pytest blissdata'
test_bliss_data_gevent:
# Run bliss data tests without coverage for any branches except the master
extends: .template_test_source
except:
- master
variables:
# monkey patching will enable blissdata's gevent mode
PYTEST_CMD: 'python -m gevent.monkey --no-thread --module pytest blissdata'
test_qt:
# Run bliss tests without coverage for any branches except the master
......@@ -121,7 +129,7 @@ test_qt:
except:
- master
variables:
PYTEST_ARGS: 'tests/qt'
PYTEST_CMD: 'pytest tests/qt'
test_writer:
# Run hdf5 writer tests without coverage for any branches except the master
......@@ -129,7 +137,7 @@ test_writer:
except:
- master
variables:
PYTEST_ARGS: 'tests/nexus_writer'
PYTEST_CMD: 'pytest tests/nexus_writer'
test_bliss_cov:
# Run bliss tests with coverage for master only
......@@ -146,7 +154,7 @@ test_bliss_cov:
- python scripts/profiling2txt.py
- sh scripts/print_test_profiling.sh
variables:
PYTEST_ARGS: 'tests --ignore tests/nexus_writer --ignore tests/qt'
PYTEST_CMD: 'pytest tests --ignore tests/nexus_writer --ignore tests/qt'
test_bliss_data_cov:
# Run bliss data tests with coverage for master only
......@@ -163,7 +171,7 @@ test_bliss_data_cov:
- python scripts/profiling2txt.py
- sh scripts/print_test_profiling.sh
variables:
PYTEST_ARGS: 'blissdata'
PYTEST_CMD: 'pytest blissdata'
test_qt_cov:
# Run BLISS tests depending on Qt with coverage for master only
......@@ -180,7 +188,7 @@ test_qt_cov:
- python scripts/profiling2txt.py
- sh scripts/print_test_profiling.sh
variables:
PYTEST_ARGS: 'tests/qt'
PYTEST_CMD: 'pytest tests/qt'
test_writer_cov:
# Run hdf5 writer tests with coverage for master only
......@@ -197,12 +205,14 @@ test_writer_cov:
- python scripts/profiling2txt.py
- sh scripts/print_test_profiling.sh
variables:
PYTEST_ARGS: 'tests/nexus_writer'
PYTEST_CMD: 'pytest tests/nexus_writer'
# Tests for windows are disabled for the moment (only do it manually)
# (they cannot run currently because of (at least) redis version for win64 is limited to v3.2.100)
test_bliss_windows:
stage: tests
inherit:
default: false
when: manual
tags:
- conda
......@@ -210,11 +220,12 @@ test_bliss_windows:
variables:
CONDA_ENV: 'bliss-windows-%CI_JOB_ID%'
before_script:
- call conda create -n %CONDA_ENV% python=3.7
# No permissions to edit the root .condarc file on windows
# - call conda config --set always_yes true --set quiet true
- call conda create --yes --quiet -n %CONDA_ENV% python=3.7
- call conda activate %CONDA_ENV%
- call conda config --env --append channels esrf-bcu
- call conda config --env --append channels tango-controls
- call mamba install --quiet --file requirements-win64.txt --file requirements-test-win64.txt
- call conda install --yes --quiet --file requirements-win64.txt --file requirements-test-win64.txt
- call pip install . --no-deps
- call pip install -e blissdata/ --no-deps
script:
......@@ -225,7 +236,6 @@ test_bliss_windows:
package:
stage: build
image: condaforge/mambaforge
tags:
- conda
- linux
......@@ -234,21 +244,18 @@ package:
# install opengl libraries (needed to avoid problem with pyopengl dependency)
- apt-get update && apt-get -y install libgl1-mesa-glx
# create package env and install conda-build
- mamba create --quiet --name buildenv python=3.7
- source activate buildenv
- conda config --append channels esrf-bcu
- mamba install --yes --quiet boa
- mamba install python=3.7 boa
# create links to reach prefixed compilers of conda
#- ln -s /opt/conda/envs/buildenv/bin/x86_64-conda_cos6-linux-gnu-gcc /opt/conda/envs/buildenv/bin/gcc
#- ln -s /opt/conda/envs/buildenv/bin/x86_64-conda_cos6-linux-gnu-g++ /opt/conda/envs/buildenv/bin/g++
script:
# triggering the creation of bliss/release.py file
- python -c "from setup import generate_release_file;generate_release_file()"
# creating the meta.yaml file for conda packet generation
# creating the meta.yaml file for conda package generation
- cd scripts
- python create_recipe.py
- conda mambabuild . --prefix-length=80 --output-folder=../dist/
# creating a local conda channel to serve bliss packet for next stage
# creating a local conda channel to serve bliss package for next stage
- cd ..
- mkdir conda-local-channel conda-local-channel/linux-64
- cp -r dist/linux-64/*.tar.bz2 conda-local-channel/linux-64/
......@@ -263,6 +270,8 @@ package:
package_windows:
stage: build
inherit:
default: false
tags:
- conda
- win
......@@ -270,20 +279,19 @@ package_windows:
CONDA_ENV: 'bliss-windows-%CI_JOB_ID%'
before_script:
# Create a dedicated env to avoid to pollute the shared machine
# create package env, install build tool
- call conda create -n %CONDA_ENV% python=3.7
# No permissions to edit the root .condarc file on windows:
# - call conda config --set always_yes true --set quiet true
- call conda create --yes --quiet -n %CONDA_ENV% python=3.7 boa
- call conda activate %CONDA_ENV%
- call conda config --env --append channels esrf-bcu
- call conda config --env --append channels tango-controls
- call mamba install --yes --quiet boa
script:
# triggering the creation of bliss/release.py file
- python -c "from setup import generate_release_file;generate_release_file()"
# creating the meta.yaml file for conda packet generation
# creating the meta.yaml file for conda package generation
- cd scripts
- python create_recipe.py
- call conda mambabuild . --prefix-length=80 --output-folder=../dist/
# creating a local conda channel to serve bliss packet for next stage
# creating a local conda channel to serve bliss package for next stage
- cd ..
- mkdir conda-local-channel conda-local-channel\win-64
- copy dist\win-64\*.tar.bz2 conda-local-channel\win-64\
......@@ -301,13 +309,11 @@ package_windows:
create_reference_doc:
stage: build
image: condaforge/mambaforge
script:
# install opengl libraries (needed to avoid problem with pyopengl dependency)
- apt-get update && apt-get -y install libgl1-mesa-glx
# create doc env and install all requirements
- mamba create --quiet --name docenv --file requirements.txt --file requirements-doc.txt
- source activate docenv
# install python requirements
- mamba install --file requirements.txt --file requirements-doc.txt
- pip install . --no-deps
- pip install -e blissdata/ --no-deps
# build of documentation
......@@ -320,13 +326,11 @@ create_reference_doc:
create_user_doc:
stage: build
image: condaforge/mambaforge
script:
# install opengl libraries (needed to avoid problem with pyopengl dependency)
- apt-get update && apt-get -y install libgl1-mesa-glx
# create doc env and install all requirements
- mamba create --quiet --yes --name mkdocsenv --file requirements-doc.txt
- source activate mkdocsenv
# install python requirements
- mamba install --file requirements-doc.txt
# build of documentation (-s : strict : fail on warnings)
- cd doc && mkdocs build -s
artifacts:
......@@ -337,13 +341,10 @@ create_user_doc:
.template_test_package:
stage: package_tests
image: condaforge/mambaforge
script:
# install Xvfb and opengl libraries (needed for test_flint)
- apt-get update && apt-get -y install xvfb libxi6
- mv bliss source # to avoid import errors (we want to test the packet, not local bliss folder)
- mamba create --name testenv
- source activate testenv
- mv bliss source # to avoid import errors (we want to test the package, not local bliss folder
- mamba install bliss==$CI_COMMIT_TAG --file requirements-test.txt --channel file://${CI_PROJECT_DIR}/conda-local-channel
- echo ${PYTEST_ARGS}
- pytest ${PYTEST_ARGS}
......@@ -356,7 +357,7 @@ test_bliss_package:
tags:
- bliss_master
variables:
PYTEST_ARGS: 'tests --ignore tests/nexus_writer --ignore tests/qt'
PYTEST_CMD: 'pytest tests --ignore tests/nexus_writer --ignore tests/qt'
test_bliss_data_package:
# Run bliss data tests using the bliss conda package
......@@ -366,7 +367,7 @@ test_bliss_data_package:
tags:
- bliss_master
variables:
PYTEST_ARGS: 'blissdata'
PYTEST_CMD: 'pytest blissdata'
test_qt_package:
# Run BLISS tests depending on Qt
......@@ -376,7 +377,7 @@ test_qt_package:
tags:
- bliss_master
variables:
PYTEST_ARGS: 'tests/qt'
PYTEST_CMD: 'pytest tests/qt'
test_writer_package:
# Run HDF5 writer tests using the bliss conda package
......@@ -386,11 +387,10 @@ test_writer_package:
tags:
- bliss_master
variables:
PYTEST_ARGS: 'tests/nexus_writer'
PYTEST_CMD: 'pytest tests/nexus_writer'
pages:
stage: deploy
image: condaforge/mambaforge
before_script:
- ''
script:
......
......@@ -25,9 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Flint
- Changed time to life of regulation plot is now independent to the x-axis
selected duration
- Changed the logging window to allow logger level edition
### Fixed
- Flint
- Remove the CPU overhead created by the logging window
### Removed
## [1.10.1]
......
......@@ -1357,9 +1357,8 @@ class Loop(CounterContainer):
try:
self._use_soft_ramp = False
current_value = self.input.read()
if self._force_ramping_from_current_pv:
current_value = self.input.read()
if not self._x_is_in_deadband(current_value):
self._set_setpoint(current_value)
......
......@@ -466,10 +466,8 @@ class Connection:
for rx_msg in wq.queue():
if isinstance(rx_msg, RuntimeError):
raise rx_msg
host, port = rx_msg.split(b"|")
self._redis_data_address = RedisAddress(
host=host.decode(), port=port.decode()
)
address = rx_msg.replace(b"|", b":").decode()
self._redis_data_address = RedisAddress.factory(address)
break
return self._redis_data_address
......@@ -624,10 +622,8 @@ class Connection:
if queue is not None:
queue.put(StopIteration)
elif messageType == protocol.REDIS_QUERY_ANSWER:
host, port = message.split(b":", 1)
self._redis_settings_address = RedisAddress(
host=host.decode(), port=port.decode()
)
address = message.decode()
self._redis_settings_address = RedisAddress.factory(address)
self._redis_query_event.set()
elif messageType == protocol.UDS_OK:
try:
......
......@@ -815,7 +815,7 @@ def tcp_server_main(sock):
def ensure_global_beacon_connection(beacon_port):
"""Avoid auto-discovery of port for the global connection object."""
"""Ensure beacon server points to itself if default_connection is used."""
if client_utils._default_connection is None:
client_utils._default_connection = connection_utils.Connection(
"localhost", beacon_port
......@@ -1205,12 +1205,14 @@ def main(args=None):
ctx = spawn_context(tcp_server_main, tcp_socket)
context_stack.enter_context(ctx)
# beacon server loopback
ensure_global_beacon_connection(beacon_port)
# Config web application
if _options.webapp_port > 0:
if has_flask():
from .web.configuration.config_app import web_app as config_app
ensure_global_beacon_connection(beacon_port)
ctx = start_webserver(config_app, _options.webapp_port)
context_stack.enter_context(ctx)
else:
......@@ -1223,7 +1225,6 @@ def main(args=None):
if has_flask():
from .web.homepage.homepage_app import web_app as homepage_app
ensure_global_beacon_connection(beacon_port)
homepage_app.config_port = _options.webapp_port
homepage_app.log_port = _options.log_viewer_port
ctx = start_webserver(homepage_app, _options.homepage_port)
......
......@@ -8,7 +8,7 @@
import enum
from bliss import global_map
from bliss.common.logtools import log_debug, user_debug
from bliss.common.logtools import log_debug
from bliss.controllers.regulator import Controller
from .oxfordcryo import OxfordCryostream
......@@ -57,11 +57,7 @@ class Oxford700(Controller):
super().__init__(config)
self._hw_controller = None
self._ramp_rate = None
self._ramprate_min = 1
self._ramprate_max = 360
self._setpoint = None
self._cmd_max_try = 6
self._ramp_rate = 0
@property
def hw_controller(self):
......@@ -233,10 +229,7 @@ class Oxford700(Controller):
Get the current setpoint (target value)
"""
log_debug(self, "Controller:get_setpoint: %s" % (tloop))
if self._setpoint is None:
self._setpoint = self.hw_controller.read_target_temperature()
return self._setpoint
return self.hw_controller.read_target_temperature()
def get_working_setpoint(self, tloop):
"""
......@@ -252,31 +245,12 @@ class Oxford700(Controller):
"""
log_debug(self, "Controller:start_ramp: %s %s" % (tloop, sp))
rate = self.get_ramprate(tloop)
# retry cmds if it has been ignored by the controller
# (cryostream can ignore cmds sometime and does not acknowledge received cmds...)
for i in range(self._cmd_max_try):
# send the command
if rate == 0:
if sp < self.get_setpoint(tloop):
self.hw_controller.cool(sp)
else:
self.hw_controller.ramp(self._ramprate_max, sp)
else:
self.hw_controller.ramp(rate, sp)
# wait 2 status update cycles and check that the new setpoint has been applied
self.hw_controller.wait_new_status()
self.hw_controller.wait_new_status()
if self.hw_controller.read_target_temperature() == sp:
self._setpoint = sp
return
user_debug(f"=== ramp cmd retry #{i}")
# use cool cmd to cooldown as fast as possible
if self._ramp_rate == 0:
if sp < self.hw_controller.read_gas_temperature():
return self.hw_controller.cool(sp)
raise RuntimeError("Oxford controller busy, cannot acknowledge new setpoint!")
return self.hw_controller.ramp(self._ramp_rate, sp)
def stop_ramp(self, tloop):
"""
......@@ -297,32 +271,15 @@ class Oxford700(Controller):
Set the ramp rate in [K/hr]
"""
log_debug(self, "Controller:set_ramprate: %s %s" % (tloop, rate))
if rate == 0:
self._ramp_rate = 0
else:
rate = max(rate, self._ramprate_min)
self._ramp_rate = min(rate, self._ramprate_max)
# ramp to current setpoint with the new ramprate
if self.is_ramping:
sp = self.get_setpoint(tloop)
self.start_ramp(tloop, sp)
self._ramp_rate = rate
def get_ramprate(self, tloop):
"""
Get the ramp rate in [K/hr]
"""
log_debug(self, "Controller:get_ramprate: %s" % (tloop))
if self._ramp_rate is None:
cur_rate = self.hw_controller.read_ramprate()
self._ramp_rate = (
cur_rate
if cur_rate != 0
else tloop.config.get("ramprate", self._ramprate_max)
)
# === cryo statusPacket reports ramprate=0 when ramping has finished,
# === so returning cached value is better than hw_controller.read_ramprate()
return self._ramp_rate
# --- controller method to set the Output to a given value (optional) -----------
......
......@@ -347,6 +347,7 @@ class CSCOMMAND(enum.IntEnum):
RESUME = 18
STOP = 19
TURBO = 20
FORMAT = 40
class CSCMDSIZE(enum.IntEnum):
......@@ -361,6 +362,7 @@ class CSCMDSIZE(enum.IntEnum):
RESUME = 2
STOP = 2
TURBO = 3
FORMAT = 3
class OxfordCryostream:
......@@ -496,6 +498,10 @@ class OxfordCryostream:
rate (int): ramp rate [K/hour] in range [1, 360]
temp (float): target temperature [K]
"""
if rate == 0: # null ramprate means as fast as possible
rate = 360
if rate < 1 or rate > 360:
raise ValueError("ramprate must be in range [1, 360] [K/hour]")
......@@ -612,30 +618,26 @@ class OxfordCryostream:
log_debug(self, f"send_cmd: size={size} cmd={command} args={args}")
data = [bytes([size]), bytes([command])]
data = [size, command]
if command == CSCOMMAND.TURBO.value:
data.append(str(args[0]).encode())
elif len(args) > 0:
if command in [CSCOMMAND.TURBO.value, CSCOMMAND.FORMAT.value]:
data.append(args[0])
else:
for arg in args:
hbyte, lbyte = self._split_bytes(arg)
data.append(hbyte)
data.append(lbyte)
data.extend(self._split_bytes(arg))
data_str = b"".join(data)
self.serial.write(data_str)
self.serial.write(bytes(data))
gevent.sleep(0.2)
# ========= internal commands =====================================
def _split_bytes(self, number):
"""Splits high and low byte (two less significant bytes)
of an integer, and returns them as bytes
"""
"""split a number into its high and low bytes, returned as a list"""
if not isinstance(number, int):
raise Exception("_split_bytes: Wrong input - should be an integer.")
low = number & 0b11111111
high = (number >> 8) & 0b11111111
return bytes([high]), bytes([low])
return [high, low]
@property
def statusPacket(self):
......@@ -645,26 +647,45 @@ class OxfordCryostream:
return self._status_packet
def _update_status(self):
maxretry = 10
retry = 0
self.serial.flush()
while True:
# === wait first to give time to the hardware before reading
gevent.sleep(0.1)
# === try to read data from serial
try:
data = self.serial.read(32, timeout=3)
status = StatusPacket(data)
except Exception as e:
self._status_packet = None
log_debug(f"=== _update_status error (retry={retry}): {e}")
self.serial.flush()
retry += 1
if retry >= maxretry:
raise e
continue
self._status_packet = status
self._status_event.set()
# Force old status packet format (i.e 32 bytes)
self.send_cmd(CSCMDSIZE.FORMAT.value, CSCOMMAND.FORMAT.value, 0)
length = 32
sp_type = 1
try:
while True:
# === wait first to give time to the hardware before reading
gevent.sleep(0.1)
# === try to read data from serial
try:
data = self.serial.read(length, timeout=3)
except Exception as e:
log_debug(self, f"=== OxfordCryostream: read serial error: {e}")
# === shift data until first byte is found
shift = 0
discard = b""
while data[0] != length or data[1] != sp_type:
discard += bytes([data[0]])
data = data[1:] + self.serial.read(1, timeout=3)
shift += 1
if shift:
log_debug(
self,
f"=== OxfordCryostream: data shifted by {shift} bytes, discarding {discard}",
)
# === try to create the StatusPacket object
try:
self._status_packet = StatusPacket(data)
self._status_event.set()
except Exception as e:
log_debug(
self, f"=== OxfordCryostream: StatusPacket creation error: {e}"
)
continue
finally:
self._status_packet = None
......@@ -94,10 +94,16 @@ def create_flint_model(settings) -> flint_model.FlintState:
from bliss.flint.manager.manager import ManageMainBehaviours
from bliss.flint.flint_window import FlintWindow
from bliss.flint.flint_api import FlintApi
from bliss.flint.model import logging_model
flintModel = flint_model.FlintState()
flintModel.setSettings(settings)
logModel = logging_model.LoggingModel(flintModel)
logModel.setMaximumLogCount(300)
logModel.connectRootLogger()
flintModel.setLogModel(logModel)
flintApi = FlintApi(flintModel)
flintModel.setFlintApi(flintApi)
......
......@@ -16,9 +16,9 @@ import os
from silx.gui import qt
from bliss.flint.widgets.log_widget import LogWidget
from bliss.flint.widgets.live_window import LiveWindow
from bliss.flint.widgets.custom_plot import CustomPlot
from bliss.flint.widgets.logging_window import LoggingWindow
from bliss.flint.widgets.state_indicator import StateIndicator
from bliss.flint.widgets.utils import app_actions
from bliss.flint.model import flint_model
......@@ -46,7 +46,6 @@ class FlintWindow(qt.QMainWindow):
self.__tabs = tabs
self.setCentralWidget(tabs)
self.__initLogWindow()
def setFlintModel(self, flintState: flint_model.FlintState):
if self.__flintState is not None:
......@@ -72,17 +71,11 @@ class FlintWindow(qt.QMainWindow):
plotId = widget.plotId()
self.removeCustomPlot(plotId)
def __initLogWindow(self):
logWindow = qt.QDialog(self)
logWidget = LogWidget(logWindow)
qt.QVBoxLayout(logWindow)
logWindow.layout().addWidget(logWidget)
logWindow.setAttribute(qt.Qt.WA_QuitOnClose, False)
logWindow.setWindowTitle("Log messages")
logWindow.rejected.connect(self.__saveLogWindowSettings)
self.__logWindow = logWindow
self.__logWidget = logWidget
logWidget.connect_logger(logging.root)
def __createLogWindow(self):
state = self.__flintState
logWindow = LoggingWindow(self, state)
logWindow.setAttribute(qt.Qt.WA_DeleteOnClose)
return logWindow
def _createSettingsMenu(self, parent) -> qt.QMenu:
settingsMenu = qt.QMenu(parent)
......@@ -177,8 +170,8 @@ class FlintWindow(qt.QMainWindow):
helpMenu.addAction(action)
stateIndicator = StateIndicator(self)
stateIndicator.setLogWidget(self.__logWidget)
stateIndicator.setFlintModel(self.__flintState)
stateIndicator.setLogModel(self.__flintState.logModel())
# widgetAction = qt.QWidgetAction(menubar)
# widgetAction.setDefaultWidget(stateIndicator)
# menubar.addAction(widgetAction)
......@@ -210,8 +203,16 @@ class FlintWindow(qt.QMainWindow):
def showLogDialog(self):
"""Show the log dialog of Flint"""
self.__logWindow.show()
self.__initLogWindowFromSettings()
state = self.__flintState
logWindow = state.logWindow()
if logWindow is None:
logWindow = self.__createLogWindow()
self.__flintState.setLogWindow(logWindow)
logWindow.finished.connect(self.__closeLogWindow)
logWindow.show()
def __closeLogWindow(self):
self.__flintState.setLogWindow(None)
def showAboutBox(self):
"""Show the about box of Flint"""
......@@ -302,23 +303,6 @@ class FlintWindow(qt.QMainWindow):
settings.setValue("pos", self.pos())
settings.endGroup()
def __initLogWindowFromSettings(self):
settings = self.__flintState.settings()
# resize window to 70% of available screen space, if no settings
settings.beginGroup("log-window")
if settings.contains("size"):
self.__logWindow.resize(settings.value("size"))
if settings.contains("pos"):
self.__logWindow.move(settings.value("pos"))
settings.endGroup()
def __saveLogWindowSettings(self):
settings = self.__flintState.settings()
settings.beginGroup("log-window")
settings.setValue("size", self.__logWindow.size())
settings.setValue("pos", self.__logWindow.pos())
settings.endGroup()
def createCustomPlot(
self,
plotWidget,
......
......@@ -119,6 +119,8 @@ class _ScanCache:
have_meaning = image_view.is_video_frame_have_meaning()
if have_meaning is not None:
self.video_frame_have_meaning[channel_name] = have_meaning
if not have_meaning:
_logger.debug("video disabled for %s", channel_name)
info = have_meaning
else:
# Default
......@@ -216,9 +218,12 @@ class ScanManager(bliss_scan.ScansObserver):
managed)."""
return scan_db_name in self.__cache
def on_scan_created(self, scan_db_name: str, scan_info: Dict):
_logger.debug("on_scan_created %s", scan_db_name)
def on_scan_started(self, scan_db_name: str, scan_info: Dict):
_logger.info("Scan started: %s", scan_info.get("title", scan_db_name))
_logger.debug("on_scan_created %s", scan_db_name)
_logger.debug("on_scan_started %s", scan_db_name)
if scan_db_name in self.__cache:
# We should receive a single new_scan per scan, but let's check anyway
_logger.debug("new_scan from %s ignored", scan_db_name)
......@@ -261,9 +266,7 @@ class ScanManager(bliss_scan.ScansObserver):
scan.scanStarted.emit()
def on_child_created(self, scan_db_name: str, node):
if not self.__is_alive_scan(scan_db_name):
_logger.debug("New scan child from %s ignored", scan_db_name)
return
_logger.debug("on_child_created %s: %s", scan_db_name, node.db_name)
def on_scalar_data_received(
self,
......@@ -425,11 +428,13 @@ class ScanManager(bliss_scan.ScansObserver):
self, cache: _ScanCache, image_view: lima_nodes.LimaDataView, channel_name: str
):
"""Try to reach the image"""
_logger.debug("reach image for %s", channel_name)
frame = None
try:
video_available = cache.is_video_available(image_view, channel_name)
if video_available:
try:
_logger.debug("get_last_live_image for %s", channel_name)
frame = image_view.get_last_live_image()
if frame.frame_number is None:
# This should never be triggered, as we should
......@@ -446,6 +451,7 @@ class ScanManager(bliss_scan.ScansObserver):
if not frame:
# Fallback to memory buffer or file
try:
_logger.debug("get_last_image for %s", channel_name)
frame = image_view.get_last_image()
except Exception:
_logger.debug(
......@@ -454,6 +460,9 @@ class ScanManager(bliss_scan.ScansObserver):
)
# Fallback again to the video
try:
_logger.debug(
"fallback with get_last_live_image for %s", channel_name
)
frame = image_view.get_last_live_image()
except ImageFormatNotSupported:
pass
......@@ -463,11 +472,13 @@ class ScanManager(bliss_scan.ScansObserver):
_logger.error("Error while reaching the last image", exc_info=True)
frame = None
# NOTE: This comparaison can be done by the Frame object (__bool__)
# NOTE: This check can be done by the Frame object (__bool__)
if not frame:
# Return an explicit None instead of an empty object
_logger.debug("frame for %s is empty", channel_name)
return None
_logger.debug("frame %s is returned for %s", frame.frame_number, channel_name)
return frame
def __process_data_event(self, data_event):
......
......@@ -129,6 +129,8 @@ class FlintState(qt.QObject):
icatClientChanged = qt.Signal()
logWindowChanged = qt.Signal()
def __init__(self, parent=None):
super(FlintState, self).__init__(parent=parent)
self.__workspace: Workspace = None
......@@ -136,6 +138,7 @@ class FlintState(qt.QObject):
self.__aliveScans: List[scan_model.Scan] = []
# FIXME: widget should be weakref
self.__liveWindow = None
self.__logWindow = None
self.__manager = None
self.__flintApi = None
self.__settings: Optional[qt.QSettings] = None
......@@ -144,6 +147,7 @@ class FlintState(qt.QObject):
self.__blissSessionName = None
self.__redisConnection = None
self.__icatClient = None
self.__logModel = None
self.__defaultScatterStyle: Optional[style_model.Style] = None
self.__defaultImageStyle: Optional[style_model.Style] = None
......@@ -190,6 +194,19 @@ class FlintState(qt.QObject):
def liveWindow(self) -> qt.QMainWindow:
return self.__liveWindow
def setLogWindow(self, window: qt.QMainWindow):
self.__logWindow = window
self.logWindowChanged.emit()
def logWindow(self) -> qt.QMainWindow:
return self.__logWindow
def setLogModel(self, model):
self.__logModel = model
def logModel(self) -> qt.QMainWindow:
return self.__logModel
def setFlintApi(self, flintApi):
self.__flintApi = flintApi
......
# -*- coding: utf-8 -*-
#
# This file is part of the bliss project
#
# Copyright (c) 2015-2022 Beamline Control Unit, ESRF
# Distributed under the GNU LGPLv3. See LICENSE for more info.
"""Module creating model around python logging library
"""
import logging
import weakref
import sys
import collections
from silx.gui import qt
class _QtLogHandler(logging.Handler):
def __init__(self, model):
logging.Handler.__init__(self)
self.__model: LoggingModel = weakref.ref(model)
def model(self):
"""
Returns the model widget connected to this handler.
The result can be None.
"""
return self.__model()
def emit(self, record: logging.LogRecord):
"""Receive a new log record."""
model = self.model()
if model is None:
return
try:
model.appendRecord(record)
except Exception:
self.handleError(record)
def handleError(self, record: logging.LogRecord):
model = self.model()
if model is None:
return
r = logging.LogRecord(
name="bliss.flint.model.logging_model",
level=logging.CRITICAL,
pathname=__file__,
lineno=45,
msg="Error while recording new records",
args=tuple(),
exc_info=sys.exc_info(),
)
model.appendRecord(r)
class LoggingModel(qt.QObject):
"""Provides a light layer to receive and store a cache of logging records
and few commands to edit the loggers configuration."""
recordReceived = qt.Signal(object)
levelConfigHaveChanged = qt.Signal(str)
levelsConfigHaveChanged = qt.Signal()
def __init__(self, parent=None):
super(LoggingModel, self).__init__(parent=parent)
self.__records = collections.deque()
self.__maximumLogCount = 200
self.__handlers = weakref.WeakKeyDictionary()
qt.QCoreApplication.instance().aboutToQuit.connect(self.disconnectAll)
def setMaximumLogCount(self, maximum: int):
self.__maximumLogCount = maximum
def maximumLogCount(self) -> int:
return self.__maximumLogCount
def records(self):
return list(self.__records)
def appendRecord(self, record: logging.LogRecord):
"""Add a record to the widget.
The update of the display is done asynchronously
"""
self.__records.append(record)
if len(self.__records) > self.__maximumLogCount:
self.__records.pop()
self.recordReceived.emit(record)
def connectRootLogger(self):
"""
Connect this model to the root logger.
"""
self.connectLogger(logging.root)
def connectLogger(self, logger: logging.Logger):
"""
Connect this model to a specific logger.
"""
handler = _QtLogHandler(self)
handler.setFormatter(
logging.Formatter("%(asctime)s - %(levelname)s: %(message)s")
)
logger.addHandler(handler)
self.__handlers[handler] = logger
def disconnectAll(self):
for handler, logger in self.__handlers.items():
logger.removeHandler(handler)
self.__handlers = {}
def setLevel(self, name, level):
"""
Change the level of a logger.
"""
logger = logging.getLogger(name)
logger.setLevel(level)
self.levelConfigHaveChanged.emit(name)
def setLevels(self, levels, reset=True):
"""
Change the level of set of loggers.
Arguments:
reset: If true, unspecified logger levels are reset to 0 (`NOTSET`)
"""
if reset:
names = list(logging.Logger.manager.loggerDict.keys())
names.append(None)
else:
names = levels.keys()
for name in names:
logger = logging.getLogger(name)
level = levels.get(name, logging.NOTSET)
logger.setLevel(level)
self.levelsConfigHaveChanged.emit()
......@@ -11,50 +11,20 @@ Provide a widget to display logs from `logging` Python module.
from __future__ import annotations
from typing import Union
from typing import Optional
from typing import List
import sys
import logging
import functools
import weakref
import traceback
from silx.gui import qt
from silx.gui.utils import concurrent
from bliss.flint.model import logging_model
from .logging_widgets import colorFromLevel
class _QtLogHandler(logging.Handler):
def __init__(self, log_widget):
logging.Handler.__init__(self)
self._log_widget = weakref.ref(log_widget)
def get_log_widget(self):
"""
Returns the log widget connected to this handler.
The result can be None.
"""
return self._log_widget()
def emit(self, record):
widget = self.get_log_widget()
if widget is None:
return
try:
concurrent.submitToQtMainThread(widget.emit, record)
except Exception:
self.handleError(record)
def handleError(self, record):
t, v, tb = sys.exc_info()
msg = "%s %s %s" % (t, v, "".join(traceback.format_tb(tb)))
widget = self.get_log_widget()
if widget is None:
return
concurrent.submitToQtMainThread(widget.emit, msg)
class LogWidget(qt.QTreeView):
class LoggingList(qt.QTreeView):
"""Display messages from the Python logging system.
By default only the 100 last messages are displayed. This can be customed
......@@ -66,29 +36,40 @@ class LogWidget(qt.QTreeView):
ModuleNameColumn = 2
MessageColumn = 3
activated = qt.Signal()
"""Sent when the window get the focus"""
RecordRole = qt.Qt.UserRole + 1
logSelected = qt.Signal(str)
"""Emitted when a log record was selected
logEmitted = qt.Signal(int)
"""Sent when a log was added"""
The event contain the name of the logger.
"""
def __init__(self, parent=None):
super(LogWidget, self).__init__(parent=parent)
super(LoggingList, self).__init__(parent=parent)
self.setEditTriggers(qt.QAbstractItemView.NoEditTriggers)
self.setWordWrap(True)
self.setTextElideMode(qt.Qt.ElideRight)
self._handlers = weakref.WeakKeyDictionary()
self.destroyed.connect(functools.partial(self._remove_handlers, self._handlers))
self._maximumLogCount = 0
self.setMaximumLogCount(100)
self._formatter = logging.Formatter()
self._records = []
self.__logModel: logging_model.LoggingModel = None
self._timer = qt.QTimer(self)
self._timer.setInterval(500)
self._timer.timeout.connect(self.flushRecords)
self._timer.start()
model = qt.QStandardItemModel(self)
model.setColumnCount(4)
model.setHorizontalHeaderLabels(["Date/time", "Level", "Module", "Message"])
self.setModel(model)
selectionModel = self.selectionModel()
selectionModel.currentRowChanged.connect(self.__currentRowChanged)
self.setAlternatingRowColors(True)
# It could be very big cells so per pixel is better
......@@ -105,9 +86,20 @@ class LogWidget(qt.QTreeView):
)
header.setSectionResizeMode(self.MessageColumn, qt.QHeaderView.Stretch)
def focusInEvent(self, event):
self.activated.emit()
return super(LogWidget, self).focusInEvent(event)
def setLogModel(self, model: logging_model.LoggingModel):
if self.__logModel is not None:
self.__logModel.recordReceived.disconnect(self.appendRecord)
self.__logModel = model
if self.__logModel is not None:
self._records += model.records()
self.__logModel.recordReceived.connect(self.appendRecord)
def __currentRowChanged(self, current: qt.QModelIndex, previous: qt.QModelIndex):
if current.parent() == qt.QModelIndex():
model = self.model()
i = model.index(current.row(), 2)
name = model.data(i)
self.logSelected.emit(name)
@staticmethod
def _remove_handlers(handlers):
......@@ -117,28 +109,12 @@ class LogWidget(qt.QTreeView):
logger.removeHandler(handler)
handlers.clear()
def setMaximumLogCount(self, maximum):
self._maximumLogCount = maximum
def logCount(self):
"""
Returns the amount of log messages displayed.
"""
return self.model().rowCount()
def colorFromLevel(self, levelno: int):
if levelno >= logging.CRITICAL:
return qt.QColor(240, 0, 240)
elif levelno >= logging.ERROR:
return qt.QColor(255, 0, 0)
elif levelno >= logging.WARNING:
return qt.QColor(180, 180, 0)
elif levelno >= logging.INFO:
return qt.QColor(0, 0, 255)
elif levelno >= logging.DEBUG:
return qt.QColor(0, 200, 200)
return qt.QColor(0, 255, 0)
def _formatStack(self, record: logging.LogRecord):
s = ""
if record.exc_info:
......@@ -165,83 +141,95 @@ class LogWidget(qt.QTreeView):
causes = [c.strip() for c in causes]
return list(reversed(causes))
def emit(self, record: Union[str, logging.LogRecord]):
record2: Optional[logging.LogRecord] = None
if isinstance(record, str):
message = record
else:
try:
message = record.getMessage()
record2 = record
except Exception as e:
# In case there is a wrong call of logging methods
message = "Error in logs: " + e.args[0]
message += "\nMessage: %r" % record.msg
message += "\nArguments: %s" % record.args
def appendRecord(self, record: Union[str, logging.LogRecord]):
"""Add a record to the widget.
The update of the display is done asynchronously
"""
self._records.append(record)
def flushRecords(self):
records = self._records
if records == []:
return
self._records = []
# FIXME: Some could drop records if more than _maximumLogCount
self.addRecords(records)
def addRecords(self, records: List[Union[str, logging.LogRecord]]):
scroll = self.verticalScrollBar()
makeLastVisible = scroll.value() == scroll.maximum()
for record in records:
self.__displayRecord(record)
model: qt.QStandardItemModel = self.model()
maxLogs = self.__logModel.maximumLogCount()
if model.rowCount() > maxLogs:
count = model.rowCount() - maxLogs
# Always remove an even amount of items to avoid blinking with alternatingRowColorswith
count += count % 2
model.removeRows(0, count)
if makeLastVisible:
self.scrollToBottom()
def recordFromIndex(self, index: qt.QModelIndex) -> logging.LogRecord:
if index.parent() != qt.QModelIndex():
return None
m = self.model()
i = m.index(index.row(), self.DateTimeColumn)
record = m.data(i, self.RecordRole)
return record
def __displayRecord(self, record: logging.LogRecord):
try:
if record2 is not None:
dt = self._formatter.formatTime(record2)
dateTimeItem = qt.QStandardItem(dt)
levelItem = qt.QStandardItem(record2.levelname)
levelno = record2.levelno
color = self.colorFromLevel(levelno)
levelItem.setForeground(color)
nameItem = qt.QStandardItem(record2.name)
messageItem = qt.QStandardItem(message)
stack = self._formatStack(record2)
if stack != "":
causes = self.__splitCauses(stack)
parentItem = dateTimeItem
for i, cause in enumerate(causes):
title = qt.QStandardItem("Backtrace" if i == 0 else "Caused by")
parentItem.appendRow(
[
title,
qt.QStandardItem(),
qt.QStandardItem(),
qt.QStandardItem(cause),
]
)
parentItem = title
else:
dateTimeItem = None
message = record.getMessage()
except Exception as e:
# In case there is a wrong call of logging methods
message = "Error in logs: " + e.args[0]
message += "\nMessage: %r" % record.msg
message += "\nArguments: %s" % record.args
dateTimeItem = None
try:
dt = self._formatter.formatTime(record)
dateTimeItem = qt.QStandardItem(dt)
dateTimeItem.setData(record, self.RecordRole)
levelItem = qt.QStandardItem(record.levelname)
levelno = record.levelno
color = colorFromLevel(levelno, 128)
levelItem.setBackground(color)
nameItem = qt.QStandardItem(record.name)
messageItem = qt.QStandardItem(message)
stack = self._formatStack(record)
if stack != "":
causes = self.__splitCauses(stack)
parentItem = dateTimeItem
for i, cause in enumerate(causes):
title = qt.QStandardItem("Backtrace" if i == 0 else "Caused by")
parentItem.appendRow(
[
title,
qt.QStandardItem(),
qt.QStandardItem(),
qt.QStandardItem(cause),
]
)
parentItem = title
except Exception:
# Make sure everything is fine
dateTimeItem = None
sys.excepthook(*sys.exc_info())
if dateTimeItem is None:
dateTimeItem = qt.QStandardItem()
levelItem = qt.QStandardItem("CRITICAL")
levelno = logging.CRITICAL
color = self.colorFromLevel(levelno)
levelItem.setForeground(color)
color = colorFromLevel(levelno, 128)
levelItem.setBackground(color)
nameItem = qt.QStandardItem()
messageItem = qt.QStandardItem(message)
scroll = self.verticalScrollBar()
makeLastVisible = scroll.value() == scroll.maximum()
model: qt.QStandardItemModel = self.model()
model.appendRow([dateTimeItem, levelItem, nameItem, messageItem])
self.logEmitted.emit(levelno)
if model.rowCount() > self._maximumLogCount:
count = model.rowCount() - self._maximumLogCount
model.removeRows(0, count)
if makeLastVisible:
self.scrollToBottom()
def connect_logger(self, logger):
"""
Connect the widget to a specific logger.
"""
handler = _QtLogHandler(self)
handler.setFormatter(
logging.Formatter("%(asctime)s - %(levelname)s: %(message)s")
)
logger.addHandler(handler)
self._handlers[handler] = logger
# -*- coding: utf-8 -*-
#
# This file is part of the bliss project
#
# Copyright (c) 2015-2022 Beamline Control Unit, ESRF
# Distributed under the GNU LGPLv3. See LICENSE for more info.
"""
Provide a widget to display logs from `logging` Python module.
"""
from __future__ import annotations
import logging
from silx.gui import qt
from silx.gui import utils
from bliss.flint.model import logging_model
_levelToColor = {
logging.CRITICAL: (199, 78, 129),
logging.ERROR: (253, 113, 98),
logging.WARNING: (251, 202, 88),
logging.INFO: (122, 184, 240),
logging.DEBUG: (127, 212, 142),
logging.NOTSET: (150, 150, 150),
}
def colorFromLevel(levelno: int, alpha: int = 255) -> qt.QColor:
"""
Returns a color from a logging level.
The returned color is one from `_levelToColor`.
Arguments:
levelno: The level, which can be a value between names levels (for example 45)
alpha: An optional alpha channel for the opacity.
"""
if levelno >= logging.CRITICAL:
norm = logging.CRITICAL
elif levelno >= logging.ERROR:
norm = logging.ERROR
elif levelno >= logging.WARNING:
norm = logging.WARNING
elif levelno >= logging.INFO:
norm = logging.INFO
elif levelno >= logging.DEBUG:
norm = logging.DEBUG
else:
norm = logging.NOTSET
rgb = _levelToColor[norm]
return qt.QColor(*rgb, alpha)
class LoggerNameComboBox(qt.QComboBox):
def __init__(self, parent=None):
super(LoggerNameComboBox, self).__init__(parent=parent)
self.setInsertPolicy(qt.QComboBox.InsertAlphabetically)
self.setEditable(True)
self.setMinimumWidth(300)
names = logging.Logger.manager.loggerDict.keys()
completer = qt.QCompleter(names, self)
completer.setCaseSensitivity(qt.Qt.CaseInsensitive)
self.setCompleter(completer)
self.refresh()
def setLoggerName(self, name):
self.setEditText(name)
self.activated[str].emit(name)
def setLogModel(self, model: logging_model.LoggingModel):
self.__logModel = model
model.levelsConfigHaveChanged.connect(self.refresh)
def refresh(self):
currentText = self.currentText()
self.clear()
names = sorted(logging.Logger.manager.loggerDict.keys())
for name in sorted(names):
logger = logging.getLogger(name)
if logger.level != logging.NOTSET:
self.addItem(name)
self.insertItem(0, "ROOT")
with utils.blockSignals(self):
self.setEditText(currentText)
def keyPressEvent(self, event: qt.QKeyEvent):
super(LoggerNameComboBox, self).keyPressEvent(event)
if event.key() in (qt.Qt.Key_Enter, qt.Qt.Key_Return):
# Skip parent propagation
event.accept()
class LoggerLevelEdit(qt.QWidget):
def __init__(self, parent=None):
super(LoggerLevelEdit, self).__init__(parent=parent)
self.__group = qt.QButtonGroup()
self.__group.setExclusive(False)
layout = qt.QHBoxLayout(self)
layout.setSpacing(1)
layout.setContentsMargins(0, 0, 0, 0)
self.__buttons = {}
self.__loggerName = None
for num, name in logging._levelToName.items():
btn = qt.QPushButton(self)
btn.setCheckable(True)
btn.setText(f" {name} ")
btn.setToolTip(f"Set the level to {name} (={num})")
r, g, b = _levelToColor.get(num, [128, 128, 128])
textcolor = "white" if name == "CRITICAL" else "black"
btn.setStyleSheet(
f"""
.QPushButton {{
background-color: rgb(200,200,200);
border: 0px;
border-radius: 9px;
color: black;
padding: 1px;
font-size: 12px;
}}
.QPushButton:checked {{
background-color: rgb({r}, {g}, {b});
color: {textcolor};
}}
"""
)
self.__buttons[num] = btn
self.__group.addButton(btn)
layout.addWidget(btn)
self.__group.buttonClicked.connect(self.__levelSelected)
def setLogModel(self, model: logging_model.LoggingModel):
self.__logModel = model
model.levelsConfigHaveChanged.connect(self.__updateDispay)
def setLoggerName(self, name):
if self.__loggerName == name:
return
self.__loggerName = name
self.__updateDispay()
def loggerName(self):
return self.__loggerName
def refresh(self):
"""
Refresh the widget in case the logging was manually changed.
"""
self.__updateDispay()
def __updateDispay(self):
name = self.__loggerName
if name == "ROOT":
name = None
logger = logging.getLogger(name)
level = logger.level
with utils.blockSignals(self.__group):
for buttonLevel, button in self.__buttons.items():
with utils.blockSignals(button):
button.setEnabled(self.__loggerName is not None)
if level == logging.NOTSET:
button.setChecked(
buttonLevel == logging.NOTSET
or buttonLevel >= logging.WARNING
)
else:
button.setChecked(buttonLevel >= level)
def __levelSelected(self, button):
name = self.loggerName()
if name is None:
return
for level, b in self.__buttons.items():
if button is b:
break
else:
return
if name == "ROOT":
name = None
self.__logModel.setLevel(name, level)
self.__updateDispay()
class LogProfileAction(qt.QAction):
def __init__(self, parent):
super(LogProfileAction, self).__init__(parent=parent)
self.__loglevels = {}
self.triggered.connect(self.__activate)
def setLogLevels(self, loglevels):
self.__loglevels = loglevels
def __activate(self):
names = list(logging.Logger.manager.loggerDict.keys())
names.append(None)
logModel = self.parent().logModel()
logModel.setLevels(self.__loglevels, reset=True)
class LogProfiles(qt.QToolButton):
logChanged = qt.Signal()
def __init__(self, parent=None):
super(LogProfiles, self).__init__(parent=parent)
self.setText("Profiles")
self.setToolTip("Load predefined log levels")
self.setPopupMode(qt.QToolButton.InstantPopup)
menu = qt.QMenu(self)
self.setMenu(menu)
profile = LogProfileAction(self)
profile.setText("Default levels")
profile.setLogLevels({None: logging.WARNING, "bliss": logging.INFO})
self.addProfileAction(profile)
profile = LogProfileAction(self)
profile.setText("Debug levels")
profile.setLogLevels({None: logging.DEBUG, "matplotlib": logging.INFO})
self.addProfileAction(profile)
profile = LogProfileAction(self)
profile.setText("Debug scan listener")
profile.setLogLevels(
{
None: logging.WARNING,
"bliss": logging.INFO,
"bliss.flint.manager.scan_manager": logging.DEBUG,
}
)
self.addProfileAction(profile)
profile = LogProfileAction(self)
profile.setText("Clear all levels")
self.addProfileAction(profile)
def setLogModel(self, model: logging_model.LoggingModel):
self.__logModel = model
def logModel(self):
return self.__logModel
def addProfileAction(self, action):
menu = self.menu()
action.triggered.connect(self.__logChanged)
menu.addAction(action)
def __logChanged(self):
self.logChanged.emit()
# -*- coding: utf-8 -*-
#
# This file is part of the bliss project
#
# Copyright (c) 2015-2022 Beamline Control Unit, ESRF
# Distributed under the GNU LGPLv3. See LICENSE for more info.
"""Module containing the description of the logging window provided by Flint"""
from __future__ import annotations
import logging
from silx.gui import qt
from bliss.flint.widgets.logging_list import LoggingList
from bliss.flint.model import flint_model
from bliss.flint.widgets import logging_widgets
class LoggingWindow(qt.QDialog):
activated = qt.Signal()
"""Sent when the window get the focus"""
def __init__(self, parent, model: flint_model.FlintState):
qt.QDialog.__init__(self, parent=parent)
self.setWindowTitle("Log messages")
self.__flintState = model
logModel = model.logModel()
logLevelEdit = logging_widgets.LoggerLevelEdit(self)
logLevelEdit.setLogModel(logModel)
logCombo = logging_widgets.LoggerNameComboBox(self)
logCombo.setLogModel(logModel)
logProfile = logging_widgets.LogProfiles(self)
logProfile.setLogModel(logModel)
logWidget = LoggingList(self)
logWidget.setLogModel(logModel)
self.__logWidget = logWidget
toolbar = qt.QToolBar(self)
toolbar.addWidget(logCombo)
toolbar.addWidget(logLevelEdit)
toolbar.addSeparator()
toolbar.addWidget(logProfile)
logWidget.logSelected.connect(logCombo.setLoggerName)
logWidget.doubleClicked.connect(self.__doubleClicked)
logCombo.activated[str].connect(logLevelEdit.setLoggerName)
# logProfile.logChanged.connect(logCombo.refresh)
logCombo.setLoggerName("ROOT")
layout = qt.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(1)
layout.addWidget(toolbar)
layout.addWidget(logWidget)
self.rejected.connect(self.__saveLogWindowSettings)
self.__initLogWindowFromSettings()
def __doubleClicked(self, index):
record = self.__logWidget.recordFromIndex(index)
if record is None:
return
# _logger.error("%s %s %s", type_, value, ''.join(traceback.format_tb(trace)))
msg = qt.QMessageBox()
msg.setWindowTitle("Logging record")
if record.levelno > logging.WARNING:
icon = qt.QMessageBox.Critical
elif record.levelno > logging.INFO:
icon = qt.QMessageBox.Warning
else:
icon = qt.QMessageBox.Information
msg.setIcon(icon)
try:
message = record.getMessage()
except Exception as e:
# In case there is a wrong call of logging methods
message = "Error in logs: " + e.args[0]
message += "\nMessage: %r" % record.msg
message += "\nArguments: %s" % record.args
cuts = message.split("\n", 1)
msg.setInformativeText(cuts[0])
msg.setDetailedText(message)
msg.raise_()
msg.exec()
def focusInEvent(self, event):
self.activated.emit()
return super(LoggingWindow, self).focusInEvent(event)
def __initLogWindowFromSettings(self):
settings = self.__flintState.settings()
# resize window to 70% of available screen space, if no settings
settings.beginGroup("log-window")
if settings.contains("size"):
self.resize(settings.value("size"))
if settings.contains("pos"):
self.move(settings.value("pos"))
settings.endGroup()
def __saveLogWindowSettings(self):
settings = self.__flintState.settings()
settings.beginGroup("log-window")
settings.setValue("size", self.size())
settings.setValue("pos", self.pos())
settings.endGroup()
......@@ -185,6 +185,42 @@ class _SingleScanStatus(qt.QWidget):
self.__updateChildScan()
class _OtherScansStatus(qt.QLabel):
def __init__(self, parent=None):
qt.QLabel.__init__(self, parent=parent)
self.setAlignment(qt.Qt.AlignCenter)
self.setVisible(False)
self.__scans = []
def addScan(self, scan):
self.__scans.append(scan)
self.__updateDisplay()
def removeScan(self, scan):
try:
self.__scans.remove(scan)
except Exception:
pass
else:
self.__updateDisplay()
def popScan(self):
if len(self.__scans) == 0:
return None
scan = self.__scans.pop(0)
self.__updateDisplay()
return scan
def __updateDisplay(self):
self.setVisible(len(self.__scans) != 0)
if len(self.__scans) == 0:
pass
elif len(self.__scans) == 1:
self.setText("plus another scan...")
else:
self.setText(f"plus {len(self.__scans)} other scans...")
class ScanStatus(ExtendedDockWidget):
def __init__(self, parent=None):
super(ScanStatus, self).__init__(parent=parent)
......@@ -193,6 +229,7 @@ class ScanStatus(ExtendedDockWidget):
_ = qt.QVBoxLayout(self.__widget)
self.__scanWidgets: List[_SingleScanStatus] = []
self.__otherScans = _OtherScansStatus(parent=self)
# Try to improve the look and feel
# FIXME: THis should be done with stylesheet
......@@ -200,6 +237,8 @@ class ScanStatus(ExtendedDockWidget):
frame.setFrameShape(qt.QFrame.StyledPanel)
layout = qt.QVBoxLayout(frame)
layout.addWidget(self.__widget)
layout.addStretch(1)
layout.addWidget(self.__otherScans)
layout.setContentsMargins(0, 0, 0, 0)
widget = qt.QFrame(self)
layout = qt.QVBoxLayout(widget)
......@@ -287,19 +326,30 @@ class ScanStatus(ExtendedDockWidget):
self.__scanWidgets.remove(widget)
widget.deleteLater()
def __aliveScanAdded(self, scan):
scan = self.__otherScans.popScan()
if scan:
self.__feedWidgetFromScan(scan)
def __feedWidgetFromScan(self, scan):
if scan.group() is not None:
widget = self.__getWidgetByScan(scan.group())
if widget is not None:
widget.setActiveChildScan(scan)
return
widget = _SingleScanStatus(self)
widget.setScan(scan)
self.__addScanWidget(widget)
if len(self.__scanWidgets) < 6:
widget = _SingleScanStatus(self)
widget.setScan(scan)
self.__addScanWidget(widget)
else:
self.__otherScans.addScan(scan)
def __aliveScanAdded(self, scan):
self.__feedWidgetFromScan(scan)
def __aliveScanRemoved(self, scan):
self.__removeWidgetFromScan(scan)
self.__otherScans.removeScan(scan)
def __currentScanChanged(self):
# TODO: The current scan could be highlighted
......
......@@ -11,7 +11,8 @@ import logging
from silx.gui import qt
from bliss.flint.model import flint_model
from bliss.flint.widgets.log_widget import LogWidget
from bliss.flint.model.logging_model import LoggingModel
from bliss.flint.widgets.logging_widgets import colorFromLevel
class StateIndicator(qt.QWidget):
......@@ -35,8 +36,9 @@ class StateIndicator(qt.QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.__button)
self.__model: flint_model.FlintState = None
self.__logWidget: LogWidget = None
self.__logModel: LoggingModel = None
self.__lastLevelNo: int = 0
self.__logWindow = None
def __clicked(self):
flintWindow = self.__model.mainWindow()
......@@ -44,11 +46,23 @@ class StateIndicator(qt.QWidget):
def setFlintModel(self, model: flint_model.FlintState):
self.__model = model
self.__model.logWindowChanged.connect(self.__logWindowChanged)
self.__logWindowChanged()
def setLogWidget(self, logWidget: LogWidget):
logWidget.logEmitted.connect(self.__logEmitted)
logWidget.activated.connect(self.__logWidgetActivated)
self.__logWidget = logWidget
def __logWindowChanged(self):
window = self.__model.logWindow()
self.setLogWindow(window)
def setLogWindow(self, window):
if self.__logWindow is not None:
self.__logWindow.activated.disconnect(self.__logWindowActivated)
self.__logWindow = window
if self.__logWindow is not None:
self.__logWindow.activated.connect(self.__logWindowActivated)
def setLogModel(self, model: LoggingModel):
model.recordReceived.connect(self.__recordReceived)
self.__logModel = model
def __createCircleIcon(self, color: qt.QColor):
pixmap = qt.QPixmap(10, 10)
......@@ -61,21 +75,22 @@ class StateIndicator(qt.QWidget):
painter.end()
return qt.QIcon(pixmap)
def __logEmitted(self, levelno: int):
def __recordReceived(self, record):
levelno = record.levelno
if levelno <= self.__lastLevelNo:
return
if levelno < logging.WARNING:
return
if self.__logWidget.isActiveWindow():
if self.__logWindow and self.__logWindow.isActiveWindow():
return
self.__lastLevelNo = levelno
color = self.__logWidget.colorFromLevel(levelno)
color = colorFromLevel(levelno)
icon = self.__createCircleIcon(color)
self.__action.setIcon(icon)
self.__action.setEnabled(True)
self.__action.setToolTip("Unread logging messages")
def __logWidgetActivated(self):
def __logWindowActivated(self):
self.__lastLevelNo = 0
self.__action.setIcon(qt.QIcon())
self.__action.setEnabled(False)
......
......@@ -31,7 +31,11 @@ def wait_for(stream, target: bytes, timeout=None):
@contextlib.contextmanager
def start_tango_server(*cmdline_args, **kwargs):
def start_tango_server(*cmdline_args, check_children=False, **kwargs):
"""
Arguments:
check_children: If true, children PID are also checked during the terminating
"""
device_fqdn = kwargs["device_fqdn"]
exception = None
for _ in range(3):
......@@ -52,10 +56,10 @@ def start_tango_server(*cmdline_args, **kwargs):
object.__setattr__(dev_proxy, "server_pid", p.pid)
yield dev_proxy
finally:
wait_terminate(p)
wait_terminate(p, check_children=check_children)
def wait_terminate(process, timeout=10):
def wait_terminate(process, timeout=10, check_children=False):
"""
Try to terminate a process then kill it.
......@@ -64,10 +68,11 @@ def wait_terminate(process, timeout=10):
Arguments:
process: A process object from `subprocess` or `psutil`, or an PID int
timeout: Timeout to way before using a kill signal
check_children: If true, check children pid and force there termination
Raises:
gevent.Timeout: If the kill fails
"""
children = []
if isinstance(process, int):
try:
name = str(process)
......@@ -80,6 +85,12 @@ def wait_terminate(process, timeout=10):
if process.poll() is not None:
eprint(f"Process {name} already terminated with code {process.returncode}")
return
if check_children:
if not isinstance(process, psutil.Process):
process = psutil.Process(process.pid)
children = process.children(recursive=True)
process.terminate()
try:
with gevent.Timeout(timeout):
......@@ -93,3 +104,18 @@ def wait_terminate(process, timeout=10):
# gevent timeout have to be used here
# See https://github.com/gevent/gevent/issues/622
process.wait()
if check_children:
for i in range(10):
_gone, alive = psutil.wait_procs(children, timeout=1)
if alive == []:
break
for p in alive:
if i < 3:
p.terminate()
else:
p.kill()
else:
raise RuntimeError(
"Timeout expired after 10 seconds. Process %s still alive." % alive
)
......@@ -5,7 +5,7 @@
# Copyright (c) 2015-2022 Beamline Control Unit, ESRF
# Distributed under the GNU LGPLv3. See LICENSE for more info.
from typing import Optional
from typing import Dict, Optional
from blissdata.beacon.data import BeaconData
from blissdata.redis.manager import RedisConnectionManager, RedisAddress
......@@ -26,6 +26,10 @@ def configure_with_beacon_address(
0: RedisAddress.factory(beacon_client.get_redis_db()),
1: RedisAddress.factory(beacon_client.get_redis_data_db()),
}
configure_redis_databases(addresses)
def configure_redis_databases(addresses: Dict[int, RedisAddress]):
redis_connection_manager = RedisConnectionManager(addresses)
def redis_connection_manager_cb():
......
......@@ -9,6 +9,15 @@ import fnmatch
from collections.abc import Mapping
class UndefinedType:
__slots__ = []
Undefined = UndefinedType()
"""Can be used as default function argument when a `None` value is a valid
and optional input. For example for a default value from a `dict.get` method."""
def deep_update(d, u):
"""Do a deep merge of one dict into another.
......