diff --git a/light9/collector/collector.py b/light9/collector/collector.py --- a/light9/collector/collector.py +++ b/light9/collector/collector.py @@ -2,13 +2,22 @@ from __future__ import division import time import logging from rdflib import Literal -from light9.namespaces import L9, RDF, DEV +from light9.namespaces import L9, RDF from light9.collector.output import setListElem from light9.collector.device import toOutputAttrs, resolve +# types only +from rdflib import Graph, URIRef +from typing import List, Dict, Tuple, Any, TypeVar, Generic +from light9.collector.output import Output + +ClientType = TypeVar('ClientType') +ClientSessionType = TypeVar('ClientSessionType') + log = logging.getLogger('collector') def outputMap(graph, outputs): + # type: (Graph, List[Output]) -> Dict[Tuple[URIRef, URIRef], Tuple[Output, int]] """From rdf config graph, compute a map of (device, outputattr) : (output, index) that explains which output index to set for any device update. @@ -20,26 +29,38 @@ def outputMap(graph, outputs): outputByUri[out.uri] = out for dc in graph.subjects(RDF.type, L9['DeviceClass']): + log.info('mapping DeviceClass %s', dc) for dev in graph.subjects(RDF.type, dc): - output = outputByUri[graph.value(dev, L9['dmxUniverse'])] + log.info(' mapping device %s', dev) + universe = graph.value(dev, L9['dmxUniverse']) + try: + output = outputByUri[universe] + except Exception: + log.warn('dev %r :dmxUniverse %r', dev, universe) + raise dmxBase = int(graph.value(dev, L9['dmxBase']).toPython()) for row in graph.objects(dc, L9['attr']): outputAttr = graph.value(row, L9['outputAttr']) offset = int(graph.value(row, L9['dmxOffset']).toPython()) index = dmxBase + offset - 1 ret[(dev, outputAttr)] = (output, index) - log.info('map %s,%s to %s,%s', dev, outputAttr, output, index) + log.info(' map %s to %s,%s', outputAttr, output, index) return ret -class Collector(object): +class Collector(Generic[ClientType, ClientSessionType]): def __init__(self, graph, outputs, clientTimeoutSec=10): + # type: (Graph, List[Output], float) -> None self.graph = graph self.outputs = outputs self.clientTimeoutSec = clientTimeoutSec self.graph.addHandler(self.rebuildOutputMap) - self.lastRequest = {} # client : (session, time, {(dev,devattr): latestValue}) - self.stickyAttrs = {} # (dev, devattr): value to use instead of 0 + + # client : (session, time, {(dev,devattr): latestValue}) + self.lastRequest = {} # type: Dict[ClientType, Tuple[ClientSessionType, float, Dict[Tuple[URIRef, URIRef], float]]] + + # (dev, devAttr): value to use instead of 0 + self.stickyAttrs = {} # type: Dict[Tuple[URIRef, URIRef], float] def rebuildOutputMap(self): self.outputMap = outputMap(self.graph, self.outputs) # (device, outputattr) : (output, index) @@ -56,15 +77,18 @@ class Collector(object): self.remapOut[(dev, attr)] = start, end def _forgetStaleClients(self, now): + # type: (float) -> None staleClients = [] - for c, (_, t, _) in self.lastRequest.iteritems(): + for c, (_, t, _2) in self.lastRequest.iteritems(): if t < now - self.clientTimeoutSec: staleClients.append(c) for c in staleClients: + log.info('forgetting stale client %r', c) del self.lastRequest[c] def resolvedSettingsDict(self, settingsList): - out = {} + # type: (List[Tuple[URIRef, URIRef, float]]) -> Dict[Tuple[URIRef, URIRef], float] + out = {} # type: Dict[Tuple[URIRef, URIRef], float] for d, da, v in settingsList: if (d, da) in out: out[(d, da)] = resolve(d, da, [out[(d, da)], v]) @@ -72,6 +96,12 @@ class Collector(object): out[(d, da)] = v return out + def _warnOnLateRequests(self, client, now, sendTime): + requestLag = now - sendTime + if requestLag > .1: + log.warn('collector.setAttrs from %s is running %.1fms after the request was made', + client, requestLag * 1000) + def setAttrs(self, client, clientSession, settings, sendTime): """ settings is a list of (device, attr, value). These attrs are @@ -82,10 +112,7 @@ class Collector(object): Call with settings=[] to ping us that your session isn't dead. """ now = time.time() - requestLag = now - sendTime - if requestLag > .1: - log.warn('collector.setAttrs from %s is running %.1fms after the request was made', - client, requestLag * 1000) + self._warnOnLateRequests(client, now, sendTime) self._forgetStaleClients(now) @@ -128,6 +155,7 @@ class Collector(object): pendingOut = {} # output : values for out in self.outputs: pendingOut[out] = [0] * out.numChannels + for device, attrs in outputAttrs.iteritems(): for outputAttr, value in attrs.iteritems(): self.setAttr(device, outputAttr, value, pendingOut) diff --git a/light9/collector/collector_test.py b/light9/collector/collector_test.py --- a/light9/collector/collector_test.py +++ b/light9/collector/collector_test.py @@ -1,7 +1,7 @@ import unittest -import datetime +import datetime, time from freezegun import freeze_time -from rdflib import Namespace +from rdflib import Namespace, URIRef from light9.namespaces import L9, DEV from light9.collector.collector import Collector, outputMap @@ -17,10 +17,32 @@ PREFIX = ''' @prefix dmx0: . ''' +THEATER = ''' + :brightness a :DeviceAttr; :dataType :scalar . + + :SimpleDimmer a :DeviceClass; + :deviceAttr :brightness; + :attr + [ :outputAttr :level; :dmxOffset 0 ] . + + :ChauvetColorStrip a :DeviceClass; + :deviceAttr :color; + :attr + [ :outputAttr :mode; :dmxOffset 0 ], + [ :outputAttr :red; :dmxOffset 1 ], + [ :outputAttr :green; :dmxOffset 2 ], + [ :outputAttr :blue; :dmxOffset 3 ] . + +''' + +t0 = 0 # time + class MockOutput(object): - def __init__(self, connections): + def __init__(self, uri, connections): self.connections = connections self.updates = [] + self.uri = uri + self.numChannels = 4 def allConnections(self): return self.connections @@ -31,29 +53,30 @@ class MockOutput(object): def flush(self): self.updates.append('flush') +@unittest.skip("outputMap got rewritten and mostly doesn't raise on these cases") class TestOutputMap(unittest.TestCase): def testWorking(self): - out0 = MockOutput([(0, DMX0['c1'])]) + out0 = MockOutput(UDMX, [(0, DMX0['c1'])]) m = outputMap(MockSyncedGraph(PREFIX + ''' dmx0:c1 :connectedTo dev:inst1Brightness . dev:inst1 a :Device; :brightness dev:inst1Brightness . '''), [out0]) self.assertEqual({(DEV['inst1'], L9['brightness']): (out0, 0)}, m) + def testMissingOutput(self): - out0 = MockOutput([(0, DMX0['c1'])]) + out0 = MockOutput(UDMX, [(0, DMX0['c1'])]) self.assertRaises(KeyError, outputMap, MockSyncedGraph(PREFIX + ''' - dmx0:c2 :connectedTo dev:inst1Brightness . dev:inst1 a :Device; :brightness dev:inst1Brightness . '''), [out0]) def testMissingOutputConnection(self): - out0 = MockOutput([(0, DMX0['c1'])]) + out0 = MockOutput(UDMX, [(0, DMX0['c1'])]) self.assertRaises(ValueError, outputMap, MockSyncedGraph(PREFIX + ''' dev:inst1 a :Device; :brightness dev:inst1Brightness . '''), [out0]) def testMultipleOutputConnections(self): - out0 = MockOutput([(0, DMX0['c1'])]) + out0 = MockOutput(UDMX, [(0, DMX0['c1'])]) self.assertRaises(ValueError, outputMap, MockSyncedGraph(PREFIX + ''' dmx0:c1 :connectedTo dev:inst1Brightness . dmx0:c2 :connectedTo dev:inst1Brightness . @@ -64,137 +87,147 @@ class TestOutputMap(unittest.TestCase): class TestCollector(unittest.TestCase): def setUp(self): - self.config = MockSyncedGraph(PREFIX + ''' - - udmx:c1 :connectedTo dev:colorStripRed . - udmx:c2 :connectedTo dev:colorStripGreen . - udmx:c3 :connectedTo dev:colorStripBlue . - udmx:c4 :connectedTo dev:colorStripMode . + self.config = MockSyncedGraph(PREFIX + THEATER + ''' dev:colorStrip a :Device, :ChauvetColorStrip; + :dmxUniverse udmx:; :dmxBase 1; :red dev:colorStripRed; :green dev:colorStripGreen; :blue dev:colorStripBlue; :mode dev:colorStripMode . - dmx0:c1 :connectedTo dev:inst1Brightness . - dev:inst1 a :Device, :Dimmer; - :brightness dev:inst1Brightness . + dev:inst1 a :Device, :SimpleDimmer; + :dmxUniverse dmx0:; :dmxBase 1; + :level dev:inst1Brightness . ''') - self.dmx0 = MockOutput([(0, DMX0['c1'])]) - self.udmx = MockOutput([(0, UDMX['c1']), - (1, UDMX['c2']), - (2, UDMX['c3']), - (3, UDMX['c4'])]) + self.dmx0 = MockOutput(DMX0[None], [(0, DMX0['c1'])]) + self.udmx = MockOutput(UDMX[None], [(0, UDMX['c1']), + (1, UDMX['c2']), + (2, UDMX['c3']), + (3, UDMX['c4'])]) def testRoutesColorOutput(self): c = Collector(self.config, outputs=[self.dmx0, self.udmx]) c.setAttrs('client', 'sess1', - [(DEV['colorStrip'], L9['color'], '#00ff00')]) + [(DEV['colorStrip'], L9['color'], '#00ff00')], t0) - self.assertEqual([[0, 255, 0, 215], 'flush'], self.udmx.updates) - self.assertEqual([], self.dmx0.updates) + self.assertEqual([[215, 0, 255, 0], 'flush'], self.udmx.updates) + self.assertEqual([[0, 0, 0, 0], 'flush'], self.dmx0.updates) def testOutputMaxOfTwoClients(self): c = Collector(self.config, outputs=[self.dmx0, self.udmx]) c.setAttrs('client1', 'sess1', - [(DEV['colorStrip'], L9['color'], '#ff0000')]) + [(DEV['colorStrip'], L9['color'], '#ff0000')], t0) c.setAttrs('client2', 'sess1', - [(DEV['colorStrip'], L9['color'], '#333333')]) + [(DEV['colorStrip'], L9['color'], '#333333')], t0) - self.assertEqual([[255, 0, 0, 215], 'flush', - [255, 51, 51, 215], 'flush'], + self.assertEqual([[215, 255, 0, 0], 'flush', + [215, 255, 51, 51], 'flush'], self.udmx.updates) - self.assertEqual([], self.dmx0.updates) + self.assertEqual([[0, 0, 0, 0], 'flush', [0, 0, 0, 0], 'flush'], + self.dmx0.updates) def testClientOnSameOutputIsRememberedOverCalls(self): c = Collector(self.config, outputs=[self.dmx0, self.udmx]) c.setAttrs('client1', 'sess1', - [(DEV['colorStrip'], L9['color'], '#080000')]) + [(DEV['colorStrip'], L9['color'], '#080000')], t0) c.setAttrs('client2', 'sess1', - [(DEV['colorStrip'], L9['color'], '#060000')]) + [(DEV['colorStrip'], L9['color'], '#060000')], t0) c.setAttrs('client1', 'sess1', - [(DEV['colorStrip'], L9['color'], '#050000')]) + [(DEV['colorStrip'], L9['color'], '#050000')], t0) - self.assertEqual([[8, 0, 0, 215], 'flush', - [8, 0, 0, 215], 'flush', - [6, 0, 0, 215], 'flush'], + self.assertEqual([[215, 8, 0, 0], 'flush', + [215, 8, 0, 0], 'flush', + [215, 6, 0, 0], 'flush'], self.udmx.updates) - self.assertEqual([], self.dmx0.updates) + self.assertEqual([[0, 0, 0, 0], 'flush', + [0, 0, 0, 0], 'flush', + [0, 0, 0, 0], 'flush'], + self.dmx0.updates) def testClientsOnDifferentOutputs(self): c = Collector(self.config, outputs=[self.dmx0, self.udmx]) - c.setAttrs('client1', 'sess1', [(DEV['colorStrip'], L9['color'], '#aa0000')]) - c.setAttrs('client2', 'sess1', [(DEV['inst1'], L9['brightness'], .5)]) + c.setAttrs('client1', 'sess1', [(DEV['colorStrip'], L9['color'], '#aa0000')], t0) + c.setAttrs('client2', 'sess1', [(DEV['inst1'], L9['brightness'], .5)], t0) # ok that udmx is flushed twice- it can screen out its own duplicates - self.assertEqual([[170, 0, 0, 215], 'flush', - [170, 0, 0, 215], 'flush'], self.udmx.updates) - self.assertEqual([[127], 'flush'], self.dmx0.updates) + self.assertEqual([[215, 170, 0, 0], 'flush', + [215, 170, 0, 0], 'flush'], self.udmx.updates) + self.assertEqual([[0, 0, 0, 0], 'flush', + [127, 0, 0, 0], 'flush'], self.dmx0.updates) def testNewSessionReplacesPreviousOutput(self): # ..as opposed to getting max'd with it c = Collector(self.config, outputs=[self.dmx0, self.udmx]) - c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], .8)]) - c.setAttrs('client1', 'sess2', [(DEV['inst1'], L9['brightness'], .5)]) + c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], .8)], t0) + c.setAttrs('client1', 'sess2', [(DEV['inst1'], L9['brightness'], .5)], t0) - self.assertEqual([[204], 'flush', [127], 'flush'], self.dmx0.updates) + self.assertEqual([[204, 0, 0, 0], 'flush', + [127, 0, 0, 0], 'flush'], self.dmx0.updates) def testNewSessionDropsPreviousSettingsOfOtherAttrs(self): - - c = Collector(MockSyncedGraph(PREFIX + ''' - - udmx:c1 :connectedTo dev:colorStripRed . - udmx:c2 :connectedTo dev:colorStripGreen . - udmx:c3 :connectedTo dev:colorStripBlue . - udmx:c4 :connectedTo dev:colorStripMode . + c = Collector(MockSyncedGraph(PREFIX + THEATER + ''' dev:colorStrip a :Device, :ChauvetColorStrip; + :dmxUniverse udmx:; :dmxBase 1; :red dev:colorStripRed; :green dev:colorStripGreen; :blue dev:colorStripBlue; :mode dev:colorStripMode . - dmx0:c1 :connectedTo dev:inst1Brightness . - dev:inst1 a :Device, :Dimmer; - :brightness dev:inst1Brightness . + dev:inst1 a :Device, :SimpleDimmer; + :dmxUniverse dmx0:; :dmxBase 0; + :level dev:inst1Brightness . '''), outputs=[self.dmx0, self.udmx]) c.setAttrs('client1', 'sess1', - [(DEV['colorStrip'], L9['color'], '#ff0000')]) + [(DEV['colorStrip'], L9['color'], '#ff0000')], t0) c.setAttrs('client1', 'sess2', - [(DEV['colorStrip'], L9['color'], '#00ff00')]) + [(DEV['colorStrip'], L9['color'], '#00ff00')], t0) - self.assertEqual([[255, 0, 0, 215], 'flush', - [0, 255, 0, 215], 'flush'], self.udmx.updates) + self.assertEqual([[215, 255, 0, 0], 'flush', + [215, 0, 255, 0], 'flush'], self.udmx.updates) def testClientIsForgottenAfterAWhile(self): with freeze_time(datetime.datetime.now()) as ft: c = Collector(self.config, outputs=[self.dmx0, self.udmx]) - c.setAttrs('cli1', 'sess1', [(DEV['inst1'], L9['brightness'], .5)]) + c.setAttrs('cli1', 'sess1', [(DEV['inst1'], L9['brightness'], .5)], + time.time()) ft.tick(delta=datetime.timedelta(seconds=1)) - c.setAttrs('cli2', 'sess1', [(DEV['inst1'], L9['brightness'], .2)]) + # this max's with cli1's value so we still see .5 + c.setAttrs('cli2', 'sess1', [(DEV['inst1'], L9['brightness'], .2)], + time.time()) ft.tick(delta=datetime.timedelta(seconds=9.1)) - c.setAttrs('cli2', 'sess1', [(DEV['inst1'], L9['brightness'], .4)]) - self.assertEqual([[127], 'flush', [127], 'flush', [102], 'flush'], + # now cli1 is forgotten, so our value appears + c.setAttrs('cli2', 'sess1', [(DEV['inst1'], L9['brightness'], .4)], + time.time()) + self.assertEqual([[127, 0, 0, 0], 'flush', + [127, 0, 0, 0], 'flush', + [102, 0, 0, 0], 'flush'], self.dmx0.updates) - def testClientUpdatesAreCollected(self): - # second call to setAttrs doesn't forget the first + def testClientUpdatesAreNotMerged(self): + # second call to setAttrs forgets the first c = Collector(self.config, outputs=[self.dmx0, self.udmx]) + t0 = time.time() + c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], .5)], t0) + c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], 1)], t0) + c.setAttrs('client1', 'sess1', [(DEV['colorStrip'], L9['color'], '#00ff00')], t0) - c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], .5)]) - c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], 1)]) - c.setAttrs('client1', 'sess1', [(DEV['colorStrip'], L9['color'], '#00ff00')]) - - self.assertEqual([[0, 255, 0, 215], 'flush'], self.udmx.updates) - self.assertEqual([[127], 'flush', [255], 'flush', [255], 'flush'], self.dmx0.updates) + self.assertEqual([[0, 0, 0, 0], 'flush', + [0, 0, 0, 0], 'flush', + [215, 0, 255, 0], 'flush'], + self.udmx.updates) + self.assertEqual([[127, 0, 0, 0], 'flush', + [255, 0, 0, 0], 'flush', + [0, 0, 0, 0], 'flush'], + self.dmx0.updates) def testRepeatedAttributesInOneRequestGetResolved(self): c = Collector(self.config, outputs=[self.dmx0, self.udmx]) @@ -202,12 +235,13 @@ class TestCollector(unittest.TestCase): c.setAttrs('client1', 'sess1', [ (DEV['inst1'], L9['brightness'], .5), (DEV['inst1'], L9['brightness'], .3), - ]) - self.assertEqual([[127], 'flush'], self.dmx0.updates) + ], t0) + self.assertEqual([[127, 0, 0, 0], 'flush'], self.dmx0.updates) c.setAttrs('client1', 'sess1', [ (DEV['inst1'], L9['brightness'], .3), (DEV['inst1'], L9['brightness'], .5), - ]) - self.assertEqual([[127], 'flush', [127], 'flush'], self.dmx0.updates) + ], t0) + self.assertEqual([[127, 0, 0, 0], 'flush', + [127, 0, 0, 0], 'flush'], self.dmx0.updates) diff --git a/light9/collector/device.py b/light9/collector/device.py --- a/light9/collector/device.py +++ b/light9/collector/device.py @@ -76,7 +76,7 @@ def toOutputAttrs(deviceType, deviceAttr out = deviceAttrSettings.get(attr) if out is None: return default - return float(out.toPython()) + return float(out.toPython()) if isinstance(out, Literal) else out def rgbAttr(attr): color = deviceAttrSettings.get(attr, '#000000') diff --git a/light9/collector/device_test.py b/light9/collector/device_test.py --- a/light9/collector/device_test.py +++ b/light9/collector/device_test.py @@ -20,8 +20,8 @@ class TestColorStrip(unittest.TestCase): class TestDimmer(unittest.TestCase): def testConvert(self): - self.assertEqual({L9['brightness']: 127}, - toOutputAttrs(L9['Dimmer'], {L9['brightness']: .5})) + self.assertEqual({L9['level']: 127}, + toOutputAttrs(L9['SimpleDimmer'], {L9['brightness']: .5})) class TestMini15(unittest.TestCase): def testConvertColor(self): diff --git a/light9/collector/output.py b/light9/collector/output.py --- a/light9/collector/output.py +++ b/light9/collector/output.py @@ -23,6 +23,8 @@ class Output(object): often as you want- the result will be sent as soon as possible, and with repeats as needed to outlast hardware timeouts. """ + uri = None # type: URIRef + numChannels = None # type: int def __init__(self): raise NotImplementedError diff --git a/light9/collector/output_test.py b/light9/collector/output_test.py --- a/light9/collector/output_test.py +++ b/light9/collector/output_test.py @@ -33,14 +33,6 @@ class TestSetListElem(unittest.TestCase) self.assertEqual([0, 1, 1], x) class TestDmxOutput(unittest.TestCase): - def testGeneratesConnectionList(self): - out = DmxOutput(L9['output/udmx/'], 3) - self.assertEqual([ - (0, L9['output/udmx/c1']), - (1, L9['output/udmx/c2']), - (2, L9['output/udmx/c3']), - ], list(out.allConnections())) - def testFlushIsNoop(self): out = DmxOutput(L9['output/udmx/'], 3) out.flush() diff --git a/light9/io/__init__.py b/light9/io/__init__.py --- a/light9/io/__init__.py +++ b/light9/io/__init__.py @@ -1,7 +1,7 @@ from __future__ import division import sys -class BaseIO: +class BaseIO(object): def __init__(self): self.dummy=1 self.__name__ = 'BaseIO' @@ -83,57 +83,3 @@ class UsbDMX(BaseIO): for lev in levels]) + "\x55" self._dmx().write(packet) -class SerialPots(BaseIO): - """ - this is a dummy object (that returns zeros forever) until you call startup() - which makes it bind to the port, etc - - """ - def __init__(self): - # no init here- call getport() to actually initialize - self.dummy=1 - self.__name__='SerialPots' # i thought this was automatic! - - def golive(self): - """ - ls -l /dev/i2c-0 - crw-rw-rw- 1 root root 89, 0 Jul 11 12:27 /dev/i2c-0 - """ - import serport - self.serport = serport - - self.f = open("/dev/i2c-0","rw") - - # this is for a chip with A0,A1,A2 lines all low: - port = 72 - - from fcntl import * - - I2C_SLAVE = 0x0703 #/* Change slave address */ - ioctl(self.f,I2C_SLAVE,port) - self.dummy=0 - - def godummy(self): - BaseIO.godummy(self) - self.f.close() - - def getlevels(self): - if self.dummy: - return (0,0,0,0) - else: - return self.serport.read_all_adc(self.f.fileno()) - - -if __name__=='__main__': - - """ tester program that just dumps levels for a while """ - from time import sleep - from serport import * - - i=0 - while i<100: - sleep(.033) - i=i+1 - - print read_all_adc(f.fileno()) - diff --git a/light9/rdfdb/graphfile_test.py b/light9/rdfdb/graphfile_test.py --- a/light9/rdfdb/graphfile_test.py +++ b/light9/rdfdb/graphfile_test.py @@ -27,6 +27,8 @@ class TestGraphFileOutput(unittest.TestC gf.flush() wroteContent = open(tf.name).read() self.assertEqual('''@prefix : . +@prefix dev: . +@prefix effect: . @prefix n: . @prefix rdf: . @prefix rdfs: . @@ -34,6 +36,4 @@ class TestGraphFileOutput(unittest.TestC @prefix xsd: . :boo n:two . - ''', wroteContent) - diff --git a/makefile b/makefile --- a/makefile +++ b/makefile @@ -102,3 +102,11 @@ effect_node_setup: create_virtualenv pac coffee: zsh -c 'coffee -cw light9/web/{.,live,timeline}/*.coffee' + +env-mypy/bin/mypy: + mkdir -p env-mypy + virtualenv -p /usr/bin/python3 env-mypy/ + env-mypy/bin/pip install mypy==0.501 lxml==3.7.3 + +mypy-collector: env-mypy/bin/mypy + env-mypy/bin/mypy --py2 --ignore-missing-imports --strict-optional --custom-typeshed-dir stubs --html-report /tmp/rep bin/collector light9/collector/*.py diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -34,3 +34,5 @@ klein==15.3.1 git+http://github.com/drewp/scales.git@448d59fb491b7631877528e7695a93553bfaaa93#egg=scales colormath==2.1.1 noise==1.2.2 + +typing==3.6.1