diff --git a/bin/secop-server b/bin/secop-server index 779ae6d..4776545 100755 --- a/bin/secop-server +++ b/bin/secop-server @@ -30,8 +30,8 @@ from os import path # Add import path for inplace usage sys.path.insert(0, path.abspath(path.join(path.dirname(__file__), '..'))) -from secop.lib import getGeneralConfig -from secop.logging import initLogging +from secop.lib import generalConfig +from secop.logging import logger from secop.server import Server @@ -75,6 +75,11 @@ def parseArgv(argv): action='store_true', help='check cfg files only', default=False) + parser.add_argument('-r', + '--relaxed', + action='store_true', + help='no checking of problematic behaviour', + default=False) return parser.parse_args(argv) @@ -85,9 +90,15 @@ def main(argv=None): args = parseArgv(argv[1:]) loglevel = 'debug' if args.verbose else ('error' if args.quiet else 'info') - getGeneralConfig(args.gencfg) - log = initLogging(loglevel) - srv = Server(args.name, log, cfgfiles=args.cfgfiles, interface=args.port, testonly=args.test) + if args.relaxed: + generalConfig.defaults['lazy_number_validation'] = True + generalConfig.defaults['disable_value_range_check'] = True + generalConfig.defaults['legacy_hasiodev'] = True + generalConfig.defaults['tolerate_poll_property'] = True + generalConfig.init(args.gencfg) + logger.init(loglevel) + + srv = Server(args.name, logger.log, cfgfiles=args.cfgfiles, interface=args.port, testonly=args.test) if args.daemonize: srv.start() diff --git a/ci/Jenkinsfile b/ci/Jenkinsfile index 44b54a5..85dac19 100644 --- a/ci/Jenkinsfile +++ b/ci/Jenkinsfile @@ -30,6 +30,8 @@ def changedFiles = ''; def run_pylint(pyver) { stage ('pylint-' + pyver) { + def cpylint = "RUNNING" + gerritPostCheck(["jenkins:pylint_${pyver}": cpylint]) def status = 'OK' changedFiles = sh returnStdout: true, script: '''\ #!/bin/bash @@ -56,8 +58,8 @@ fi withCredentials([string(credentialsId: 'GERRITHTTP', variable: 'GERRITHTTP')]) { sh """\ - #!/bin/bash - if [ -f pylint_results.txt ] ; then +#!/bin/bash + if [ -f pylint_results.txt ] ; then /home/jenkins/tools2/bin/pylint2gerrit mv pylint_results.txt pylint-${pyver}.txt else @@ -68,18 +70,15 @@ fi echo "pylint result: $res" this.verifyresult.put('pylint'+pyver, 1) + cpylint = "SUCCESSFUL" if ( res != 0 ) { currentBuild.result='FAILURE' this.verifyresult.put('pylint'+ pyver, -1) status = 'FAILURE' + cpylint = "FAILED" } - gerritverificationpublisher([ - verifyStatusValue: this.verifyresult['pylint'+pyver], - verifyStatusCategory: 'pylint ', - verifyStatusName: 'pylint-'+pyver, - verifyStatusReporter: 'jenkins', - verifyStatusRerun: '!recheck']) + gerritPostCheck(["jenkins:pylint_${pyver}": cpylint]) archiveArtifacts([allowEmptyArchive: true, artifacts: 'pylint-*.txt']) recordIssues([enabledForFailure: true, @@ -99,7 +98,9 @@ fi def run_tests(pyver) { stage('Test:' + pyver) { - writeFile file: 'setup.cfg', text: ''' + def cpytest = "RUNNING" + gerritPostCheck(["jenkins:pytest_${pyver}":"RUNNING"]) + writeFile file: 'setup.cfg', text: ''' [tool:pytest] addopts = --junit-xml=pytest.xml --junit-prefix=''' + pyver @@ -116,18 +117,15 @@ python3 setup.py develop make test ''' verifyresult.put(pyver, 1) + cpytest = "SUCCESSFUL" } } catch (all) { currentBuild.result = 'FAILURE' status = 'FAILURE' + cpytest= "FAILED" verifyresult.put(pyver, -1) } - gerritverificationpublisher([ - verifyStatusValue: verifyresult[pyver], - verifyStatusCategory: 'test ', - verifyStatusName: 'pytest-'+pyver, - verifyStatusReporter: 'jenkins', - verifyStatusRerun: '!recheck']) + gerritPostCheck(["jenkins:pytest_${pyver}":cpytest]) step([$class: 'JUnitResultArchiver', allowEmptyResults: true, keepLongStdio: true, testResults: 'pytest.xml']) @@ -138,6 +136,8 @@ make test } def run_docs() { + def cdocs = "RUNNING" + gerritPostCheck(["jenkins:docs":cdocs]) stage('prepare') { sh ''' . /home/jenkins/secopvenv/bin/activate @@ -185,15 +185,9 @@ def run_docs() { stage('store html doc for build') { publishHTML([allowMissing: false, alwaysLinkToLastBuild: false, keepAll: true, reportDir: 'doc/_build/html', reportFiles: 'index.html', reportName: 'Built documentation', reportTitles: '']) - gerritverificationpublisher([ - verifyStatusValue: 1, - verifyStatusCategory: 'test ', - verifyStatusName: 'doc', - verifyStatusReporter: 'jenkins', - verifyStatusRerun: '@recheck' - ]) + cdocs = "SUCCESSFUL" } - + gerritPostCheck(["jenkins:docs":cdocs]) } diff --git a/debian/changelog b/debian/changelog index 84333b3..2b822a0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,111 @@ +secop-core (0.12.4) focal; urgency=medium + + * fix command inheritance + + -- Markus Zolliker Thu, 11 Nov 2021 16:21:19 +0100 + +secop-core (0.12.3) focal; urgency=medium + + [ Georg Brandl ] + * Makefile: fix docker image + + [ Markus Zolliker ] + * various fixes + * remove irrelevant comments + * introduce BytesIO + * GUI fixes + * persistent params / trinamic motor + * fix Parameter/Command copy method + * show first instead of last traceback on multiple errors + * fix parameter inheritance + * fix property inheritance + * fix python 3.5 compatibility + * omit updates of unchanged values within short time + * improve simulation + * automatically register subclasses of AsynConn + * fix feature for removing commands + + -- Georg Brandl Wed, 10 Nov 2021 16:33:19 +0100 + +secop-core (0.12.2) focal; urgency=medium + + [ Markus Zolliker ] + * fix issue with new syntax in simulation + * treat specifier of describe message + * allow to remove accessibles + + [ Enrico Faulhaber ] + * secop_mlz: small fixes + + -- Markus Zolliker Tue, 18 May 2021 10:29:17 +0200 + +secop-core (0.12.1) focal; urgency=medium + + * remove secop-console from debian *.install file + + -- Enrico Faulhaber Tue, 04 May 2021 09:42:53 +0200 + +secop-core (0.12.0) focal; urgency=medium + + [ Markus Zolliker ] + * make datatypes immutable + * customizable general config + * support for multiple secop servers + * secop.asynconn without pyserial + * change cfg file format + * fix bug in secop.gui.valuewidgets + * fix deadlock when reconnecting client + * allow class instead of class name in proxy_class + * fix pylint command in Makefile + * change arguments of stringio-server + * router bug fix + * introduce update callbacks + * improve error handling on client connections + * ppms: improve status and temperature + * rework tcp server + * cosmetics on datatypes.TextType + * improve error handling in SecopClient + * improve HasIodev + * HasIodev bug fix + * fix handling of StructOf datatype + * more flexible end_of_line in stringio + * improvements on PPMS and LS370 + * add readbytes method to AsynConn + * Param(..., initwrite=True) works only with poll=True + * fix initwrite behaviour + * make order of accessibles work again + * main module of LS370 is now drivable + * improve softcal + * make arguments of Parameter and Override consistent + * new syntax for parameter/commands/properties + * enhance documentation + * removed old style syntax + * after running isort + * try to follow PEP8 + * fix inheritance order + * remove obsolete code + * lookup cfg files in a list of directories + * added hook for optional history writer + * fixed errors during migration + * move historywriter to secop_psi + * fix autoscan behaviour in ls370res + + [ l_samenv ] + * improve tutorial_helevel + * fixed bugs from syntax migration + + [ Markus Zolliker ] + * user friendly reporting of config errors + + [ Bjoern Pedersen ] + * Jenkisfile: verification + * Fixes to Jenkinsfile + * No pull for images, they are recreated in the job + * Another Jenkisfile error + * Correct checks enum + + -- Markus Zolliker Tue, 04 May 2021 08:49:57 +0200 + secop-core (0.11.6) unstable; urgency=medium * fix secop-generator @@ -133,7 +241,7 @@ secop-core (0.10.5) unstable; urgency=low [ Jenkins ] - -- Jenkins Tue, 29 Oct 2019 16:33:18 +0100 + -- Jenkins Tue, 29 Oct 2019 16:33:18 +0100 secop-core (0.10.3) unstable; urgency=low @@ -142,7 +250,7 @@ secop-core (0.10.3) unstable; urgency=low [ Jenkins ] - -- Jenkins Fri, 11 Oct 2019 10:49:43 +0200 + -- Jenkins Fri, 11 Oct 2019 10:49:43 +0200 secop-core (0.10.2) unstable; urgency=low @@ -153,7 +261,7 @@ secop-core (0.10.2) unstable; urgency=low [ Jenkins ] - -- Jenkins Fri, 11 Oct 2019 10:42:58 +0200 + -- Jenkins Fri, 11 Oct 2019 10:42:58 +0200 secop-core (0.10.1) unstable; urgency=low @@ -162,7 +270,7 @@ secop-core (0.10.1) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 26 Sep 2019 16:41:10 +0200 + -- Jenkins Thu, 26 Sep 2019 16:41:10 +0200 secop-core (0.10.0) unstable; urgency=low @@ -171,7 +279,7 @@ secop-core (0.10.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 26 Sep 2019 16:31:14 +0200 + -- Jenkins Thu, 26 Sep 2019 16:31:14 +0200 secop-core (0.9.0) unstable; urgency=low @@ -198,7 +306,7 @@ secop-core (0.9.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 26 Sep 2019 16:26:07 +0200 + -- Jenkins Thu, 26 Sep 2019 16:26:07 +0200 secop-core (0.8.1) unstable; urgency=low @@ -207,7 +315,7 @@ secop-core (0.8.1) unstable; urgency=low [ Jenkins ] - -- Jenkins Wed, 25 Sep 2019 15:40:44 +0200 + -- Jenkins Wed, 25 Sep 2019 15:40:44 +0200 secop-core (0.8.0) unstable; urgency=low @@ -275,7 +383,7 @@ secop-core (0.8.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Wed, 25 Sep 2019 10:27:51 +0200 + -- Jenkins Wed, 25 Sep 2019 10:27:51 +0200 secop-core (0.7.0) unstable; urgency=low @@ -311,7 +419,7 @@ secop-core (0.7.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 28 Mar 2019 13:46:08 +0100 + -- Jenkins Thu, 28 Mar 2019 13:46:08 +0100 secop-core (0.6.4) unstable; urgency=low @@ -376,7 +484,7 @@ secop-core (0.6.4) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 20 Dec 2018 16:44:03 +0100 + -- Jenkins Thu, 20 Dec 2018 16:44:03 +0100 secop-core (0.6.3) unstable; urgency=low @@ -390,7 +498,7 @@ secop-core (0.6.3) unstable; urgency=low [ Jenkins ] - -- Jenkins Fri, 27 Jul 2018 09:31:59 +0200 + -- Jenkins Fri, 27 Jul 2018 09:31:59 +0200 secop-core (0.6.2) unstable; urgency=low @@ -429,7 +537,7 @@ secop-core (0.6.2) unstable; urgency=low [ Jenkins ] - -- Jenkins Wed, 18 Jul 2018 12:06:57 +0200 + -- Jenkins Wed, 18 Jul 2018 12:06:57 +0200 secop-core (0.6.1) unstable; urgency=low @@ -438,7 +546,7 @@ secop-core (0.6.1) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 19 Apr 2018 10:24:44 +0200 + -- Jenkins Thu, 19 Apr 2018 10:24:44 +0200 secop-core (0.6.0) unstable; urgency=low @@ -458,7 +566,7 @@ secop-core (0.6.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Tue, 17 Apr 2018 17:38:52 +0200 + -- Jenkins Tue, 17 Apr 2018 17:38:52 +0200 secop-core (0.5.0) unstable; urgency=low @@ -521,7 +629,7 @@ secop-core (0.5.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Tue, 17 Apr 2018 12:45:58 +0200 + -- Jenkins Tue, 17 Apr 2018 12:45:58 +0200 secop-core (0.4.4) unstable; urgency=low @@ -530,7 +638,7 @@ secop-core (0.4.4) unstable; urgency=low [ Jenkins ] - -- Jenkins Sun, 24 Sep 2017 22:25:01 +0200 + -- Jenkins Sun, 24 Sep 2017 22:25:01 +0200 secop-core (0.4.3) unstable; urgency=low @@ -539,7 +647,7 @@ secop-core (0.4.3) unstable; urgency=low [ Jenkins ] - -- Jenkins Fri, 22 Sep 2017 17:29:46 +0200 + -- Jenkins Fri, 22 Sep 2017 17:29:46 +0200 secop-core (0.4.2) unstable; urgency=low @@ -548,7 +656,7 @@ secop-core (0.4.2) unstable; urgency=low [ Jenkins ] - -- Jenkins Fri, 22 Sep 2017 16:37:59 +0200 + -- Jenkins Fri, 22 Sep 2017 16:37:59 +0200 secop-core (0.4.1) unstable; urgency=low @@ -557,7 +665,7 @@ secop-core (0.4.1) unstable; urgency=low [ Jenkins ] - -- Jenkins Fri, 22 Sep 2017 13:25:28 +0200 + -- Jenkins Fri, 22 Sep 2017 13:25:28 +0200 secop-core (0.4.0) unstable; urgency=low @@ -567,7 +675,7 @@ secop-core (0.4.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Fri, 22 Sep 2017 10:33:04 +0200 + -- Jenkins Fri, 22 Sep 2017 10:33:04 +0200 secop-core (0.3.0) unstable; urgency=low @@ -633,7 +741,7 @@ secop-core (0.3.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Mon, 18 Sep 2017 14:18:36 +0200 + -- Jenkins Mon, 18 Sep 2017 14:18:36 +0200 secop-core (0.2.0) unstable; urgency=low @@ -642,7 +750,7 @@ secop-core (0.2.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 07 Sep 2017 14:55:41 +0200 + -- Jenkins Thu, 07 Sep 2017 14:55:41 +0200 secop-core (0.1.1) unstable; urgency=low @@ -651,7 +759,7 @@ secop-core (0.1.1) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 07 Sep 2017 11:02:19 +0200 + -- Jenkins Thu, 07 Sep 2017 11:02:19 +0200 secop-core (0.1.0) unstable; urgency=low @@ -660,7 +768,7 @@ secop-core (0.1.0) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 07 Sep 2017 10:50:24 +0200 + -- Jenkins Thu, 07 Sep 2017 10:50:24 +0200 secop-core (0.0.8) unstable; urgency=low @@ -669,7 +777,7 @@ secop-core (0.0.8) unstable; urgency=low [ Jenkins ] - -- Jenkins Tue, 01 Aug 2017 14:13:11 +0200 + -- Jenkins Tue, 01 Aug 2017 14:13:11 +0200 secop-core (0.0.7) unstable; urgency=low @@ -678,7 +786,7 @@ secop-core (0.0.7) unstable; urgency=low [ Jenkins ] - -- Jenkins Tue, 01 Aug 2017 13:52:15 +0200 + -- Jenkins Tue, 01 Aug 2017 13:52:15 +0200 secop-core (0.0.6) unstable; urgency=low @@ -688,7 +796,7 @@ secop-core (0.0.6) unstable; urgency=low [ Jenkins ] - -- Jenkins Tue, 01 Aug 2017 13:39:07 +0200 + -- Jenkins Tue, 01 Aug 2017 13:39:07 +0200 secop-core (0.0.5) unstable; urgency=low @@ -697,7 +805,7 @@ secop-core (0.0.5) unstable; urgency=low [ Jenkins ] - -- Jenkins Tue, 01 Aug 2017 13:11:43 +0200 + -- Jenkins Tue, 01 Aug 2017 13:11:43 +0200 secop-core (0.0.4) unstable; urgency=low @@ -706,7 +814,7 @@ secop-core (0.0.4) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 27 Jul 2017 11:39:42 +0200 + -- Jenkins Thu, 27 Jul 2017 11:39:42 +0200 secop-core (0.0.3) unstable; urgency=low @@ -716,7 +824,7 @@ secop-core (0.0.3) unstable; urgency=low [ Jenkins ] - -- Jenkins Thu, 27 Jul 2017 11:27:28 +0200 + -- Jenkins Thu, 27 Jul 2017 11:27:28 +0200 secop-core (0.0.2) unstable; urgency=medium @@ -794,4 +902,4 @@ secop-core (0.0.2) unstable; urgency=medium [ Jenkins ] - -- Jenkins Wed, 19 Jul 2017 11:44:13 +0200 + -- Jenkins Wed, 19 Jul 2017 11:44:13 +0200 diff --git a/debian/secop-core.install b/debian/secop-core.install index ae330e5..e6ac845 100644 --- a/debian/secop-core.install +++ b/debian/secop-core.install @@ -1,5 +1,4 @@ usr/bin/secop-server -usr/bin/secop-console usr/lib/python3.*/dist-packages/secop/*.py usr/lib/python3.*/dist-packages/secop/lib usr/lib/python3.*/dist-packages/secop/client diff --git a/doc/source/reference.rst b/doc/source/reference.rst index a81091e..9326ba2 100644 --- a/doc/source/reference.rst +++ b/doc/source/reference.rst @@ -4,6 +4,8 @@ Reference Module Base Classes ................... +.. autodata:: secop.modules.Done + .. autoclass:: secop.modules.Module :members: earlyInit, initModule, startModule, pollerClass @@ -49,11 +51,15 @@ Communication :show-inheritance: :members: communicate -.. autoclass:: secop.stringio.StringIO +.. autoclass:: secop.io.StringIO :show-inheritance: :members: communicate, multicomm -.. autoclass:: secop.stringio.HasIodev +.. autoclass:: secop.io.BytesIO + :show-inheritance: + :members: communicate, multicomm + +.. autoclass:: secop.io.HasIO :show-inheritance: .. autoclass:: secop.iohandler.IOHandlerBase diff --git a/doc/source/tutorial_helevel.rst b/doc/source/tutorial_helevel.rst index 77aadea..3ac85e0 100644 --- a/doc/source/tutorial_helevel.rst +++ b/doc/source/tutorial_helevel.rst @@ -22,7 +22,7 @@ CCU4 luckily has a very simple and logical protocol: .. code:: python # the most common Frappy classes can be imported from secop.core - from secop.core import Readable, Parameter, FloatRange, BoolType, StringIO, HasIodev + from secop.core import Readable, Parameter, FloatRange, BoolType, StringIO, HasIO class CCU4IO(StringIO): @@ -34,14 +34,13 @@ CCU4 luckily has a very simple and logical protocol: identification = [('cid', r'CCU4.*')] - # inheriting the HasIodev mixin creates us a private attribute *_iodev* - # for talking with the hardware + # inheriting HasIO allows us to use the communicate method for talking with the hardware # Readable as a base class defines the value and status parameters - class HeLevel(HasIodev, Readable): + class HeLevel(HasIO, Readable): """He Level channel of CCU4""" # define the communication class to create the IO module - iodevClass = CCU4IO + ioClass = CCU4IO # define or alter the parameters # as Readable.value exists already, we give only the modified property 'unit' @@ -49,7 +48,7 @@ CCU4 luckily has a very simple and logical protocol: def read_value(self): # method for reading the main value - reply = self._iodev.communicate('h') # send 'h\n' and get the reply 'h=\n' + reply = self.communicate('h') # send 'h\n' and get the reply 'h=\n' name, txtvalue = reply.split('=') assert name == 'h' # check that we got a reply to our command return txtvalue # the framework will automatically convert the string to a float @@ -115,17 +114,17 @@ the status codes from the hardware to the standard SECoP status codes. } def read_status(self): - name, txtvalue = self._iodev.communicate('hsf').split('=') + name, txtvalue = self.communicate('hsf').split('=') assert name == 'hsf' return self.STATUS_MAP(int(txtvalue)) def read_empty_length(self): - name, txtvalue = self._iodev.communicate('hem').split('=') + name, txtvalue = self.communicate('hem').split('=') assert name == 'hem' return txtvalue def write_empty_length(self, value): - name, txtvalue = self._iodev.communicate('hem=%g' % value).split('=') + name, txtvalue = self.communicate('hem=%g' % value).split('=') assert name == 'hem' return txtvalue @@ -152,7 +151,7 @@ which means it might be worth to create a *query* method, and then the for changing a parameter :returns: the (new) value of the parameter """ - name, txtvalue = self._iodev.communicate(cmd).split('=') + name, txtvalue = self.communicate(cmd).split('=') assert name == cmd.split('=')[0] # check that we got a reply to our command return txtvalue # Frappy will automatically convert the string to the needed data type diff --git a/secop_demo/cryo.py b/secop_demo/cryo.py index 77e4d5a..20533af 100644 --- a/secop_demo/cryo.py +++ b/secop_demo/cryo.py @@ -111,6 +111,7 @@ class Cryostat(CryoBase): group='stability') def initModule(self): + super().initModule() self._stopflag = False self._thread = mkthread(self.thread) diff --git a/secop_demo/modules.py b/secop_demo/modules.py index 09a2f7f..22d3675 100644 --- a/secop_demo/modules.py +++ b/secop_demo/modules.py @@ -133,6 +133,7 @@ class MagneticField(Drivable): status = Parameter(datatype=TupleOf(EnumType(Status), StringType())) def initModule(self): + super().initModule() self._state = Enum('state', idle=1, switch_on=2, switch_off=3, ramp=4).idle self._heatswitch = self.DISPATCHER.get_module(self.heatswitch) _thread = threading.Thread(target=self._thread) @@ -235,6 +236,7 @@ class SampleTemp(Drivable): ) def initModule(self): + super().initModule() _thread = threading.Thread(target=self._thread) _thread.daemon = True _thread.start() diff --git a/secop_mlz/amagnet.py b/secop_mlz/amagnet.py index 7b2491e..9763fbb 100644 --- a/secop_mlz/amagnet.py +++ b/secop_mlz/amagnet.py @@ -31,7 +31,7 @@ import math from secop.datatypes import ArrayOf, FloatRange, StringType, StructOf, TupleOf from secop.errors import ConfigError, DisabledError from secop.lib.sequence import SequencerMixin, Step -from secop.modules import BasicPoller, Drivable, Parameter +from secop.modules import Drivable, Parameter class GarfieldMagnet(SequencerMixin, Drivable): @@ -47,9 +47,6 @@ class GarfieldMagnet(SequencerMixin, Drivable): the symmetry setting selects which. """ - pollerClass = BasicPoller - - # parameters subdev_currentsource = Parameter('(bipolar) Powersupply', datatype=StringType(), readonly=True, export=False) subdev_enable = Parameter('Switch to set for on/off', datatype=StringType(), readonly=True, export=False) @@ -57,10 +54,10 @@ class GarfieldMagnet(SequencerMixin, Drivable): subdev_symmetry = Parameter('Switch to read for symmetry', datatype=StringType(), readonly=True, export=False) userlimits = Parameter('User defined limits of device value', datatype=TupleOf(FloatRange(unit='$'), FloatRange(unit='$')), - default=(float('-Inf'), float('+Inf')), readonly=False, poll=10) + default=(float('-Inf'), float('+Inf')), readonly=False) abslimits = Parameter('Absolute limits of device value', datatype=TupleOf(FloatRange(unit='$'), FloatRange(unit='$')), - default=(-0.5, 0.5), poll=True, + default=(-0.5, 0.5), ) precision = Parameter('Precision of the device value (allowed deviation ' 'of stable values from target)', @@ -71,7 +68,7 @@ class GarfieldMagnet(SequencerMixin, Drivable): calibration = Parameter('Coefficients for calibration ' 'function: [c0, c1, c2, c3, c4] calculates ' 'B(I) = c0*I + c1*erf(c2*I) + c3*atan(c4*I)' - ' in T', poll=1, + ' in T', datatype=ArrayOf(FloatRange(), 5, 5), default=(1.0, 0.0, 0.0, 0.0, 0.0)) calibrationtable = Parameter('Map of Coefficients for calibration per symmetry setting', @@ -137,7 +134,7 @@ class GarfieldMagnet(SequencerMixin, Drivable): '_current2field polynome not monotonic!') def initModule(self): - super(GarfieldMagnet, self).initModule() + super().initModule() self._enable = self.DISPATCHER.get_module(self.subdev_enable) self._symmetry = self.DISPATCHER.get_module(self.subdev_symmetry) self._polswitch = self.DISPATCHER.get_module(self.subdev_polswitch) @@ -220,7 +217,7 @@ class GarfieldMagnet(SequencerMixin, Drivable): self._currentsource.read_value() * self._get_field_polarity()) - def read_hw_status(self): + def readHwStatus(self): # called from SequencerMixin.read_status if no sequence is running if self._enable.value == 'Off': return self.Status.WARN, 'Disabled' diff --git a/secop_mlz/entangle.py b/secop_mlz/entangle.py index f3324f0..ba97125 100644 --- a/secop_mlz/entangle.py +++ b/secop_mlz/entangle.py @@ -39,7 +39,7 @@ from secop.datatypes import ArrayOf, EnumType, FloatRange, \ from secop.errors import CommunicationFailedError, \ ConfigError, HardwareError, ProgrammingError from secop.lib import lazy_property -from secop.modules import BasicPoller, Command, \ +from secop.modules import Command, \ Drivable, Module, Parameter, Readable ##### @@ -157,8 +157,6 @@ class PyTangoDevice(Module): execution and attribute operations with logging and exception mapping. """ - pollerClass = BasicPoller - # parameters comtries = Parameter('Maximum retries for communication', datatype=IntRange(1, 100), default=3, readonly=False, @@ -210,7 +208,7 @@ class PyTangoDevice(Module): # exception mapping is enabled). self._createPyTangoDevice = self._applyGuardToFunc( self._createPyTangoDevice, 'constructor') - super(PyTangoDevice, self).earlyInit() + super().earlyInit() @lazy_property def _dev(self): @@ -249,10 +247,10 @@ class PyTangoDevice(Module): # otherwise would lead to attribute errors later try: device.State - except AttributeError: + except AttributeError as e: raise CommunicationFailedError( self, 'connection to Tango server failed, ' - 'is the server running?') + 'is the server running?') from e return self._applyGuardsToPyTangoDevice(device) def _applyGuardsToPyTangoDevice(self, dev): @@ -376,14 +374,17 @@ class AnalogInput(PyTangoDevice, Readable): The AnalogInput handles all devices only delivering an analogue value. """ - def startModule(self, started_callback): - super(AnalogInput, self).startModule(started_callback) - # query unit from tango and update value property - attrInfo = self._dev.attribute_query('value') - # prefer configured unit if nothing is set on the Tango device, else - # update - if attrInfo.unit != 'No unit': - self.accessibles['value'].datatype.setProperty('unit', attrInfo.unit) + def startModule(self, start_events): + super().startModule(start_events) + try: + # query unit from tango and update value property + attrInfo = self._dev.attribute_query('value') + # prefer configured unit if nothing is set on the Tango device, else + # update + if attrInfo.unit != 'No unit': + self.accessibles['value'].datatype.setProperty('unit', attrInfo.unit) + except Exception as e: + self.log.error(e) def read_value(self): return self._dev.value @@ -422,7 +423,7 @@ class AnalogOutput(PyTangoDevice, Drivable): userlimits = Parameter('User defined limits of device value', datatype=LimitsType(FloatRange(unit='$')), default=(float('-Inf'), float('+Inf')), - readonly=False, poll=10, + readonly=False, ) abslimits = Parameter('Absolute limits of device value', datatype=LimitsType(FloatRange(unit='$')), @@ -446,13 +447,13 @@ class AnalogOutput(PyTangoDevice, Drivable): _moving = False def initModule(self): - super(AnalogOutput, self).initModule() + super().initModule() # init history self._history = [] # will keep (timestamp, value) tuple self._timeout = None # keeps the time at which we will timeout, or None - def startModule(self, started_callback): - super(AnalogOutput, self).startModule(started_callback) + def startModule(self, start_events): + super().startModule(start_events) # query unit from tango and update value property attrInfo = self._dev.attribute_query('value') # prefer configured unit if nothing is set on the Tango device, else @@ -460,8 +461,8 @@ class AnalogOutput(PyTangoDevice, Drivable): if attrInfo.unit != 'No unit': self.accessibles['value'].datatype.setProperty('unit', attrInfo.unit) - def pollParams(self, nr=0): - super(AnalogOutput, self).pollParams(nr) + def doPoll(self): + super().doPoll() while len(self._history) > 2: # if history would be too short, break if self._history[-1][0] - self._history[1][0] <= self.window: @@ -489,8 +490,11 @@ class AnalogOutput(PyTangoDevice, Drivable): hist = self._history[:] window_start = currenttime() - self.window hist_in_window = [v for (t, v) in hist if t >= window_start] + if len(hist) == len(hist_in_window): + return False # no data point before window if not hist_in_window: - return False # no relevant history -> no knowledge + # window is too small -> use last point only + hist_in_window = [self.value] max_in_hist = max(hist_in_window) min_in_hist = min(hist_in_window) @@ -503,13 +507,14 @@ class AnalogOutput(PyTangoDevice, Drivable): if self._isAtTarget(): self._timeout = None self._moving = False - return super(AnalogOutput, self).read_status() - if self._timeout: - if self._timeout < currenttime(): - return self.Status.UNSTABLE, 'timeout after waiting for stable value' - if self._moving: - return (self.Status.BUSY, 'moving') - return (self.Status.IDLE, 'stable') + status = super().read_status() + else: + if self._timeout and self._timeout < currenttime(): + status = self.Status.UNSTABLE, 'timeout after waiting for stable value' + else: + status = (self.Status.BUSY, 'moving') if self._moving else (self.Status.IDLE, 'stable') + self.setFastPoll(self.isBusy(status)) + return status @property def absmin(self): @@ -571,11 +576,14 @@ class AnalogOutput(PyTangoDevice, Drivable): if not self.timeout: self._timeout = None self._moving = True - self._history = [] # clear history - self.read_status() # poll our status to keep it updated + # do not clear the history here: + # - if the target is not changed by more than precision, there is no need to wait + # self._history = [] + self.read_status() # poll our status to keep it updated (this will also set fast poll) + return self.read_target() def _hw_wait(self): - while super(AnalogOutput, self).read_status()[0] == self.Status.BUSY: + while super().read_status()[0] == self.Status.BUSY: sleep(0.3) def stop(self): @@ -597,8 +605,7 @@ class Actuator(AnalogOutput): readonly=False, datatype=FloatRange(0, unit='$/s'), ) ramp = Parameter('The speed of changing the value', - readonly=False, datatype=FloatRange(0, unit='$/s'), - poll=30, + readonly=False, datatype=FloatRange(0, unit='$/min'), ) def read_speed(self): @@ -677,17 +684,22 @@ class TemperatureController(Actuator): ) pid = Parameter('pid control Parameters', datatype=TupleOf(FloatRange(), FloatRange(), FloatRange()), - readonly=False, group='pid', poll=30, + readonly=False, group='pid', ) - setpoint = Parameter('Current setpoint', datatype=FloatRange(unit='$'), poll=1, + setpoint = Parameter('Current setpoint', datatype=FloatRange(unit='$'), ) - heateroutput = Parameter('Heater output', datatype=FloatRange(), poll=1, + heateroutput = Parameter('Heater output', datatype=FloatRange(), ) # overrides precision = Parameter(default=0.1) ramp = Parameter(description='Temperature ramp') + def doPoll(self): + super().doPoll() + self.read_setpoint() + self.read_heateroutput() + def read_ramp(self): return self._dev.ramp @@ -730,6 +742,10 @@ class TemperatureController(Actuator): def read_heateroutput(self): return self._dev.heaterOutput + # remove UserCommand setposition from Actuator + # (makes no sense for a TemperatureController) + setposition = None + class PowerSupply(Actuator): """A power supply (voltage and current) device. @@ -737,13 +753,19 @@ class PowerSupply(Actuator): # parameters voltage = Parameter('Actual voltage', - datatype=FloatRange(unit='V'), poll=-5) + datatype=FloatRange(unit='V')) current = Parameter('Actual current', - datatype=FloatRange(unit='A'), poll=-5) + datatype=FloatRange(unit='A')) # overrides ramp = Parameter(description='Current/voltage ramp') + def doPoll(self): + super().doPoll() + # TODO: poll voltage and current faster when busy + self.read_voltage() + self.read_current() + def read_ramp(self): return self._dev.ramp @@ -777,16 +799,18 @@ class NamedDigitalInput(DigitalInput): datatype=StringType(), export=False) # XXX:!!! def initModule(self): - super(NamedDigitalInput, self).initModule() + super().initModule() try: - # pylint: disable=eval-used - mapping = eval(self.mapping.replace('\n', ' ')) + mapping = self.mapping + if isinstance(mapping, str): + # pylint: disable=eval-used + mapping = eval(self.mapping.replace('\n', ' ')) if isinstance(mapping, str): # pylint: disable=eval-used mapping = eval(mapping) self.accessibles['value'].setProperty('datatype', EnumType('value', **mapping)) except Exception as e: - raise ValueError('Illegal Value for mapping: %r' % e) + raise ValueError('Illegal Value for mapping: %r' % self.mapping) from e def read_value(self): value = self._dev.value @@ -805,7 +829,7 @@ class PartialDigitalInput(NamedDigitalInput): datatype=IntRange(0), default=1) def initModule(self): - super(PartialDigitalInput, self).initModule() + super().initModule() self._mask = (1 << self.bitwidth) - 1 # self.accessibles['value'].datatype = IntRange(0, self._mask) @@ -827,9 +851,16 @@ class DigitalOutput(PyTangoDevice, Drivable): def read_value(self): return self._dev.value # mapping is done by datatype upon export() + def read_status(self): + status = self.read_status() + self.setFastPoll(self.isBusy(status)) + return status + def write_target(self, value): self._dev.value = value self.read_value() + self.read_status() # this will also set fast poll + return self.read_target() def read_target(self): attrObj = self._dev.read_attribute('value') @@ -845,22 +876,25 @@ class NamedDigitalOutput(DigitalOutput): datatype=StringType(), export=False) def initModule(self): - super(NamedDigitalOutput, self).initModule() + super().initModule() try: - # pylint: disable=eval-used - mapping = eval(self.mapping.replace('\n', ' ')) + mapping = self.mapping + if isinstance(mapping, str): + # pylint: disable=eval-used + mapping = eval(self.mapping.replace('\n', ' ')) if isinstance(mapping, str): # pylint: disable=eval-used mapping = eval(mapping) self.accessibles['value'].setProperty('datatype', EnumType('value', **mapping)) self.accessibles['target'].setProperty('datatype', EnumType('target', **mapping)) except Exception as e: - raise ValueError('Illegal Value for mapping: %r' % e) + raise ValueError('Illegal Value for mapping: %r' % self.mapping) from e def write_target(self, value): # map from enum-str to integer value self._dev.value = int(value) self.read_value() + return self.read_target() class PartialDigitalOutput(NamedDigitalOutput): @@ -875,7 +909,7 @@ class PartialDigitalOutput(NamedDigitalOutput): datatype=IntRange(0), default=1) def initModule(self): - super(PartialDigitalOutput, self).initModule() + super().initModule() self._mask = (1 << self.bitwidth) - 1 # self.accessibles['value'].datatype = IntRange(0, self._mask) # self.accessibles['target'].datatype = IntRange(0, self._mask) @@ -891,6 +925,7 @@ class PartialDigitalOutput(NamedDigitalOutput): (value << self.startbit) self._dev.value = newvalue self.read_value() + return self.read_target() class StringIO(PyTangoDevice, Module): diff --git a/test/test_attach.py b/test/test_attach.py new file mode 100644 index 0000000..9c6c69c --- /dev/null +++ b/test/test_attach.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** + +from secop.modules import Module, Attached +from secop.protocol.dispatcher import Dispatcher + + +# class DispatcherStub: +# # omit_unchanged_within = 0 +# +# # def __init__(self, updates): +# # self.updates = updates +# # +# # def announce_update(self, modulename, pname, pobj): +# # self.updates.setdefault(modulename, {}) +# # if pobj.readerror: +# # self.updates[modulename]['error', pname] = str(pobj.readerror) +# # else: +# # self.updates[modulename][pname] = pobj.value +# +# def __init__(self): +# self.modules = {} +# +# def get_module(self, name): +# return self.modules[name] +# +# def register_module(self, name, module): +# self.modules[name] = module + + +class LoggerStub: + def debug(self, fmt, *args): + print(fmt % args) + info = warning = exception = debug + handlers = [] + + +logger = LoggerStub() + + +class ServerStub: + restart = None + shutdown = None + + def __init__(self): + self.dispatcher = Dispatcher('dispatcher', logger, {}, self) + + +def test_attach(): + class Mod(Module): + att = Attached() + + srv = ServerStub() + a = Module('a', logger, {'description': ''}, srv) + m = Mod('m', logger, {'description': '', 'att': 'a'}, srv) + assert m.propertyValues['att'] == 'a' + srv.dispatcher.register_module(a, 'a') + srv.dispatcher.register_module(m, 'm') + assert m.att == a diff --git a/test/test_datatypes.py b/test/test_datatypes.py index 158be29..097190f 100644 --- a/test/test_datatypes.py +++ b/test/test_datatypes.py @@ -28,7 +28,9 @@ import pytest from secop.datatypes import ArrayOf, BLOBType, BoolType, \ CommandType, ConfigError, DataType, Enum, EnumType, FloatRange, \ IntRange, ProgrammingError, ScaledInteger, StatusType, \ - StringType, StructOf, TextType, TupleOf, get_datatype + StringType, StructOf, TextType, TupleOf, get_datatype, \ + DiscouragedConversion +from secop.lib import generalConfig def copytest(dt): @@ -36,6 +38,7 @@ def copytest(dt): assert dt.export_datatype() == dt.copy().export_datatype() assert dt != dt.copy() + def test_DataType(): dt = DataType() with pytest.raises(NotImplementedError): @@ -116,7 +119,6 @@ def test_IntRange(): dt('1.3') dt(1) dt(0) - dt('1') with pytest.raises(ProgrammingError): IntRange('xc', 'Yx') @@ -132,6 +134,7 @@ def test_IntRange(): with pytest.raises(ConfigError): dt.checkProperties() + def test_ScaledInteger(): dt = ScaledInteger(0.01, -3, 3) copytest(dt) @@ -407,6 +410,7 @@ def test_ArrayOf(): dt = ArrayOf(EnumType('myenum', single=0), 5) copytest(dt) + def test_TupleOf(): # test constructor catching illegal arguments with pytest.raises(ValueError): @@ -641,6 +645,7 @@ def test_oneway_compatible(dt, contained_in): with pytest.raises(ValueError): contained_in.compatible(dt) + @pytest.mark.parametrize('dt1, dt2', [ (FloatRange(-5.5, 5.5), ScaledInteger(10, -5.5, 5.5)), (IntRange(0,1), BoolType()), @@ -650,6 +655,7 @@ def test_twoway_compatible(dt1, dt2): dt1.compatible(dt1) dt2.compatible(dt2) + @pytest.mark.parametrize('dt1, dt2', [ (StringType(), FloatRange()), (IntRange(-10, 10), StringType()), @@ -665,3 +671,12 @@ def test_incompatible(dt1, dt2): dt1.compatible(dt2) with pytest.raises(ValueError): dt2.compatible(dt1) + + +@pytest.mark.parametrize('dt', [FloatRange(), IntRange(), ScaledInteger(1)]) +def test_lazy_validation(dt): + generalConfig.defaults['lazy_number_validation'] = True + dt('0') + generalConfig.defaults['lazy_number_validation'] = False + with pytest.raises(DiscouragedConversion): + dt('0') diff --git a/test/test_handler.py b/test/test_handler.py new file mode 100644 index 0000000..6d00fa7 --- /dev/null +++ b/test/test_handler.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** + + +from secop.rwhandler import ReadHandler, WriteHandler, \ + CommonReadHandler, CommonWriteHandler, nopoll +from secop.core import Module, Parameter, FloatRange, Done + + +class DispatcherStub: + # the first update from the poller comes a very short time after the + # initial value from the timestamp. However, in the test below + # the second update happens after the updates dict is cleared + # -> we have to inhibit the 'omit unchanged update' feature + omit_unchanged_within = 0 + + def __init__(self, updates): + self.updates = updates + + def announce_update(self, modulename, pname, pobj): + self.updates.setdefault(modulename, {}) + if pobj.readerror: + self.updates[modulename]['error', pname] = str(pobj.readerror) + else: + self.updates[modulename][pname] = pobj.value + + +class LoggerStub: + def debug(self, fmt, *args): + print(fmt % args) + info = warning = exception = error = debug + handlers = [] + + +logger = LoggerStub() + + +class ServerStub: + def __init__(self, updates): + self.dispatcher = DispatcherStub(updates) + + +class ModuleTest(Module): + def __init__(self, updates=None, **opts): + opts['description'] = '' + super().__init__('mod', logger, opts, ServerStub(updates or {})) + + +def test_handler(): + data = [] + + class Mod(ModuleTest): + a = Parameter('', FloatRange(), readonly=False) + b = Parameter('', FloatRange(), readonly=False) + + @ReadHandler(['a', 'b']) + def read_hdl(self, pname): + value = data.pop() + data.append(pname) + return value + + @WriteHandler(['a', 'b']) + def write_hdl(self, pname, value): + data.append(pname) + return value + + assert Mod.read_a.poll is True + assert Mod.read_b.poll is True + + m = Mod() + + data.append(1.2) + assert m.read_a() == 1.2 + assert data.pop() == 'a' + + data.append(1.3) + assert m.read_b() == 1.3 + assert data.pop() == 'b' + + assert m.write_a(1.5) == 1.5 + assert m.a == 1.5 + assert data.pop() == 'a' + + assert m.write_b(7) == 7 + assert m.b == 7 + assert data.pop() == 'b' + + data.append(Done) + assert m.read_b() == 7 + assert data.pop() == 'b' + + assert data == [] + + +def test_common_handler(): + data = [] + + class Mod(ModuleTest): + a = Parameter('', FloatRange(), readonly=False) + b = Parameter('', FloatRange(), readonly=False) + + @CommonReadHandler(['a', 'b']) + def read_hdl(self): + self.a, self.b = data.pop() + data.append('read_hdl') + + @CommonWriteHandler(['a', 'b']) + def write_hdl(self, values): + self.a = values['a'] + self.b = values['b'] + data.append('write_hdl') + + assert set([Mod.read_a.poll, Mod.read_b.poll]) == {True, False} + + m = Mod(a=1, b=2) + assert m.writeDict == {'a': 1, 'b': 2} + m.write_a(3) + assert m.a == 3 + assert m.b == 2 + assert data.pop() == 'write_hdl' + assert m.writeDict == {} + + m.write_b(4) + assert m.a == 3 + assert m.b == 4 + assert data.pop() == 'write_hdl' + + data.append((3, 4)) + assert m.read_a() == 3 + assert m.a == 3 + assert m.b == 4 + assert data.pop() == 'read_hdl' + data.append((5, 6)) + assert m.read_b() == 6 + assert data.pop() == 'read_hdl' + + data.append((1.1, 2.2)) + assert m.read_b() == 2.2 + assert m.a == 1.1 + assert m.b == 2.2 + assert data.pop() == 'read_hdl' + + assert data == [] + + +def test_nopoll(): + class Mod1(ModuleTest): + a = Parameter('', FloatRange(), readonly=False) + b = Parameter('', FloatRange(), readonly=False) + + @ReadHandler(['a', 'b']) + def read_hdl(self): + pass + + assert Mod1.read_a.poll is True + assert Mod1.read_b.poll is True + + class Mod2(ModuleTest): + a = Parameter('', FloatRange(), readonly=False) + b = Parameter('', FloatRange(), readonly=False) + + @CommonReadHandler(['a', 'b']) + def read_hdl(self): + pass + + assert Mod2.read_a.poll is True + assert Mod2.read_b.poll is False + + class Mod3(ModuleTest): + a = Parameter('', FloatRange(), readonly=False) + b = Parameter('', FloatRange(), readonly=False) + + @ReadHandler(['a', 'b']) + @nopoll + def read_hdl(self): + pass + + assert Mod3.read_a.poll is False + assert Mod3.read_b.poll is False + + class Mod4(ModuleTest): + a = Parameter('', FloatRange(), readonly=False) + b = Parameter('', FloatRange(), readonly=False) + + @nopoll + @ReadHandler(['a', 'b']) + def read_hdl(self): + pass + + assert Mod4.read_a.poll is False + assert Mod4.read_b.poll is False + + class Mod5(ModuleTest): + a = Parameter('', FloatRange(), readonly=False) + b = Parameter('', FloatRange(), readonly=False) + + @CommonReadHandler(['a', 'b']) + @nopoll + def read_hdl(self): + pass + + assert Mod5.read_a.poll is False + assert Mod5.read_b.poll is False + + class Mod6(ModuleTest): + a = Parameter('', FloatRange(), readonly=False) + b = Parameter('', FloatRange(), readonly=False) + + @nopoll + @CommonReadHandler(['a', 'b']) + def read_hdl(self): + pass + + assert Mod6.read_a.poll is False + assert Mod6.read_b.poll is False diff --git a/test/test_iohandler.py b/test/test_iohandler.py index f860a10..da773ac 100644 --- a/test/test_iohandler.py +++ b/test/test_iohandler.py @@ -120,7 +120,7 @@ def test_IOHandler(): real = Parameter('a float value', FloatRange(), default=12.3, handler=group2, readonly=False) text = Parameter('a string value', StringType(), default='x', handler=group2, readonly=False) - def sendRecv(self, command): + def communicate(self, command): assert data.pop('command') == command return data.pop('reply') @@ -146,7 +146,7 @@ def test_IOHandler(): print(updates) updates.clear() # get rid of updates from initialisation - # for sendRecv + # for communicate data.push('command', 'SIMPLE?') data.push('reply', '4.51') # for analyze_group1 @@ -159,7 +159,7 @@ def test_IOHandler(): assert updates.pop('simple') == 45.1 assert not updates - # for sendRecv + # for communicate data.push('command', 'CMD?3') data.push('reply', '1.23,text,5') # for analyze_group2 @@ -172,7 +172,7 @@ def test_IOHandler(): assert data.empty() assert not updates - # for sendRecv + # for communicate data.push('command', 'CMD?3') data.push('reply', '1.23,text,5') # for analyze_group2 @@ -183,7 +183,7 @@ def test_IOHandler(): data.push('self', 12.3, 'string') data.push('new', 12.3, 'FOO') data.push('changed', 1.23, 'foo', 9) - # for sendRecv + # for communicate data.push('command', 'CMD 3,1.23,foo,9|CMD?3') data.push('reply', '1.23,foo,9') # for analyze_group2 diff --git a/test/test_logging.py b/test/test_logging.py new file mode 100644 index 0000000..7c07bb4 --- /dev/null +++ b/test/test_logging.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** + +import pytest +import mlzlog +from secop.modules import Module +from secop.protocol.dispatcher import Dispatcher +from secop.protocol.interface import encode_msg_frame, decode_msg +import secop.logging +from secop.logging import logger, generalConfig, HasComlog + + +class ServerStub: + restart = None + shutdown = None + + def __init__(self): + self.dispatcher = Dispatcher('', logger.log.getChild('dispatcher'), {}, self) + + +class Connection: + def __init__(self, name, dispatcher, result): + self.result = result + self.dispatcher = dispatcher + self.name = name + dispatcher.add_connection(self) + + def send_reply(self, msg): + self.result.append(encode_msg_frame(*msg).strip().decode()) + + def send(self, msg): + request = decode_msg(msg.encode()) + assert self.dispatcher.handle_request(self, request) == request + + +@pytest.fixture(name='init') +def init_(monkeypatch): + logger.__init__() + + class Playground: + def __init__(self, console_level='debug', comlog=True, com_module=True): + self.result_dict = result_dict = dict( + console=[], comlog=[], conn1=[], conn2=[]) + + class ConsoleHandler(mlzlog.Handler): + def __init__(self, *args, **kwds): + super().__init__() + self.result = result_dict['console'] + + def emit(self, record): + if record.name != 'frappy.dispatcher': + self.result.append('%s %s %s' % (record.name, record.levelname, record.getMessage())) + + class ComLogHandler(mlzlog.Handler): + def __init__(self, *args, **kwds): + super().__init__() + self.result = result_dict['comlog'] + + def emit(self, record): + self.result.append('%s %s' % (record.name.split('.')[1], record.getMessage())) + + class LogfileHandler(mlzlog.Handler): + def __init__(self, *args, **kwds): + super().__init__() + + def noop(self, *args): + pass + + close = flush = emit = noop + + monkeypatch.setattr(mlzlog, 'ColoredConsoleHandler', ConsoleHandler) + monkeypatch.setattr(secop.logging, 'ComLogfileHandler', ComLogHandler) + monkeypatch.setattr(secop.logging, 'LogfileHandler', LogfileHandler) + + class Mod(Module): + result = [] + + def __init__(self, name, srv, **kwds): + kwds['description'] = '' + super().__init__(name or 'mod', logger.log.getChild(name), kwds, srv) + srv.dispatcher.register_module(self, name, name) + self.result[:] = [] + + def earlyInit(self): + pass + + class Com(HasComlog, Mod): + def __init__(self, name, srv, **kwds): + super().__init__(name, srv, **kwds) + self.earlyInit() + self.log.handlers[-1].result = result_dict['comlog'] + + def communicate(self, request): + self.comLog('> %s', request) + + generalConfig.testinit(logger_root='frappy', comlog=comlog) + logger.init(console_level) + self.srv = ServerStub() + + self.conn1 = Connection('conn1', self.srv.dispatcher, self.result_dict['conn1']) + self.conn2 = Connection('conn2', self.srv.dispatcher, self.result_dict['conn2']) + self.mod = Mod('mod', self.srv) + self.com = Com('com', self.srv, comlog=com_module) + for item in self.result_dict.values(): + assert item == [] + + def check(self, both=None, **expected): + if both: + expected['conn1'] = expected['conn2'] = both + assert self.result_dict['console'] == expected.get('console', []) + assert self.result_dict['comlog'] == expected.get('comlog', []) + assert self.result_dict['conn1'] == expected.get('conn1', []) + assert self.result_dict['conn2'] == expected.get('conn2', []) + for item in self.result_dict.values(): + item[:] = [] + + def comlog(self, flag): + logger.comlog = flag + + yield Playground + # revert settings + generalConfig.testinit() + logger.__init__() + + +def test_mod_info(init): + p = init() + p.mod.log.info('i') + p.check(console=['frappy.mod INFO i']) + p.conn1.send('logging mod "debug"') + p.conn2.send('logging mod "info"') + p.mod.log.info('i') + p.check(console=['frappy.mod INFO i'], both=['log mod:info "i"']) + + +def test_mod_debug(init): + p = init() + p.mod.log.debug('d') + p.check(console=['frappy.mod DEBUG d']) + p.conn1.send('logging mod "debug"') + p.conn2.send('logging mod "info"') + p.mod.log.debug('d') + p.check(console=['frappy.mod DEBUG d'], conn1=['log mod:debug "d"']) + + +def test_com_info(init): + p = init() + p.com.log.info('i') + p.check(console=['frappy.com INFO i']) + p.conn1.send('logging com "info"') + p.conn2.send('logging com "debug"') + p.com.log.info('i') + p.check(console=['frappy.com INFO i'], both=['log com:info "i"']) + + +def test_com_debug(init): + p = init() + p.com.log.debug('d') + p.check(console=['frappy.com DEBUG d']) + p.conn2.send('logging com "debug"') + p.com.log.debug('d') + p.check(console=['frappy.com DEBUG d'], conn2=['log com:debug "d"']) + + +def test_com_com(init): + p = init() + p.com.communicate('x') + p.check(console=['frappy.com COMLOG > x'], comlog=['com > x']) + p.conn1.send('logging mod "debug"') + p.conn2.send('logging mod "info"') + p.conn2.send('logging com "debug"') + p.com.communicate('x') + p.check(console=['frappy.com COMLOG > x'], comlog=['com > x'], conn2=['log com:comlog "> x"']) + + +def test_main_info(init): + p = init(console_level='info') + p.mod.log.debug('d') + p.com.communicate('x') + p.check(comlog=['com > x']) + p.conn1.send('logging mod "debug"') + p.conn2.send('logging mod "info"') + p.conn2.send('logging com "debug"') + p.com.communicate('x') + p.check(comlog=['com > x'], conn2=['log com:comlog "> x"']) + + +def test_comlog_off(init): + p = init(console_level='info', comlog=False) + p.mod.log.debug('d') + p.com.communicate('x') + p.check() + + +def test_comlog_module_off(init): + p = init(console_level='info', com_module=False) + p.mod.log.debug('d') + p.com.communicate('x') + p.check() + + +def test_remote_all_off(init): + p = init() + p.conn1.send('logging mod "debug"') + p.conn2.send('logging mod "info"') + p.conn2.send('logging com "debug"') + p.mod.log.debug('d') + p.com.communicate('x') + p.mod.log.info('i') + checks = dict( + console=['frappy.mod DEBUG d', 'frappy.com COMLOG > x', 'frappy.mod INFO i'], + comlog=['com > x'], + conn1=['log mod:debug "d"', 'log mod:info "i"'], + conn2=['log com:comlog "> x"', 'log mod:info "i"']) + p.check(**checks) + p.conn1.send('logging "off"') + p.mod.log.debug('d') + p.com.communicate('x') + p.mod.log.info('i') + checks.pop('conn1') + p.check(**checks) + p.conn2.send('logging . "off"') + p.mod.log.debug('d') + p.com.communicate('x') + p.mod.log.info('i') + checks.pop('conn2') + p.check(**checks) + + +def test_remote_single_off(init): + p = init() + p.conn1.send('logging mod "debug"') + p.conn2.send('logging mod "info"') + p.conn2.send('logging com "debug"') + p.mod.log.debug('d') + p.com.communicate('x') + p.mod.log.info('i') + checks = dict( + console=['frappy.mod DEBUG d', 'frappy.com COMLOG > x', 'frappy.mod INFO i'], + comlog=['com > x'], + conn1=['log mod:debug "d"', 'log mod:info "i"'], + conn2=['log com:comlog "> x"', 'log mod:info "i"']) + p.check(**checks) + p.conn2.send('logging com "off"') + p.mod.log.debug('d') + p.com.communicate('x') + p.mod.log.info('i') + checks['conn2'] = ['log mod:info "i"'] + p.check(**checks) + p.conn2.send('logging mod "off"') + p.mod.log.debug('d') + p.com.communicate('x') + p.mod.log.info('i') + checks['conn2'] = [] + p.check(**checks) diff --git a/test/test_modules.py b/test/test_modules.py index 361488a..178d222 100644 --- a/test/test_modules.py +++ b/test/test_modules.py @@ -22,15 +22,16 @@ # ***************************************************************************** """test data types.""" +import sys import threading - import pytest -from secop.datatypes import BoolType, FloatRange, StringType, IntRange +from secop.datatypes import BoolType, FloatRange, StringType, IntRange, ScaledInteger from secop.errors import ProgrammingError, ConfigError from secop.modules import Communicator, Drivable, Readable, Module from secop.params import Command, Parameter -from secop.poller import BasicPoller +from secop.rwhandler import ReadHandler, WriteHandler, nopoll +from secop.lib import generalConfig class DispatcherStub: @@ -52,9 +53,10 @@ class DispatcherStub: class LoggerStub: - def debug(self, *args): - print(*args) - info = warning = exception = debug + def debug(self, fmt, *args): + print(fmt % args) + info = warning = exception = error = debug + handlers = [] logger = LoggerStub() @@ -65,13 +67,21 @@ class ServerStub: self.dispatcher = DispatcherStub(updates) +class DummyMultiEvent(threading.Event): + def get_trigger(self): + def trigger(event=self): + event.set() + sys.exit() + return trigger + + def test_Communicator(): - o = Communicator('communicator', LoggerStub(), {'.description':''}, ServerStub({})) + o = Communicator('communicator', LoggerStub(), {'.description': ''}, ServerStub({})) o.earlyInit() o.initModule() - event = threading.Event() - o.startModule(event.set) - assert event.is_set() # event should be set immediately + event = DummyMultiEvent() + o.startModule(event) + assert event.is_set() # event should be set immediately def test_ModuleMagic(): @@ -87,14 +97,13 @@ def test_ModuleMagic(): a1 = Parameter('a1', datatype=BoolType(), default=False) a2 = Parameter('a2', datatype=BoolType(), default=True) value = Parameter(datatype=StringType(), default='first') + target = Parameter(datatype=StringType(), default='') @Command(argument=BoolType(), result=BoolType()) def cmd2(self, arg): """another stuff""" return not arg - pollerClass = BasicPoller - def read_param1(self): return True @@ -104,12 +113,16 @@ def test_ModuleMagic(): def read_a1(self): return True + @nopoll def read_a2(self): return True def read_value(self): return 'second' + def read_status(self): + return 'IDLE', 'ok' + with pytest.raises(ProgrammingError): class Mod1(Module): # pylint: disable=unused-variable def do_this(self): # old style command @@ -132,9 +145,12 @@ def test_ModuleMagic(): return arg value = Parameter(datatype=FloatRange(unit='deg')) + target = Parameter(datatype=FloatRange(), default=0) a1 = Parameter(datatype=FloatRange(unit='$/s'), readonly=False) + # remark: it might be a programming error to override the datatype + # and not overriding the read_* method. This is not checked! b2 = Parameter('', datatype=BoolType(), default=True, - poll=True, readonly=False, initwrite=True) + readonly=False, initwrite=True) def write_a1(self, value): self._a1_written = value @@ -170,30 +186,33 @@ def test_ModuleMagic(): # check for inital updates working properly o1 = Newclass1('o1', logger, {'.description':''}, srv) - expectedBeforeStart = {'target': 0.0, 'status': (Drivable.Status.IDLE, ''), + expectedBeforeStart = {'target': '', 'status': (Drivable.Status.IDLE, ''), 'param1': False, 'param2': 1.0, 'a1': 0.0, 'a2': True, 'pollinterval': 5.0, 'value': 'first'} assert updates.pop('o1') == expectedBeforeStart o1.earlyInit() - event = threading.Event() - o1.startModule(event.set) + event = DummyMultiEvent() + o1.startModule(event) event.wait() # should contain polled values - expectedAfterStart = {'status': (Drivable.Status.IDLE, ''), - 'value': 'second'} + expectedAfterStart = { + 'status': (Drivable.Status.IDLE, 'ok'), 'value': 'second', + 'param1': True, 'param2': 0.0, 'a1': True} assert updates.pop('o1') == expectedAfterStart # check in addition if parameters are written o2 = Newclass2('o2', logger, {'.description':'', 'a1': 2.7}, srv) # no update for b2, as this has to be written expectedBeforeStart['a1'] = 2.7 + expectedBeforeStart['target'] = 0.0 assert updates.pop('o2') == expectedBeforeStart o2.earlyInit() - event = threading.Event() - o2.startModule(event.set) + event = DummyMultiEvent() + o2.startModule(event) event.wait() # value has changed type, b2 and a1 are written - expectedAfterStart.update(value=0, b2=True, a1=2.7) + expectedAfterStart.update(value=0, b2=True, a1=True) + # ramerk: a1=True: this behaviour is a Porgamming error assert updates.pop('o2') == expectedAfterStart assert o2._a1_written == 2.7 assert o2._b2_written is True @@ -210,13 +229,15 @@ def test_ModuleMagic(): # check '$' in unit works properly assert o2.parameters['a1'].datatype.unit == 'mm/s' cfg = Newclass2.configurables - assert set(cfg.keys()) == {'export', 'group', 'description', + assert set(cfg.keys()) == { + 'export', 'group', 'description', 'disable_value_range_check', 'meaning', 'visibility', 'implementation', 'interface_classes', 'target', 'stop', - 'status', 'param1', 'param2', 'cmd', 'a2', 'pollinterval', 'b2', 'cmd2', 'value', - 'a1'} - assert set(cfg['value'].keys()) == {'group', 'export', 'relative_resolution', + 'status', 'param1', 'param2', 'cmd', 'a2', 'pollinterval', 'slowinterval', 'b2', + 'cmd2', 'value', 'a1'} + assert set(cfg['value'].keys()) == { + 'group', 'export', 'relative_resolution', 'visibility', 'unit', 'default', 'datatype', 'fmtstr', - 'absolute_resolution', 'poll', 'max', 'min', 'readonly', 'constant', + 'absolute_resolution', 'max', 'min', 'readonly', 'constant', 'description', 'needscfg'} # check on the level of classes @@ -459,3 +480,177 @@ def test_command_none(): assert 'stop' in Mod('o', logger, {'description': ''}, srv).accessibles assert 'stop' not in Mod2('o', logger, {'description': ''}, srv).accessibles + + +def test_bad_method(): + class Mod0(Drivable): # pylint: disable=unused-variable + def write_target(self, value): + pass + + with pytest.raises(ProgrammingError): + class Mod1(Drivable): # pylint: disable=unused-variable + def write_taget(self, value): + pass + + class Mod2(Drivable): # pylint: disable=unused-variable + def read_value(self, value): + pass + + with pytest.raises(ProgrammingError): + class Mod3(Drivable): # pylint: disable=unused-variable + def read_valu(self, value): + pass + + +def test_generic_access(): + class Mod(Module): + param = Parameter('handled param', StringType(), readonly=False) + unhandled = Parameter('unhandled param', StringType(), default='', readonly=False) + data = {'param': ''} + + @ReadHandler(['param']) + def read_handler(self, pname): + value = self.data[pname] + setattr(self, pname, value) + return value + + @WriteHandler(['param']) + def write_handler(self, pname, value): + value = value.lower() + self.data[pname] = value + setattr(self, pname, value) + return value + + updates = {} + srv = ServerStub(updates) + + obj = Mod('obj', logger, {'description': '', 'param': 'initial value'}, srv) + assert obj.param == 'initial value' + assert obj.write_param('Cheese') == 'cheese' + assert obj.write_unhandled('Cheese') == 'Cheese' + assert updates == {'obj': {'param': 'cheese', 'unhandled': 'Cheese'}} + updates.clear() + assert obj.write_param('Potato') == 'potato' + assert updates == {'obj': {'param': 'potato'}} + updates.clear() + assert obj.read_param() == 'potato' + assert obj.read_unhandled() + assert updates == {'obj': {'param': 'potato'}} + updates.clear() + assert updates == {} + + +def test_duplicate_handler_name(): + with pytest.raises(ProgrammingError): + class Mod(Module): # pylint: disable=unused-variable + param = Parameter('handled param', StringType(), readonly=False) + + @ReadHandler(['param']) + def handler(self, pname): + pass + + @WriteHandler(['param']) + def handler(self, pname, value): # pylint: disable=function-redefined + pass + + +def test_handler_overwrites_method(): + with pytest.raises(RuntimeError): + class Mod1(Module): # pylint: disable=unused-variable + param = Parameter('handled param', StringType(), readonly=False) + + @ReadHandler(['param']) + def read_handler(self, pname): + pass + + def read_param(self): + pass + + with pytest.raises(RuntimeError): + class Mod2(Module): # pylint: disable=unused-variable + param = Parameter('handled param', StringType(), readonly=False) + + @WriteHandler(['param']) + def write_handler(self, pname, value): + pass + + def write_param(self, value): + pass + + +def test_no_read_write(): + class Mod(Module): + param = Parameter('test param', StringType(), readonly=False) + + updates = {} + srv = ServerStub(updates) + + obj = Mod('obj', logger, {'description': '', 'param': 'cheese'}, srv) + assert obj.param == 'cheese' + assert obj.read_param() == 'cheese' + assert updates == {'obj': {'param': 'cheese'}} + assert obj.write_param('egg') == 'egg' + assert obj.param == 'egg' + assert updates == {'obj': {'param': 'egg'}} + + +def test_incompatible_value_target(): + class Mod1(Drivable): + value = Parameter('', FloatRange(0, 10), default=0) + target = Parameter('', FloatRange(0, 11), default=0) + + class Mod2(Drivable): + value = Parameter('', FloatRange(), default=0) + target = Parameter('', StringType(), default='') + + class Mod3(Drivable): + value = Parameter('', FloatRange(), default=0) + target = Parameter('', ScaledInteger(1, 0, 10), default=0) + + srv = ServerStub({}) + + with pytest.raises(ConfigError): + obj = Mod1('obj', logger, {'description': ''}, srv) # pylint: disable=unused-variable + + with pytest.raises(ProgrammingError): + obj = Mod2('obj', logger, {'description': ''}, srv) + + obj = Mod3('obj', logger, {'description': ''}, srv) + + +def test_problematic_value_range(): + class Mod(Drivable): + value = Parameter('', FloatRange(0, 10), default=0) + target = Parameter('', FloatRange(0, 10), default=0) + + srv = ServerStub({}) + + obj = Mod('obj', logger, {'description': '', 'value.max': 10.1}, srv) # pylint: disable=unused-variable + + with pytest.raises(ConfigError): + obj = Mod('obj', logger, {'description': ''}, srv) + + class Mod2(Drivable): + value = Parameter('', FloatRange(), default=0) + target = Parameter('', FloatRange(), default=0) + + obj = Mod2('obj', logger, {'description': ''}, srv) + obj = Mod2('obj', logger, {'description': '', 'target.min': 0, 'target.max': 10}, srv) + + with pytest.raises(ConfigError): + obj = Mod('obj', logger, { + 'value.min': 0, 'value.max': 10, + 'target.min': 0, 'target.max': 10, 'description': ''}, srv) + + obj = Mod('obj', logger, {'disable_value_range_check': True, + 'value.min': 0, 'value.max': 10, + 'target.min': 0, 'target.max': 10, 'description': ''}, srv) + + generalConfig.defaults['disable_value_range_check'] = True + + class Mod4(Drivable): + value = Parameter('', FloatRange(0, 10), default=0) + target = Parameter('', FloatRange(0, 10), default=0) + obj = Mod4('obj', logger, { + 'value.min': 0, 'value.max': 10, + 'target.min': 0, 'target.max': 10, 'description': ''}, srv) diff --git a/test/test_multievent.py b/test/test_multievent.py new file mode 100644 index 0000000..3cf1106 --- /dev/null +++ b/test/test_multievent.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** + +import time +from secop.lib.multievent import MultiEvent + + +def test_without_timeout(): + m = MultiEvent() + s1 = m.get_trigger(name='s1') + s2 = m.get_trigger(name='s2') + assert not m.wait(0) + assert m.deadline() is None + assert m.waiting_for() == {'s1', 's2'} + s2() + assert m.waiting_for() == {'s1'} + assert not m.wait(0) + s1() + assert not m.waiting_for() + assert m.wait(0) + + +def test_with_timeout(monkeypatch): + current_time = 1000 + monkeypatch.setattr(time, 'monotonic', lambda: current_time) + m = MultiEvent() + assert m.deadline() == 0 + m.name = 's1' + s1 = m.get_trigger(10) + assert m.deadline() == 1010 + m.name = 's2' + s2 = m.get_trigger(20) + assert m.deadline() == 1020 + current_time += 21 + assert not m.wait(0) + assert m.waiting_for() == {'s1', 's2'} + s1() + assert m.waiting_for() == {'s2'} + s2() + assert not m.waiting_for() + assert m.wait(0) diff --git a/test/test_poller.py b/test/test_poller.py index 2987a9e..ae94e29 100644 --- a/test/test_poller.py +++ b/test/test_poller.py @@ -21,18 +21,20 @@ # ***************************************************************************** """test poller.""" +import sys +import threading import time -from collections import OrderedDict +import logging import pytest -from secop.modules import Drivable -from secop.poller import DYNAMIC, REGULAR, SLOW, Poller +from secop.core import Module, Parameter, FloatRange, Readable, ReadHandler, nopoll +from secop.lib.multievent import MultiEvent -Status = Drivable.Status class Time: - STARTTIME = 1000 # artificial time zero + STARTTIME = 1000 # artificial time zero + def __init__(self): self.reset() self.finish = float('inf') @@ -61,187 +63,103 @@ class Time: self.seconds += seconds self.busytime += seconds + artime = Time() # artificial test time -@pytest.fixture(autouse=True) -def patch_time(monkeypatch): - monkeypatch.setattr(time, 'time', artime.time) + +class Event(threading.Event): + def wait(self, timeout=None): + artime.sleep(max(0, timeout)) -class Event: - def __init__(self): - self.flag = False +class DispatcherStub: + maxcycles = 10 - def wait(self, timeout): - artime.sleep(max(0,timeout)) - - def set(self): - self.flag = True - - def clear(self): - self.flag = False - - def is_set(self): - return self.flag - - -class Parameter: - def __init__(self, name, readonly, poll, polltype, interval): - self.poll = poll - self.polltype = polltype # used for check only - self.export = name - self.readonly = readonly - self.interval = interval - self.timestamp = 0 - self.handler = None - self.reset() - - def reset(self): - self.cnt = 0 - self.span = 0 - self.maxspan = 0 - - def rfunc(self): - artime.busy(artime.commtime) + def announce_update(self, modulename, pname, pobj): now = artime.time() - self.span = now - self.timestamp - self.maxspan = max(self.maxspan, self.span) - self.timestamp = now - self.cnt += 1 - return True - - def __repr__(self): - return 'Parameter(%s)' % ", ".join("%s=%r" % item for item in self.__dict__.items()) - - -class Module: - properties = {} - pollerClass = Poller - iodev = 'common_iodev' - def __init__(self, name, pollinterval=5, fastfactor=0.25, slowfactor=4, busy=False, - counts=(), auto=None): - '''create a dummy module - - nauto, ndynamic, nregular, nslow are the number of parameters of each polltype - ''' - self.pollinterval = pollinterval - self.fast_pollfactor = fastfactor - self.slow_pollfactor = slowfactor - self.parameters = OrderedDict() - self.name = name - self.is_busy = busy - if auto is not None: - self.pvalue = self.addPar('value', True, auto or DYNAMIC, DYNAMIC) - # readonly = False should not matter: - self.pstatus = self.addPar('status', False, auto or DYNAMIC, DYNAMIC) - self.pregular = self.addPar('regular', True, auto or REGULAR, REGULAR) - self.pslow = self.addPar('slow', False, auto or SLOW, SLOW) - self.addPar('notpolled', True, False, 0) - self.counts = 'auto' + if hasattr(pobj, 'stat'): + pobj.stat.append(now) else: - ndynamic, nregular, nslow = counts - for i in range(ndynamic): - self.addPar('%s:d%d' % (name, i), True, DYNAMIC, DYNAMIC) - for i in range(nregular): - self.addPar('%s:r%d' % (name, i), True, REGULAR, REGULAR) - for i in range(nslow): - self.addPar('%s:s%d' % (name, i), False, SLOW, SLOW) - self.counts = counts + pobj.stat = [now] + self.maxcycles -= 1 + if self.maxcycles <= 0: + self.finish_event.set() + sys.exit() # stop thread - def addPar(self, name, readonly, poll, expected_polltype): - # self.count[polltype] += 1 - expected_interval = self.pollinterval - if expected_polltype == SLOW: - expected_interval *= self.slow_pollfactor - elif expected_polltype == DYNAMIC and self.is_busy: - expected_interval *= self.fast_pollfactor - pobj = Parameter(name, readonly, poll, expected_polltype, expected_interval) - setattr(self, 'read_' + pobj.export, pobj.rfunc) - self.parameters[pobj.export] = pobj - return pobj - def isBusy(self): - return self.is_busy +class ServerStub: + def __init__(self): + self.dispatcher = DispatcherStub() - def pollOneParam(self, pname): - getattr(self, 'read_' + pname)() - def writeInitParams(self): - pass +class Base(Module): + def __init__(self): + srv = ServerStub() + super().__init__('mod', logging.getLogger('dummy'), dict(description=''), srv) + self.dispatcher = srv.dispatcher + self.nextPollEvent = Event() - def __repr__(self): - rdict = self.__dict__.copy() - rdict.pop('parameters') - return 'Module(%r, counts=%r, f=%r, pollinterval=%g, is_busy=%r)' % (self.name, - self.counts, (self.fast_pollfactor, self.slow_pollfactor, 1), - self.pollinterval, self.is_busy) + def run(self, maxcycles): + self.dispatcher.maxcycles = maxcycles + self.dispatcher.finish_event = threading.Event() + self.startModule(MultiEvent()) + self.dispatcher.finish_event.wait(1) -module_list = [ - [Module('x', 3.0, 0.125, 10, False, auto=True), - Module('y', 3.0, 0.125, 10, False, auto=False)], - [Module('a', 1.0, 0.25, 4, True, (5, 5, 10)), - Module('b', 2.0, 0.25, 4, True, (5, 5, 50))], - [Module('c', 1.0, 0.25, 4, False, (5, 0, 0))], - [Module('d', 1.0, 0.25, 4, True, (0, 9, 0))], - [Module('e', 1.0, 0.25, 4, True, (0, 0, 9))], - [Module('f', 1.0, 0.25, 4, True, (0, 0, 0))], - ] -@pytest.mark.parametrize('modules', module_list) -def test_Poller(modules): - # check for proper timing - for overloaded in False, True: - artime.reset() - count = {DYNAMIC: 0, REGULAR: 0, SLOW: 0} - maxspan = {DYNAMIC: 0, REGULAR: 0, SLOW: 0} - pollTable = dict() - for module in modules: - Poller.add_to_table(pollTable, module) - for pobj in module.parameters.values(): - if pobj.poll: - maxspan[pobj.polltype] = max(maxspan[pobj.polltype], pobj.interval) - count[pobj.polltype] += 1 - pobj.reset() - assert len(pollTable) == 1 - poller = pollTable[(Poller, 'common_iodev')] - artime.stop = poller.stop - poller._event = Event() # patch Event.wait +class Mod1(Base, Readable): + param1 = Parameter('', FloatRange()) + param2 = Parameter('', FloatRange()) + param3 = Parameter('', FloatRange()) + param4 = Parameter('', FloatRange()) - assert (sum(count.values()) > 0) == bool(poller) + @ReadHandler(('param1', 'param2', 'param3')) + def read_param(self, name): + artime.sleep(1.0) + return 0 - def started_callback(modules=modules): - for module in modules: - for pobj in module.parameters.values(): - assert pobj.cnt == bool(pobj.poll) # all parameters have to be polled once - pobj.reset() # set maxspan and cnt to 0 + @nopoll + def read_param4(self): + return 0 - if overloaded: - # overloaded scenario - artime.commtime = 1.0 - ncycles = 10 - if count[SLOW] > 0: - cycletime = (count[REGULAR] + 1) * count[SLOW] * 2 - else: - cycletime = max(count[REGULAR], count[DYNAMIC]) * 2 - artime.reset(cycletime * ncycles * 1.01) # poller will quit given time - poller.run(started_callback) - total = artime.time() - artime.STARTTIME - for module in modules: - for pobj in module.parameters.values(): - if pobj.poll: - # average_span = total / (pobj.cnt + 1) - assert total / (pobj.cnt + 1) <= max(cycletime, pobj.interval * 1.1) - else: - # normal scenario - artime.commtime = 0.001 - artime.reset(max(maxspan.values()) * 5) # poller will quit given time - poller.run(started_callback) - total = artime.time() - artime.STARTTIME - for module in modules: - for pobj in module.parameters.values(): - if pobj.poll: - assert pobj.cnt > 0 - assert pobj.maxspan <= maxspan[pobj.polltype] * 1.1 - assert (pobj.cnt + 1) * pobj.interval >= total * 0.99 - assert abs(pobj.span - pobj.interval) < 0.01 - pobj.reset() + def read_status(self): + artime.sleep(1.0) + return 0 + + def read_value(self): + artime.sleep(1.0) + return 0 + + +@pytest.mark.parametrize( + 'ncycles, pollinterval, slowinterval, mspan, pspan', + [ # normal case: 5+-1 15+-1 + ( 60, 5, 15, (4, 6), (14, 16)), + # pollinterval faster then reading: mspan max 3 s (polls of value, status and ONE other parameter) + ( 60, 1, 5, (1, 3), (5, 16)), + ]) +def test_poll(ncycles, pollinterval, slowinterval, mspan, pspan, monkeypatch): + monkeypatch.setattr(time, 'time', artime.time) + artime.reset() + m = Mod1() + m.pollinterval = pollinterval + m.slowInterval = slowinterval + m.run(ncycles) + assert not hasattr(m.parameters['param4'], 'stat') + for pname in ['value', 'status']: + pobj = m.parameters[pname] + lowcnt = 0 + for t1, t2 in zip(pobj.stat[1:], pobj.stat[2:-1]): + if t2 - t1 < mspan[0]: + print(t2 - t1) + lowcnt += 1 + assert t2 - t1 <= mspan[1] + assert lowcnt <= 1 + for pname in ['param1', 'param2', 'param3']: + pobj = m.parameters[pname] + lowcnt = 0 + for t1, t2 in zip(pobj.stat[1:], pobj.stat[2:-1]): + if t2 - t1 < pspan[0]: + print(pname, t2 - t1) + lowcnt += 1 + assert t2 - t1 <= pspan[1] + assert lowcnt <= 1 diff --git a/test/test_properties.py b/test/test_properties.py index 3aca31b..6c1d8e4 100644 --- a/test/test_properties.py +++ b/test/test_properties.py @@ -26,6 +26,7 @@ import pytest from secop.datatypes import FloatRange, IntRange, StringType, ValueType from secop.errors import BadValueError, ConfigError, ProgrammingError from secop.properties import HasProperties, Property +from secop.core import Parameter def Prop(*args, name=None, **kwds): @@ -38,10 +39,10 @@ V_test_Property = [ [Prop(StringType(), 'default', extname='extname', mandatory=False), dict(default='default', extname='extname', export=True, mandatory=False) ], - [Prop(IntRange(), '42', export=True, name='custom', mandatory=True), + [Prop(IntRange(), 42, export=True, name='custom', mandatory=True), dict(default=42, extname='_custom', export=True, mandatory=True), ], - [Prop(IntRange(), '42', export=True, name='name'), + [Prop(IntRange(), 42, export=True, name='name'), dict(default=42, extname='_name', export=True, mandatory=False) ], [Prop(IntRange(), 42, '_extname', mandatory=True), @@ -85,12 +86,12 @@ def test_Property_basic(): Property('') with pytest.raises(ValueError): Property('', 1) - Property('', IntRange(), '42', 'extname', False, False) + Property('', IntRange(), 42, 'extname', False, False) def test_Properties(): class Cls(HasProperties): - aa = Property('', IntRange(0, 99), '42', export=True) + aa = Property('', IntRange(0, 99), 42, export=True) bb = Property('', IntRange(), 0, export=False) assert Cls.aa.default == 42 @@ -149,17 +150,25 @@ def test_Property_override(): assert o2.a == 3 with pytest.raises(ProgrammingError) as e: - class cx(c): # pylint: disable=unused-variable + class cx(c): # pylint: disable=unused-variable def a(self): pass assert 'collides with' in str(e.value) with pytest.raises(ProgrammingError) as e: - class cz(c): # pylint: disable=unused-variable + class cy(c): # pylint: disable=unused-variable a = 's' assert 'can not set' in str(e.value) + with pytest.raises(ProgrammingError) as e: + class cz(c): # pylint: disable=unused-variable + a = 's' + + class cp(c): # pylint: disable=unused-variable + # overriding a Property with a Parameter is allowed + a = Parameter('x', IntRange()) + def test_Properties_mro(): class Base(HasProperties): diff --git a/test/test_statemachine.py b/test/test_statemachine.py new file mode 100644 index 0000000..fb61dda --- /dev/null +++ b/test/test_statemachine.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** + + +from secop.lib.statemachine import StateMachine, Stop, Retry + + +def rise(state): + state.step += 1 + print('rise', state.step) + if state.init: + state.status = 'rise' + state.level += 1 + if state.level > 3: + return turn + return Retry() + + +def turn(state): + state.step += 1 + if state.init: + state.status = 'turn' + state.direction += 1 + if state.direction > 3: + return fall + return Retry() + + +def fall(state): + state.step += 1 + if state.init: + state.status = 'fall' + state.level -= 1 + if state.level < 0: + raise ValueError('crash') + return Retry(0) # retry until crash! + + +def error_handler(state): + state.last_error_name = type(state.last_error).__name__ + + +class LoggerStub: + def debug(self, fmt, *args): + print(fmt % args) + info = warning = exception = error = debug + handlers = [] + + +class DummyThread: + def is_alive(self): + return True + + +def test_fun(): + s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) + assert s.step == 0 + assert s.status == '' + s.cycle() # do nothing + assert s.step == 0 + s.start(rise, level=0, direction=0) + s.cycle() + for i in range(1, 4): + assert s.status == 'rise' + assert s.step == i + assert s.level == i + assert s.direction == 0 + s.cycle() + for i in range(5, 8): + assert s.status == 'turn' + assert s.step == i + assert s.level == 4 + assert s.direction == i - 4 + s.cycle() + s.cycle() # -> crash + assert isinstance(s.last_error, ValueError) + assert str(s.last_error) == 'crash' + assert s.state is None + + +def test_max_chain(): + s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) + s.start(fall, level=999+1, direction=0) + s.cycle() + assert isinstance(s.last_error, RuntimeError) + assert s.state is None + + +def test_stop(): + s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) + s.start(rise, level=0, direction=0) + for _ in range(1, 3): + s.cycle() + s.stop() + s.cycle() + assert isinstance(s.last_error, Stop) + assert s.state is None + + +def test_std_error_handling(): + s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) + s.start(rise, level=0, direction=0) + s.cycle() + s.level = None # -> TypeError on next step + s.cycle() + assert s.state is None # default error handler: stop machine + assert isinstance(s.last_error, TypeError) + assert not hasattr(s, 'last_error_name') + + +def test_default_error_handling(): + s = StateMachine(step=0, status='', cleanup=error_handler, threaded=False, logger=LoggerStub()) + s.start(rise, level=0, direction=0) + s.cycle() + s.level = None + s.cycle() + assert s.state is None + assert s.last_error_name == 'TypeError' + assert isinstance(s.last_error, TypeError) + + +def test_cleanup_on_restart(): + s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub()) + s.start(rise, level=0, direction=0) + s.cycle() + s.start(turn) + s.cycle() + assert s.state is turn + assert s.last_error is None