# HG changeset patch # User Edouard Tisserant # Date 1687098522 -7200 # Node ID 46f3ca3f0157d6f22f792d6a5c93d1940812f86f # Parent 4582f0fcf4c4a7d8bce67dbd91fbe854ac9716ce OPC-UA client : python3 + AsyncUA fixes diff -r 4582f0fcf4c4 -r 46f3ca3f0157 opc_ua/client.py --- a/opc_ua/client.py Wed May 31 23:16:29 2023 +0200 +++ b/opc_ua/client.py Sun Jun 18 16:28:42 2023 +0200 @@ -142,7 +142,7 @@ c_code = '#include "beremiz.h"\n' c_code += self.modeldata.GenerateC(c_path, locstr, self.GetConfig()) - with open(c_path, 'wb') as c_file: + with open(c_path, 'w') as c_file: c_file.write(c_code) LDFLAGS = ['"' + os.path.join(Open62541LibraryPath, "libopen62541.a") + '"', '-lcrypto'] diff -r 4582f0fcf4c4 -r 46f3ca3f0157 opc_ua/opcua_client_maker.py --- a/opc_ua/opcua_client_maker.py Wed May 31 23:16:29 2023 +0200 +++ b/opc_ua/opcua_client_maker.py Sun Jun 18 16:28:42 2023 +0200 @@ -2,9 +2,12 @@ import csv - -from opcua import Client -from opcua import ua +import asyncio +import functools +from threading import Thread + +from asyncua import Client +from asyncua import ua import wx import wx.lib.gizmos as gizmos # Formerly wx.gizmos in Classic @@ -182,33 +185,28 @@ # splitter. panel. splitter ClientPanel = self.GetParent().GetParent().GetParent() nodes = ClientPanel.GetSelectedNodes() - for node in nodes: - cname = node.get_node_class().name - dname = node.get_display_name().Text - if cname != "Variable": - self.log("Node {} ignored (not a variable)".format(dname)) + for node, properties in nodes: + if properties.cname != "Variable": + self.log("Node {} ignored (not a variable)".format(properties.dname)) continue - tname = node.get_data_type_as_variant_type().name + tname = properties.variant_type if tname not in UA_IEC_types: - self.log("Node {} ignored (unsupported type)".format(dname)) + self.log("Node {} ignored (unsupported type)".format(properties.dname)) continue - access = node.get_access_level() if {"input":ua.AccessLevel.CurrentRead, - "output":ua.AccessLevel.CurrentWrite}[self.direction] not in access: - self.log("Node {} ignored because of insuficient access rights".format(dname)) + "output":ua.AccessLevel.CurrentWrite}[self.direction] not in properties.access: + self.log("Node {} ignored because of insuficient access rights".format(properties.dname)) continue - nsid = node.nodeid.NamespaceIndex - nid = node.nodeid.Identifier - nid_type = type(nid).__name__ - iecid = nid - - value = [dname, - nsid, + nid_type = type(properties.nid).__name__ + iecid = properties.nid + + value = [properties.dname, + properties.nsid, nid_type, - nid, + properties.nid, tname, iecid] self.model.AddRow(value) @@ -239,18 +237,41 @@ smileidx = il.Add(wx.ArtProvider.GetBitmap(wx.ART_ADD_BOOKMARK, wx.ART_OTHER, isz)) +AsyncUAClientLoop = None +def AsyncUAClientLoopProc(): + asyncio.set_event_loop(AsyncUAClientLoop) + AsyncUAClientLoop.run_forever() + +def ExecuteSychronously(func, timeout=1): + def AsyncSychronizer(*args, **kwargs): + global AsyncUAClientLoop + # create asyncio loop + if AsyncUAClientLoop is None: + AsyncUAClientLoop = asyncio.new_event_loop() + Thread(target=AsyncUAClientLoopProc, daemon=True).start() + # schedule work in this loop + future = asyncio.run_coroutine_threadsafe(func(*args, **kwargs), AsyncUAClientLoop) + # wait max 5sec until connection completed + return future.result(timeout) + return AsyncSychronizer + +def ExecuteSychronouslyWithTimeout(timeout): + return functools.partial(ExecuteSychronously,timeout=timeout) + + class OPCUAClientPanel(wx.SplitterWindow): def __init__(self, parent, modeldata, log, config_getter): self.log = log wx.SplitterWindow.__init__(self, parent, -1) - self.ordered_nodes = [] + self.ordered_nps = [] self.inout_panel = wx.Panel(self) self.inout_sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=2, vgap=0) self.inout_sizer.AddGrowableCol(0) self.inout_sizer.AddGrowableRow(1) + self.clientloop = None self.client = None self.config_getter = config_getter @@ -279,31 +300,67 @@ def OnClose(self): if self.client is not None: - self.client.disconnect() + asyncio.run(self.client.disconnect()) self.client = None def __del__(self): self.OnClose() + async def GetAsyncUANodeProperties(self, node): + properties = type("UANodeProperties",(),dict( + nsid = node.nodeid.NamespaceIndex, + nid = node.nodeid.Identifier, + dname = (await node.read_display_name()).Text, + cname = (await node.read_node_class()).name, + )) + if properties.cname == "Variable": + properties.access = await node.get_access_level() + properties.variant_type = (await node.read_data_type_as_variant_type()).name + return properties + + @ExecuteSychronouslyWithTimeout(5) + async def ConnectAsyncUAClient(self, config): + client = Client(config["URI"]) + + AuthType = config["AuthType"] + if AuthType=="UserPasword": + await client.set_user(config["User"]) + await client.set_password(config["Password"]) + elif AuthType=="x509": + await client.set_security_string( + "{Policy},{Mode},{Certificate},{PrivateKey}".format(**config)) + + await client.connect() + self.client = client + + # load definition of server specific structures/extension objects + await self.client.load_type_definitions() + + # returns root node object and its properties + rootnode = self.client.get_root_node() + return rootnode, await self.GetAsyncUANodeProperties(rootnode) + + @ExecuteSychronously + async def DisconnectAsyncUAClient(self): + if self.client is not None: + await self.client.disconnect() + self.client = None + + @ExecuteSychronously + async def GetAsyncUANodeChildren(self, node): + children = await node.get_children() + return [ (child, await self.GetAsyncUANodeProperties(child)) for child in children] + def OnConnectButton(self, event): if self.connect_button.GetValue(): config = self.config_getter() - self.client = Client(config["URI"]) self.log("OPCUA browser: connecting to {}\n".format(config["URI"])) - AuthType = config["AuthType"] - if AuthType=="UserPasword": - self.client.set_user(config["User"]) - self.client.set_password(config["Password"]) - elif AuthType=="x509": - self.client.set_security_string( - "{Policy},{Mode},{Certificate},{PrivateKey}".format(**config)) - try : - self.client.connect() + rootnode, rootnodeproperties = self.ConnectAsyncUAClient(config) except Exception as e: - self.log("OPCUA browser: "+str(e)+"\n") + self.log("Exception in OPCUA browser: "+repr(e)+"\n") self.client = None self.connect_button.SetValue(False) return @@ -328,10 +385,7 @@ self.tree.SetMainColumn(0) - self.client.load_type_definitions() # load definition of server specific structures/extension objects - rootnode = self.client.get_root_node() - - rootitem = self.AddNodeItem(self.tree.AddRoot, rootnode) + rootitem = self.AddNodeItem(self.tree.AddRoot, rootnode, rootnodeproperties) # Populate first level so that root can be expanded self.CreateSubItems(rootitem) @@ -343,7 +397,7 @@ self.tree.Expand(rootitem) - hint = wx.StaticText(self, label = "Drag'n'drop desired variables from tree to Input or Output list") + hint = wx.StaticText(self.tree_panel, label = "Drag'n'drop desired variables from tree to Input or Output list") self.tree_sizer.Add(self.tree, flag=wx.GROW) self.tree_sizer.Add(hint, flag=wx.GROW) @@ -353,29 +407,23 @@ self.SplitVertically(self.tree_panel, self.inout_panel, 500) else: - self.client.disconnect() - self.client = None + self.DisconnectAsyncUAClient() self.Unsplit(self.tree_panel) self.tree_panel.Destroy() - def CreateSubItems(self, item): - node, browsed = self.tree.GetPyData(item) + node, properties, browsed = self.tree.GetPyData(item) if not browsed: - for subnode in node.get_children(): - self.AddNodeItem(lambda n: self.tree.AppendItem(item, n), subnode) - self.tree.SetPyData(item,(node, True)) - - def AddNodeItem(self, item_creation_func, node): - nsid = node.nodeid.NamespaceIndex - nid = node.nodeid.Identifier - dname = node.get_display_name().Text - cname = node.get_node_class().name - - item = item_creation_func(dname) - - if cname == "Variable": - access = node.get_access_level() + children = self.GetAsyncUANodeChildren(node) + for subnode, subproperties in children: + self.AddNodeItem(lambda n: self.tree.AppendItem(item, n), subnode, subproperties) + self.tree.SetPyData(item,(node, properties, True)) + + def AddNodeItem(self, item_creation_func, node, properties): + item = item_creation_func(properties.dname) + + if properties.cname == "Variable": + access = properties.access normalidx = fileidx r = ua.AccessLevel.CurrentRead in access w = ua.AccessLevel.CurrentWrite in access @@ -387,14 +435,14 @@ ext = "WO" # not sure this one exist else: ext = "no access" # not sure this one exist - cname = "Var "+node.get_data_type_as_variant_type().name+" (" + ext + ")" + cname = "Var "+properties.variant_type+" (" + ext + ")" else: normalidx = fldridx - self.tree.SetPyData(item,(node, False)) - self.tree.SetItemText(item, cname, 1) - self.tree.SetItemText(item, str(nsid), 2) - self.tree.SetItemText(item, type(nid).__name__+": "+str(nid), 3) + self.tree.SetPyData(item,(node, properties, False)) + self.tree.SetItemText(item, properties.cname, 1) + self.tree.SetItemText(item, str(properties.nsid), 2) + self.tree.SetItemText(item, type(properties.nid).__name__+": "+str(properties.nid), 3) self.tree.SetItemImage(item, normalidx, which = wx.TreeItemIcon_Normal) self.tree.SetItemImage(item, fldropenidx, which = wx.TreeItemIcon_Expanded) @@ -412,28 +460,28 @@ items = self.tree.GetSelections() items_pydata = [self.tree.GetPyData(item) for item in items] - nodes = [node for node, _unused in items_pydata] + nps = [(node,properties) for node, properties, unused in items_pydata] # append new nodes to ordered list - for node in nodes: - if node not in self.ordered_nodes: - self.ordered_nodes.append(node) + for np in nps: + if np not in self.ordered_nps: + self.ordered_nps.append(np) # filter out vanished items - self.ordered_nodes = [ - node - for node in self.ordered_nodes - if node in nodes] + self.ordered_nps = [ + np + for np in self.ordered_nps + if np in nps] def GetSelectedNodes(self): - return self.ordered_nodes + return self.ordered_nps def OnTreeBeginDrag(self, event): """ Called when a drag is started in tree @param event: wx.TreeEvent """ - if self.ordered_nodes: + if self.ordered_nps: # Just send a recognizable mime-type, drop destination # will get python data from parent data = wx.CustomDataObject(OPCUAClientDndMagicWord) @@ -496,7 +544,7 @@ self[direction] = OPCUAClientList(log, change_callback) def LoadCSV(self,path): - with open(path, 'rb') as csvfile: + with open(path, 'r') as csvfile: reader = csv.reader(csvfile, delimiter=',', quotechar='"') buf = {direction:[] for direction, _model in self.items()} for direction, model in self.items(): @@ -507,7 +555,7 @@ list.append(self[direction],row[1:]) def SaveCSV(self,path): - with open(path, 'wb') as csvfile: + with open(path, 'w') as csvfile: for direction, data in self.items(): writer = csv.writer(csvfile, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) @@ -806,7 +854,7 @@ } """ - with open(path, 'wb') as Cfile: + with open(path, 'w') as Cfile: Cfile.write(Ccode) diff -r 4582f0fcf4c4 -r 46f3ca3f0157 tests/cli_tests/opcua_test.bash --- a/tests/cli_tests/opcua_test.bash Wed May 31 23:16:29 2023 +0200 +++ b/tests/cli_tests/opcua_test.bash Sun Jun 18 16:28:42 2023 +0200 @@ -19,33 +19,38 @@ import sys import os import time +import asyncio -from opcua import ua, Server +from asyncua import Server -server = Server() -host = os.environ.get("OPCUA_DEFAULT_HOST", "127.0.0.1") -endpoint = "opc.tcp://"+host+":4840/freeopcua/server/" -server.set_endpoint(endpoint) +async def main(): + server = Server() + host = os.environ.get("OPCUA_DEFAULT_HOST", "127.0.0.1") + endpoint = "opc.tcp://"+host+":4840/freeopcua/server/" + await server.init() + server.set_endpoint(endpoint) -uri = "http://beremiz.github.io" -idx = server.register_namespace(uri) + uri = "http://beremiz.github.io" + idx = await server.register_namespace(uri) -objects = server.get_objects_node() + objects = server.get_objects_node() -testobj = objects.add_object(idx, "TestObject") -testvarout = testobj.add_variable(idx, "TestOut", 1.2) -testvar = testobj.add_variable(idx, "TestIn", 5.6) -testvar.set_writable() + testobj = await objects.add_object(idx, "TestObject") + testvarout = await testobj.add_variable(idx, "TestOut", 1.2) + testvar = await testobj.add_variable(idx, "TestIn", 5.6) + await testvar.set_writable() -server.start() + await server.start() + try: + while True: + await asyncio.sleep(1) + print(await testvar.get_value()) + sys.stdout.flush() + finally: + await server.stop() -try: - while True: - time.sleep(1) - print testvar.get_value() - sys.stdout.flush() -finally: - server.stop() +asyncio.run(main()) + EOF SERVER_PID=$! diff -r 4582f0fcf4c4 -r 46f3ca3f0157 tests/cli_tests/opcua_test_encrypted.bash --- a/tests/cli_tests/opcua_test_encrypted.bash Wed May 31 23:16:29 2023 +0200 +++ b/tests/cli_tests/opcua_test_encrypted.bash Sun Jun 18 16:28:42 2023 +0200 @@ -24,37 +24,51 @@ import sys import os import time +import asyncio -from opcua import ua, Server +from asyncua import ua, Server +from asyncua.server.users import User, UserRole -server = Server() -host = os.environ.get("OPCUA_DEFAULT_HOST", "127.0.0.1") -endpoint = "opc.tcp://"+host+":4840/freeopcua/server/" -server.set_endpoint(endpoint) +# Asyncua can't work without (over)simple shared cerificates/privkey. +# No user is involved in that case, but asyncua needs it. +# Over permessive User Manager hereafter helps cuting that corner. +class AllAdminUserManager: + def get_user(self, iserver, username=None, password=None, certificate=None): + return User(role=UserRole.Admin) -server.set_security_policy([ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt]) -server.load_certificate("my_cert.der") -server.load_private_key("my_private_key.pem") +async def main(): + server = Server(user_manager=AllAdminUserManager()) + host = os.environ.get("OPCUA_DEFAULT_HOST", "127.0.0.1") + endpoint = "opc.tcp://"+host+":4840/freeopcua/server/" + await server.init() + server.set_endpoint(endpoint) -uri = "http://beremiz.github.io" -idx = server.register_namespace(uri) + server.set_security_policy([ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt]) + await server.load_certificate("my_cert.der") + await server.load_private_key("my_private_key.pem") -objects = server.get_objects_node() + uri = "http://beremiz.github.io" + idx = await server.register_namespace(uri) -testobj = objects.add_object(idx, "TestObject") -testvarout = testobj.add_variable(idx, "TestOut", 1.2) -testvar = testobj.add_variable(idx, "TestIn", 5.6) -testvar.set_writable() + objects = server.get_objects_node() -server.start() + testobj = await objects.add_object(idx, "TestObject") + testvarout = await testobj.add_variable(idx, "TestOut", 1.2) + testvar = await testobj.add_variable(idx, "TestIn", 5.6) + await testvar.set_writable() -try: - while True: - time.sleep(1) - print testvar.get_value() - sys.stdout.flush() -finally: - server.stop() + await server.start() + + try: + while True: + await asyncio.sleep(1) + print(await testvar.get_value()) + sys.stdout.flush() + finally: + await server.stop() + +asyncio.run(main()) + EOF SERVER_PID=$! diff -r 4582f0fcf4c4 -r 46f3ca3f0157 tests/ide_tests/opcua_browse.sikuli/opcua_browse.py --- a/tests/ide_tests/opcua_browse.sikuli/opcua_browse.py Wed May 31 23:16:29 2023 +0200 +++ b/tests/ide_tests/opcua_browse.sikuli/opcua_browse.py Sun Jun 18 16:28:42 2023 +0200 @@ -29,7 +29,7 @@ app.doubleClick("TestObject") - app.dragNdrop(["TestIn", "Testln"], "output variables") + app.dragNdrop(["TestIn", "Testln","Testin"], "output variables") app.wait(1) diff -r 4582f0fcf4c4 -r 46f3ca3f0157 tests/ide_tests/opcua_browse.sikuli/opcua_service.bash --- a/tests/ide_tests/opcua_browse.sikuli/opcua_service.bash Wed May 31 23:16:29 2023 +0200 +++ b/tests/ide_tests/opcua_browse.sikuli/opcua_service.bash Sun Jun 18 16:28:42 2023 +0200 @@ -8,33 +8,38 @@ import sys import os import time +import asyncio -from opcua import ua, Server +from asyncua import Server -server = Server() -host = os.environ.get("OPCUA_DEFAULT_HOST", "127.0.0.1") -endpoint = "opc.tcp://"+host+":4840/freeopcua/server/" -server.set_endpoint(endpoint) +async def main(): + server = Server() + host = os.environ.get("OPCUA_DEFAULT_HOST", "127.0.0.1") + endpoint = "opc.tcp://"+host+":4840/freeopcua/server/" + await server.init() + server.set_endpoint(endpoint) -uri = "http://beremiz.github.io" -idx = server.register_namespace(uri) + uri = "http://beremiz.github.io" + idx = await server.register_namespace(uri) -objects = server.get_objects_node() + objects = server.get_objects_node() -testobj = objects.add_object(idx, "TestObject") -testvarout = testobj.add_variable(idx, "TestOut", 1.2) -testvar = testobj.add_variable(idx, "TestIn", 5.6) -testvar.set_writable() + testobj = await objects.add_object(idx, "TestObject") + testvarout = await testobj.add_variable(idx, "TestOut", 1.2) + testvar = await testobj.add_variable(idx, "TestIn", 5.6) + await testvar.set_writable() -server.start() + await server.start() + try: + while True: + await asyncio.sleep(1) + inval = await testvar.get_value() + print(inval) + await testvarout.set_value(inval*2) + sys.stdout.flush() + finally: + await server.stop() -try: - while True: - time.sleep(1) - inval=testvar.get_value() - print inval - testvarout.set_value(inval*2) - sys.stdout.flush() -finally: - server.stop() +asyncio.run(main()) + EOF diff -r 4582f0fcf4c4 -r 46f3ca3f0157 tests/ide_tests/opcua_browse_encrypted.sikuli/opcua_browse_encrypted.py --- a/tests/ide_tests/opcua_browse_encrypted.sikuli/opcua_browse_encrypted.py Wed May 31 23:16:29 2023 +0200 +++ b/tests/ide_tests/opcua_browse_encrypted.sikuli/opcua_browse_encrypted.py Sun Jun 18 16:28:42 2023 +0200 @@ -31,7 +31,7 @@ app.doubleClick("TestObject") - app.dragNdrop(["TestIn", "Testln"], "output variables") + app.dragNdrop(["TestIn", "Testln", "Testin"], "output variables") app.wait(1) diff -r 4582f0fcf4c4 -r 46f3ca3f0157 tests/ide_tests/opcua_browse_encrypted.sikuli/opcua_service.bash --- a/tests/ide_tests/opcua_browse_encrypted.sikuli/opcua_service.bash Wed May 31 23:16:29 2023 +0200 +++ b/tests/ide_tests/opcua_browse_encrypted.sikuli/opcua_service.bash Sun Jun 18 16:28:42 2023 +0200 @@ -1,13 +1,17 @@ #!/bin/bash +set -x -e + echo "Instant encrypted OPC-UA server for test" +rm -f my_cert.pem my_cert.der my_private_key.pem + yes "" | openssl req -x509 -newkey rsa:2048 -keyout my_private_key.pem -out my_cert.pem \ -days 355 -nodes -addext "subjectAltName = URI:urn:example.org:FreeOpcUa:python-opcua" openssl x509 -outform der -in my_cert.pem -out my_cert.der PROJECT_FILES_DIR=$BEREMIZPATH/tests/projects/opcua_browse_encrypted/project_files -mkdir $PROJECT_FILES_DIR +mkdir $PROJECT_FILES_DIR -p cp my_cert.der my_private_key.pem $PROJECT_FILES_DIR echo "CERTS READY" @@ -18,37 +22,51 @@ import sys import os import time +import asyncio -from opcua import ua, Server +from asyncua import ua, Server +from asyncua.server.users import User, UserRole -server = Server() -host = os.environ.get("OPCUA_DEFAULT_HOST", "127.0.0.1") -endpoint = "opc.tcp://"+host+":4840/freeopcua/server/" -server.set_endpoint(endpoint) +# Asyncua can't work without (over)simple shared cerificates/privkey. +# No user is involved in that case, but asyncua needs it. +# Over permessive User Manager hereafter helps cuting that corner. +class AllAdminUserManager: + def get_user(self, iserver, username=None, password=None, certificate=None): + return User(role=UserRole.Admin) -server.set_security_policy([ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt]) -server.load_certificate("my_cert.der") -server.load_private_key("my_private_key.pem") +async def main(): + server = Server(user_manager=AllAdminUserManager()) + host = os.environ.get("OPCUA_DEFAULT_HOST", "127.0.0.1") + endpoint = "opc.tcp://"+host+":4840/freeopcua/server/" + await server.init() + server.set_endpoint(endpoint) -uri = "http://beremiz.github.io" -idx = server.register_namespace(uri) + server.set_security_policy([ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt]) + await server.load_certificate("my_cert.der") + await server.load_private_key("my_private_key.pem") -objects = server.get_objects_node() + uri = "http://beremiz.github.io" + idx = await server.register_namespace(uri) -testobj = objects.add_object(idx, "TestObject") -testvarout = testobj.add_variable(idx, "TestOut", 1.2) -testvar = testobj.add_variable(idx, "TestIn", 5.6) -testvar.set_writable() + objects = server.get_objects_node() -server.start() + testobj = await objects.add_object(idx, "TestObject") + testvarout = await testobj.add_variable(idx, "TestOut", 1.2) + testvar = await testobj.add_variable(idx, "TestIn", 5.6) + await testvar.set_writable() -try: - while True: - time.sleep(1) - inval=testvar.get_value() - print inval - testvarout.set_value(inval*2) - sys.stdout.flush() -finally: - server.stop() + await server.start() + + try: + while True: + await asyncio.sleep(1) + inval = await testvar.get_value() + print(inval) + await testvarout.set_value(inval*2) + sys.stdout.flush() + finally: + await server.stop() + +asyncio.run(main()) + EOF diff -r 4582f0fcf4c4 -r 46f3ca3f0157 tests/projects/opcua_client_encrypted/opcua_0@opcua/confnode.xml --- a/tests/projects/opcua_client_encrypted/opcua_0@opcua/confnode.xml Wed May 31 23:16:29 2023 +0200 +++ b/tests/projects/opcua_client_encrypted/opcua_0@opcua/confnode.xml Sun Jun 18 16:28:42 2023 +0200 @@ -1,5 +1,5 @@ - + diff -r 4582f0fcf4c4 -r 46f3ca3f0157 tests/tools/Docker/Dockerfile --- a/tests/tools/Docker/Dockerfile Wed May 31 23:16:29 2023 +0200 +++ b/tests/tools/Docker/Dockerfile Sun Jun 18 16:28:42 2023 +0200 @@ -77,13 +77,13 @@ RUN ~/beremizenv/bin/pip install \ pytest pytest-timeout ddt \ sslpsk posix_spawn \ - opcua \ matplotlib lxml \ zeroconf \ pycountry \ Pyro5 msgpack autobahn click RUN ~/beremizenv/bin/pip install \ + git+https://github.com/FreeOpcUa/opcua-asyncio.git@98a64897a2d171653353de2f36d33085aef65e82 \ git+https://github.com/beremiz/nevow-py3.git@nevow-0.14.5.dev1 RUN set -xe && \