release: 13
[jack_mixer.git] / nsmclient.py
blobe422261de14d870563b45e0507344ec6215f7246
1 #! /usr/bin/env python3
2 # -*- coding: utf-8 -*-
3 """
4 PyNSMClient - A New Session Manager Client-Library in one file.
6 The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/
7 New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager
8 With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 )
10 MIT License
12 Copyright 2014-2020 Nils Hilbricht https://www.laborejo.org
14 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
15 associated documentation files (the "Software"), to deal in the Software without restriction,
16 including without limitation the rights to use, copy, modify, merge, publish, distribute,
17 sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
18 furnished to do so, subject to the following conditions:
20 The above copyright notice and this permission notice shall be included in all copies or
21 substantial portions of the Software.
23 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
24 NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
25 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
26 DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
27 OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28 """
30 import logging;
31 logger = None #filled by init with prettyName
33 import struct
34 import socket
35 from os import getenv, getpid, kill
36 import os
37 import os.path
38 import shutil
39 from uuid import uuid4
40 from sys import argv
41 from signal import signal, SIGTERM, SIGINT, SIGKILL #react to exit signals to close the client gracefully. Or kill if the client fails to do so.
42 from urllib.parse import urlparse
44 class _IncomingMessage(object):
45 """Representation of a parsed datagram representing an OSC message.
47 An OSC message consists of an OSC Address Pattern followed by an OSC
48 Type Tag String followed by zero or more OSC Arguments.
49 """
51 def __init__(self, dgram):
52 #NSM Broadcasts are bundles, but very simple ones. We only need to care about the single message it contains.
53 #Therefore we can strip the bundle prefix and handle it as normal message.
54 if b"#bundle" in dgram:
55 bundlePrefix, singleMessage = dgram.split(b"/", maxsplit=1)
56 dgram = b"/" + singleMessage # / eaten by split
57 self.isBroadcast = True
58 else:
59 self.isBroadcast = False
60 self.LENGTH = 4 #32 bit
61 self._dgram = dgram
62 self._parameters = []
63 self.parse_datagram()
66 def get_int(self, dgram, start_index):
67 """Get a 32-bit big-endian two's complement integer from the datagram.
69 Args:
70 dgram: A datagram packet.
71 start_index: An index where the integer starts in the datagram.
73 Returns:
74 A tuple containing the integer and the new end index.
76 Raises:
77 ValueError if the datagram could not be parsed.
78 """
79 try:
80 if len(dgram[start_index:]) < self.LENGTH:
81 raise ValueError('Datagram is too short')
82 return (
83 struct.unpack('>i', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH)
84 except (struct.error, TypeError) as e:
85 raise ValueError('Could not parse datagram %s' % e)
87 def get_string(self, dgram, start_index):
88 """Get a python string from the datagram, starting at pos start_index.
90 We receive always the full string, but handle only the part from the start_index internally.
91 In the end return the offset so it can be added to the index for the next parameter.
92 Each subsequent call handles less of the same string, starting further to the right.
94 According to the specifications, a string is:
95 "A sequence of non-null ASCII characters followed by a null,
96 followed by 0-3 additional null characters to make the total number
97 of bits a multiple of 32".
99 Args:
100 dgram: A datagram packet.
101 start_index: An index where the string starts in the datagram.
103 Returns:
104 A tuple containing the string and the new end index.
106 Raises:
107 ValueError if the datagram could not be parsed.
109 #First test for empty string, which is nothing, followed by a terminating \x00 padded by three additional \x00.
110 if dgram[start_index:].startswith(b"\x00\x00\x00\x00"):
111 return "", start_index + 4
113 #Otherwise we have a non-empty string that must follow the rules of the docstring.
115 offset = 0
116 try:
117 while dgram[start_index + offset] != 0:
118 offset += 1
119 if offset == 0:
120 raise ValueError('OSC string cannot begin with a null byte: %s' % dgram[start_index:])
121 # Align to a byte word.
122 if (offset) % self.LENGTH == 0:
123 offset += self.LENGTH
124 else:
125 offset += (-offset % self.LENGTH)
126 # Python slices do not raise an IndexError past the last index,
127 # do it ourselves.
128 if offset > len(dgram[start_index:]):
129 raise ValueError('Datagram is too short')
130 data_str = dgram[start_index:start_index + offset]
131 return data_str.replace(b'\x00', b'').decode('utf-8'), start_index + offset
132 except IndexError as ie:
133 raise ValueError('Could not parse datagram %s' % ie)
134 except TypeError as te:
135 raise ValueError('Could not parse datagram %s' % te)
137 def get_float(self, dgram, start_index):
138 """Get a 32-bit big-endian IEEE 754 floating point number from the datagram.
140 Args:
141 dgram: A datagram packet.
142 start_index: An index where the float starts in the datagram.
144 Returns:
145 A tuple containing the float and the new end index.
147 Raises:
148 ValueError if the datagram could not be parsed.
150 try:
151 return (struct.unpack('>f', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH)
152 except (struct.error, TypeError) as e:
153 raise ValueError('Could not parse datagram %s' % e)
155 def parse_datagram(self):
156 try:
157 self._address_regexp, index = self.get_string(self._dgram, 0)
158 if not self._dgram[index:]:
159 # No params is legit, just return now.
160 return
162 # Get the parameters types.
163 type_tag, index = self.get_string(self._dgram, index)
164 if type_tag.startswith(','):
165 type_tag = type_tag[1:]
167 # Parse each parameter given its type.
168 for param in type_tag:
169 if param == "i": # Integer.
170 val, index = self.get_int(self._dgram, index)
171 elif param == "f": # Float.
172 val, index = self.get_float(self._dgram, index)
173 elif param == "s": # String.
174 val, index = self.get_string(self._dgram, index)
175 else:
176 logger.warning("Unhandled parameter type: {0}".format(param))
177 continue
178 self._parameters.append(val)
179 except ValueError as pe:
180 #raise ValueError('Found incorrect datagram, ignoring it', pe)
181 # Raising an error is not ignoring it!
182 logger.warning("Found incorrect datagram, ignoring it. {}".format(pe))
184 @property
185 def oscpath(self):
186 """Returns the OSC address regular expression."""
187 return self._address_regexp
189 @staticmethod
190 def dgram_is_message(dgram):
191 """Returns whether this datagram starts as an OSC message."""
192 return dgram.startswith(b'/')
194 @property
195 def size(self):
196 """Returns the length of the datagram for this message."""
197 return len(self._dgram)
199 @property
200 def dgram(self):
201 """Returns the datagram from which this message was built."""
202 return self._dgram
204 @property
205 def params(self):
206 """Convenience method for list(self) to get the list of parameters."""
207 return list(self)
209 def __iter__(self):
210 """Returns an iterator over the parameters of this message."""
211 return iter(self._parameters)
213 class _OutgoingMessage(object):
214 def __init__(self, oscpath):
215 self.LENGTH = 4 #32 bit
216 self.oscpath = oscpath
217 self._args = []
219 def write_string(self, val):
220 dgram = val.encode('utf-8')
221 diff = self.LENGTH - (len(dgram) % self.LENGTH)
222 dgram += (b'\x00' * diff)
223 return dgram
225 def write_int(self, val):
226 return struct.pack('>i', val)
228 def write_float(self, val):
229 return struct.pack('>f', val)
231 def add_arg(self, argument):
232 t = {str:"s", int:"i", float:"f"}[type(argument)]
233 self._args.append((t, argument))
235 def build(self):
236 dgram = b''
238 #OSC Path
239 dgram += self.write_string(self.oscpath)
241 if not self._args:
242 dgram += self.write_string(',')
243 return dgram
245 # Write the parameters.
246 arg_types = "".join([arg[0] for arg in self._args])
247 dgram += self.write_string(',' + arg_types)
248 for arg_type, value in self._args:
249 f = {"s":self.write_string, "i":self.write_int, "f":self.write_float}[arg_type]
250 dgram += f(value)
251 return dgram
253 class NSMNotRunningError(Exception):
254 """Error raised when environment variable $NSM_URL was not found."""
256 class NSMClient(object):
257 """The representation of the host programs as NSM sees it.
258 Technically consists of an udp server and a udp client.
260 Does not run an event loop itself and depends on the host loop.
261 E.g. a Qt timer or just a simple while True: sleep(0.1) in Python."""
262 def __init__(self, prettyName, supportsSaveStatus, saveCallback, openOrNewCallback, exitProgramCallback, hideGUICallback=None, showGUICallback=None, broadcastCallback=None, sessionIsLoadedCallback=None, loggingLevel = "info"):
264 self.nsmOSCUrl = self.getNsmOSCUrl() #this fails and raises NSMNotRunningError if NSM is not available. Host programs can ignore it or exit their program.
266 self.realClient = True
267 self.cachedSaveStatus = None #save status checks for this.
269 global logger
270 logger = logging.getLogger(prettyName)
271 logger.info("import")
272 if loggingLevel == "info" or loggingLevel == 20:
273 logging.basicConfig(level=logging.INFO) #development
274 logger.info("Starting PyNSM2 Client with logging level INFO. Switch to 'error' for a release!") #the NSM name is not ready yet so we just use the pretty name
275 elif loggingLevel == "error" or loggingLevel == 40:
276 logging.basicConfig(level=logging.ERROR) #production
277 else:
278 logging.warning("Unknown logging level: {}. Choose 'info' or 'error'".format(loggingLevel))
279 logging.basicConfig(level=logging.INFO) #development
281 #given parameters,
282 self.prettyName = prettyName #keep this consistent! Settle for one name.
283 self.supportsSaveStatus = supportsSaveStatus
284 self.saveCallback = saveCallback
285 self.exitProgramCallback = exitProgramCallback
286 self.openOrNewCallback = openOrNewCallback #The host needs to: Create a jack client with ourClientNameUnderNSM - Open the saved file and all its resources
287 self.broadcastCallback = broadcastCallback
288 self.hideGUICallback = hideGUICallback
289 self.showGUICallback = showGUICallback
290 self.sessionIsLoadedCallback = sessionIsLoadedCallback
292 #Reactions get the raw _IncomingMessage OSC object
293 #A client can add to reactions.
294 self.reactions = {
295 "/nsm/client/save" : self._saveCallback,
296 "/nsm/client/show_optional_gui" : lambda msg: self.showGUICallback(),
297 "/nsm/client/hide_optional_gui" : lambda msg: self.hideGUICallback(),
298 "/nsm/client/session_is_loaded" : self._sessionIsLoadedCallback,
299 #Hello source-code reader. You can add your own reactions here by nsmClient.reactions[oscpath]=func, where func gets the raw _IncomingMessage OSC object as argument.
300 #broadcast is handled directly by the function because it has more parameters
302 #self.discardReactions = set(["/nsm/client/session_is_loaded"])
303 self.discardReactions = set()
305 #Networking and Init
306 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #internet, udp
307 self.sock.bind(('', 0)) #pick a free port on localhost.
308 ip, port = self.sock.getsockname()
309 self.ourOscUrl = f"osc.udp://{ip}:{port}/"
311 self.executableName = self.getExecutableName()
313 #UNIX Signals. Used for quit.
314 signal(SIGTERM, self.sigtermHandler) #NSM sends only SIGTERM. #TODO: really? pynsm version 1 handled sigkill as well.
315 signal(SIGINT, self.sigtermHandler)
317 #The following instance parameters are all set in announceOurselves
318 self.serverFeatures = None
319 self.sessionName = None
320 self.ourPath = None
321 self.ourClientNameUnderNSM = None
322 self.ourClientId = None # the "file extension" of ourClientNameUnderNSM
323 self.isVisible = None #set in announceGuiVisibility
324 self.saveStatus = True # true is clean. false means we need saving.
326 self.announceOurselves()
328 assert self.serverFeatures, self.serverFeatures
329 assert self.sessionName, self.sessionName
330 assert self.ourPath, self.ourPath
331 assert self.ourClientNameUnderNSM, self.ourClientNameUnderNSM
333 self.sock.setblocking(False) #We have waited for tha handshake. Now switch blocking off because we expect sock.recvfrom to be empty in 99.99...% of the time so we shouldn't wait for the answer.
334 #After this point the host must include self.reactToMessage in its event loop
336 #We assume we are save at startup.
337 self.announceSaveStatus(isClean = True)
339 logger.info("NSMClient client init complete. Going into listening mode.")
342 def reactToMessage(self):
343 """This is the main loop message. It is added to the clients event loop."""
344 try:
345 data, addr = self.sock.recvfrom(4096) #4096 is quite big. We don't expect nsm messages this big. Better safe than sorry. However, messages will crash the program if they are bigger than 4096.
346 except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not.
347 return None
349 msg = _IncomingMessage(data)
350 if msg.oscpath in self.reactions:
351 self.reactions[msg.oscpath](msg)
352 elif msg.oscpath in self.discardReactions:
353 pass
354 elif msg.oscpath == "/reply" and msg.params == ["/nsm/server/open", "Loaded."]: #NSM sends that all programs of the session were loaded.
355 logger.info ("Got /reply Loaded from NSM Server")
356 elif msg.oscpath == "/reply" and msg.params == ["/nsm/server/save", "Saved."]: #NSM sends that all program-states are saved. Does only happen from the general save instruction, not when saving our client individually
357 logger.info ("Got /reply Saved from NSM Server")
358 elif msg.isBroadcast:
359 if self.broadcastCallback:
360 logger.info (f"Got broadcast with messagePath {msg.oscpath} and listOfArguments {msg.params}")
361 self.broadcastCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM, msg.oscpath, msg.params)
362 else:
363 logger.info (f"No callback for broadcast! Got messagePath {msg.oscpath} and listOfArguments {msg.params}")
364 elif msg.oscpath == "/error":
365 logger.warning("Got /error from NSM Server. Path: {} , Parameter: {}".format(msg.oscpath, msg.params))
366 else:
367 logger.warning("Reaction not implemented:. Path: {} , Parameter: {}".format(msg.oscpath, msg.params))
370 def send(self, path:str, listOfParameters:list, host=None, port=None):
371 """Send any osc message. Defaults to nsmd URL.
372 Will not wait for an answer but return None."""
373 if host and port:
374 url = (host, port)
375 else:
376 url = self.nsmOSCUrl
377 msg = _OutgoingMessage(path)
378 for arg in listOfParameters:
379 msg.add_arg(arg) #type is auto-determined by outgoing message
380 self.sock.sendto(msg.build(), url)
382 def getNsmOSCUrl(self):
383 """Return and save the nsm osc url or raise an error"""
384 nsmOSCUrl = getenv("NSM_URL")
385 if not nsmOSCUrl:
386 raise NSMNotRunningError("New-Session-Manager environment variable $NSM_URL not found.")
387 else:
388 #osc.udp://hostname:portnumber/
389 o = urlparse(nsmOSCUrl)
390 return o.hostname, o.port
392 def getExecutableName(self):
393 """Finding the actual executable name can be a bit hard
394 in Python. NSM wants the real starting point, even if
395 it was a bash script.
397 #TODO: I really don't know how to find out the name of the bash script
398 fullPath = argv[0]
399 assert os.path.dirname(fullPath) in os.environ["PATH"], (fullPath, os.path.dirname(fullPath), os.environ["PATH"]) #NSM requires the executable to be in the path. No excuses. This will never happen since the reference NSM server-GUI already checks for this.
401 executableName = os.path.basename(fullPath)
402 assert not "/" in executableName, executableName #see above.
403 return executableName
405 def announceOurselves(self):
406 """Say hello to NSM and tell it we are ready to receive
407 instructions
409 /nsm/server/announce s:application_name s:capabilities s:executable_name i:api_version_major i:api_version_minor i:pid"""
411 def buildClientFeaturesString():
412 #:dirty:switch:progress:
413 result = []
414 if self.supportsSaveStatus:
415 result.append("dirty")
416 if self.hideGUICallback and self.showGUICallback:
417 result.append("optional-gui")
418 if result:
419 return ":".join([""] + result + [""])
420 else:
421 return ""
423 logger.info("Sending our NSM-announce message")
425 announce = _OutgoingMessage("/nsm/server/announce")
426 announce.add_arg(self.prettyName) #s:application_name
427 announce.add_arg(buildClientFeaturesString()) #s:capabilities
428 announce.add_arg(self.executableName) #s:executable_name
429 announce.add_arg(1) #i:api_version_major
430 announce.add_arg(2) #i:api_version_minor
431 announce.add_arg(int(getpid())) #i:pid
432 hostname, port = self.nsmOSCUrl
433 assert hostname, self.nsmOSCUrl
434 assert port, self.nsmOSCUrl
435 self.sock.sendto(announce.build(), self.nsmOSCUrl)
437 #Wait for /reply (aka 'Howdy, what took you so long?)
438 data, addr = self.sock.recvfrom(1024)
439 msg = _IncomingMessage(data)
441 if msg.oscpath == "/error":
442 originalMessage, errorCode, reason = msg.params
443 logger.error("Code {}: {}".format(errorCode, reason))
444 quit()
446 elif msg.oscpath == "/reply":
447 nsmAnnouncePath, welcomeMessage, managerName, self.serverFeatures = msg.params
448 assert nsmAnnouncePath == "/nsm/server/announce", nsmAnnouncePath
449 logger.info("Got /reply " + welcomeMessage)
451 #Wait for /nsm/client/open
452 data, addr = self.sock.recvfrom(1024)
453 msg = _IncomingMessage(data)
454 assert msg.oscpath == "/nsm/client/open", msg.oscpath
455 self.ourPath, self.sessionName, self.ourClientNameUnderNSM = msg.params
456 self.ourClientId = os.path.splitext(self.ourClientNameUnderNSM)[1][1:]
457 logger.info("Got '/nsm/client/open' from NSM. Telling our client to load or create a file with name {}".format(self.ourPath))
458 self.openOrNewCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM) #Host function to either load an existing session or create a new one.
459 logger.info("Our client should be done loading or creating the file {}".format(self.ourPath))
460 replyToOpen = _OutgoingMessage("/reply")
461 replyToOpen.add_arg("/nsm/client/open")
462 replyToOpen.add_arg("{} is opened or created".format(self.prettyName))
463 self.sock.sendto(replyToOpen.build(), self.nsmOSCUrl)
464 else:
465 raise ValueError("Unexpected message path after announce: {}".format((msg.oscpath, msg.params)))
467 def announceGuiVisibility(self, isVisible):
468 message = "/nsm/client/gui_is_shown" if isVisible else "/nsm/client/gui_is_hidden"
469 self.isVisible = isVisible
470 guiVisibility = _OutgoingMessage(message)
471 logger.info("Telling NSM that our clients switched GUI visibility to: {}".format(message))
472 self.sock.sendto(guiVisibility.build(), self.nsmOSCUrl)
474 def announceSaveStatus(self, isClean):
475 """Only send to the NSM Server if there was really a change"""
476 if not self.supportsSaveStatus:
477 return
479 if not isClean == self.cachedSaveStatus:
480 message = "/nsm/client/is_clean" if isClean else "/nsm/client/is_dirty"
481 self.cachedSaveStatus = isClean
482 saveStatus = _OutgoingMessage(message)
483 logger.info("Telling NSM that our clients save state is now: {}".format(message))
484 self.sock.sendto(saveStatus.build(), self.nsmOSCUrl)
486 def _saveCallback(self, msg):
487 logger.info("Telling our client to save as {}".format(self.ourPath))
488 self.saveCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM)
489 replyToSave = _OutgoingMessage("/reply")
490 replyToSave.add_arg("/nsm/client/save")
491 replyToSave.add_arg("{} saved".format(self.prettyName))
492 self.sock.sendto(replyToSave.build(), self.nsmOSCUrl)
493 #it is assumed that after saving the state is clear
494 self.announceSaveStatus(isClean = True)
497 def _sessionIsLoadedCallback(self, msg):
498 if self.sessionIsLoadedCallback:
499 logger.info("Received 'Session is Loaded'. Our client supports it. Forwarding message...")
500 self.sessionIsLoadedCallback()
501 else:
502 logger.info("Received 'Session is Loaded'. Our client does not support it, which is the default. Discarding message...")
504 def sigtermHandler(self, signal, frame):
505 """Wait for the user to quit the program
507 The user function does not need to exit itself.
508 Just shutdown audio engines etc.
510 It is possible, that the client does not implement quit
511 properly. In that case NSM protocol demands that we quit anyway.
512 No excuses.
514 Achtung GDB! If you run your program with
515 gdb --args python foo.py
516 the Python signal handler will not work. This has nothing to do with this library.
518 logger.info("Telling our client to quit.")
519 self.exitProgramCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM)
520 #There is a chance that exitProgramCallback will hang and the program won't quit. However, this is broken design and bad programming. We COULD place a timeout here and just kill after 10s or so, but that would make quitting our responsibility and fixing a broken thing.
521 #If we reach this point we have reached the point of no return. Say goodbye.
522 logger.warning("Client did not quit on its own. Sending SIGKILL.")
523 kill(getpid(), SIGKILL)
524 logger.error("SIGKILL did nothing. Do it manually.")
526 def debugResetDataAndExit(self):
527 """This is solely meant for debugging and testing. The user way of action should be to
528 remove the client from the session and add a new instance, which will get a different
529 NSM-ID.
530 Afterwards we perform a clean exit."""
531 logger.warning("debugResetDataAndExit will now delete {} and then request an exit.".format(self.ourPath))
532 if os.path.exists(self.ourPath):
533 if os.path.isfile(self.ourPath):
534 try:
535 os.remove(self.ourPath)
536 except Exception as e:
537 logger.info(e)
538 elif os.path.isdir(self.ourPath):
539 try:
540 shutil.rmtree(self.ourPath)
541 except Exception as e:
542 logger.info(e)
543 else:
544 logger.info("{} does not exist.".format(self.ourPath))
545 self.serverSendExitToSelf()
547 def serverSendExitToSelf(self):
548 """If you want a very strict client you can block any non-NSM quit-attempts, like ignoring a
549 qt closeEvent, and instead send the NSM Server a request to close this client.
550 This method is a shortcut to do just that.
552 logger.info("Sending SIGTERM to ourselves to trigger the exit callback.")
553 #if "server-control" in self.serverFeatures:
554 # message = _OutgoingMessage("/nsm/server/stop")
555 # message.add_arg("{}".format(self.ourClientId))
556 # self.sock.sendto(message.build(), self.nsmOSCUrl)
557 #else:
558 kill(getpid(), SIGTERM) #this calls the exit callback
560 def serverSendSaveToSelf(self):
561 """Some clients want to offer a manual Save function, mostly for psychological reasons.
562 We offer a clean solution in calling this function which will trigger a round trip over the
563 NSM server so our client thinks it received a Save instruction. This leads to a clean
564 state with a good saveStatus and no required extra functionality in the client."""
566 logger.info("instructing the NSM-Server to send Save to ourselves.")
567 if "server-control" in self.serverFeatures:
568 #message = _OutgoingMessage("/nsm/server/save") # "Save All" Command.
569 message = _OutgoingMessage("/nsm/gui/client/save")
570 message.add_arg("{}".format(self.ourClientId))
571 self.sock.sendto(message.build(), self.nsmOSCUrl)
572 else:
573 logger.warning("...but the NSM-Server does not support server control. Server only supports: {}".format(self.serverFeatures))
575 def changeLabel(self, label:str):
576 """This function is implemented because it is provided by NSM. However, it does not much.
577 The message gets received but is not saved.
578 The official NSM GUI uses it but then does not save it.
579 We would have to send it every startup ourselves.
581 This is fine for us as clients, but you need to provide a GUI field to enter that label."""
582 logger.info("Telling the NSM-Server that our label is now " + label)
583 message = _OutgoingMessage("/nsm/client/label")
584 message.add_arg(label) #s:label
585 self.sock.sendto(message.build(), self.nsmOSCUrl)
587 def broadcast(self, path:str, arguments:list):
588 """/nsm/server/broadcast s:path [arguments...]
589 We, as sender, will not receive the broadcast back.
591 Broadcasts starting with /nsm are not allowed and will get discarded by the server
593 if path.startswith("/nsm"):
594 logger.warning("Attempted broadbast starting with /nsm. Not allwoed")
595 else:
596 logger.info("Sending broadcast " + path + repr(arguments))
597 message = _OutgoingMessage("/nsm/server/broadcast")
598 message.add_arg(path)
599 for arg in arguments:
600 message.add_arg(arg) #type autodetect
601 self.sock.sendto(message.build(), self.nsmOSCUrl)
603 def importResource(self, filePath):
604 """aka. import into session
606 ATTENTION! You will still receive an absolute path from this function. You need to make
607 sure yourself that this path will not be saved in your save file, but rather use a place-
608 holder that gets replaced by the actual session path each time. A good point is after
609 serialisation. search&replace for the session prefix ("ourPath") and replace it with a tag
610 e.g. <sessionDirectory>. The opposite during load.
611 Only such a behaviour will make your session portable.
613 Do not use the following pattern: An alternative that comes to mind is to only work with
614 relative paths and force your programs workdir to the session directory. Better work with
615 absolute paths internally .
617 Symlinks given path into session dir and returns the linked path relative to the ourPath.
618 It can handles single files as well as whole directories.
620 if filePath is already a symlink we do not follow it. os.path.realpath or os.readlink will
621 not be used.
623 Multilayer links may indicate a users ordering system that depends on
624 abstractions. e.g. with mounted drives under different names which get symlinked to a
625 reliable path.
627 Basically do not question the type of our input filePath.
629 tar with the follow symlink option has os.path.realpath behaviour and therefore is able
630 to follow multiple levels of links anyway.
632 A hardlink does not count as a link and will be detected and treated as real file.
634 Cleaning up a session directory is either responsibility of the user
635 or of our client program. We do not provide any means to unlink or delete files from the
636 session directory.
639 #Even if the project was not saved yet now it is time to make our directory in the NSM dir.
640 if not os.path.exists(self.ourPath):
641 os.makedirs(self.ourPath)
643 filePath = os.path.abspath(filePath) #includes normalisation
644 if not os.path.exists(self.ourPath):raise FileNotFoundError(self.ourPath)
645 if not os.path.isdir(self.ourPath): raise NotADirectoryError(self.ourPath)
646 if not os.access(self.ourPath, os.W_OK): raise PermissionError("not writable", self.ourPath)
648 if not os.path.exists(filePath):raise FileNotFoundError(filePath)
649 if os.path.isdir(filePath): raise IsADirectoryError(filePath)
650 if not os.access(filePath, os.R_OK): raise PermissionError("not readable", filePath)
652 filePathInOurSession = os.path.commonprefix([filePath, self.ourPath]) == self.ourPath
653 linkedPath = os.path.join(self.ourPath, os.path.basename(filePath))
654 linkedPathAlreadyExists = os.path.exists(linkedPath)
656 if not os.access(os.path.dirname(linkedPath), os.W_OK): raise PermissionError("not writable", os.path.dirname(linkedPath))
659 if filePathInOurSession:
660 #loadResource from our session dir. Portable session, manually copied beforehand or just loading a link again.
661 linkedPath = filePath #we could return here, but we continue to get the tests below.
662 logger.info(f"tried to import external resource {filePath} but this is already in our session directory. We use this file directly instead. ")
664 elif linkedPathAlreadyExists and os.readlink(linkedPath) == filePath:
665 #the imported file already exists as link in our session dir. We do not link it again but simply report the existing link.
666 #We only check for the first target of the existing link and do not follow it through to a real file.
667 #This way all user abstractions and file structures will be honored.
668 linkedPath = linkedPath
669 logger.info(f"tried to import external resource {filePath} but this was already linked to our session directory before. We use the old link: {linkedPath} ")
671 elif linkedPathAlreadyExists:
672 #A new file shall be imported but it would create a linked name which already exists in our session dir.
673 #Because we already checked for a new link to the same file above this means actually linking a different file so we need to differentiate with a unique name
674 firstpart, extension = os.path.splitext(linkedPath)
675 uniqueLinkedPath = firstpart + "." + uuid4().hex + extension
676 assert not os.path.exists(uniqueLinkedPath)
677 os.symlink(filePath, uniqueLinkedPath)
678 logger.info(self.ourClientNameUnderNSM + f":pysm2: tried to import external resource {filePath} but potential target link {linkedPath} already exists. Linked to {uniqueLinkedPath} instead.")
679 linkedPath = uniqueLinkedPath
681 else: #this is the "normal" case. External resources will be linked.
682 assert not os.path.exists(linkedPath)
683 os.symlink(filePath, linkedPath)
684 logger.info(f"imported external resource {filePath} as link {linkedPath}")
686 assert os.path.exists(linkedPath), linkedPath
687 return linkedPath
689 class NullClient(object):
690 """Use this as a drop-in replacement if your program has a mode without NSM but you don't want
691 to change the code itself.
692 This was originally written for programs that have a core-engine and normal mode of operations
693 is a GUI with NSM but they also support commandline-scripts and batch processing.
694 For these you don't want NSM."""
696 def __init__(self, *args, **kwargs):
697 self.realClient = False
698 self.ourClientNameUnderNSM = "NSM Null Client"
700 def announceSaveStatus(self, *args):
701 pass
703 def announceGuiVisibility(self, *args):
704 pass
706 def reactToMessage(self):
707 pass
709 def importResource(self):
710 return ""
712 def serverSendExitToSelf(self):
713 quit()