@@ -7,3 +7,6 @@ TAGS | |||
build | |||
builddir | |||
subprojects | |||
# Ignore (compiled) python files, | |||
*__pycache__/* | |||
*.pyc |
@@ -6,7 +6,8 @@ Contributors notice after the change (LastName, FirstName / nick) | |||
## 2021-01-15 1.5.0 | |||
WARNING: Next scheduled release (2021-04-15) will switch the default session dir to an XDG Path. | |||
WARNING: Next scheduled release (2021-04-15) will switch the default session root | |||
to $XDG_DATA_HOME ( default on most distributions: ~/.local/share/nsm/ ) | |||
With the next release prepare to either | |||
1) Move old sessions to the new root directory (preferred) | |||
2) Symlink "~/NSM Sessions" to the new root directory | |||
@@ -21,6 +22,14 @@ Legacy-GUI: | |||
Fix manpage description and usage with the correct executable name | |||
Fix resizing to very small and back. ( / TheGreatWhiteShark ) | |||
Extras: | |||
This repository now contains extras (libraries, programs, documentation etc.) | |||
Extras are technically not connected to the main programs of this repository. | |||
There is no dependency to any "extra" nor any license implications. | |||
Please read extras/README.md. | |||
nsm.h was moved to extras/nsm.h | |||
"extras/pynsm" is now a part of NEW-SM. It was a standalone git repo until now. | |||
## 2020-07-15 1.4.0 | |||
Add documentation and manpages. | |||
@@ -65,7 +65,7 @@ which was released under the GNU GENERAL PUBLIC LICENSE Version 2, June 1991. | |||
All files, except nsm.h kept in this fork were GPL "version 2 of the License, or (at your | |||
option) any later version." | |||
`nsm.h` is licensed under the ISC License. | |||
`extras/nsm.h/nsm.h` is licensed under the ISC License. | |||
New-Session-Manager changed the license to GNU GENERAL PUBLIC LICENSE, Version 3, 29 June 2007. | |||
See file COPYING | |||
@@ -57,9 +57,15 @@ and are therefore out of scope for this document. | |||
However, the same server-side API can also be implemented by other programs (such as Carla), | |||
although consistency and robustness will likely suffer if non-NSM compliant clients are allowed to | |||
participate in a session. There is no direct dependency for client implementations, as long as they | |||
can send and receive OSC. Some clients use `liblo` (the OSC library), which becomes a dependency if | |||
you choose to implement NSM-support with the provided header file `nsm.h`. | |||
participate in a session. | |||
There is no direct dependency for client implementations, as long as they | |||
can send and receive OSC. | |||
Some clients use `liblo` (the OSC library), which becomes a dependency if you choose to implement | |||
NSM-support with the provided header file `nsm.h` (`extras/nsm.h/nsm.h` in the git repository). | |||
Some clients use the provided single-file python library `pynsm` (`extras/pynsm/nsmclient.py` in the git repository) | |||
which has no dependencies outside the Python3 standard library. | |||
The aim of this project is to thoroughly define the behavior required of clients. Often the | |||
difficulty with other session-management approaches has been not in implementing code-support for | |||
@@ -209,8 +215,8 @@ like Python can be more challenging. | |||
|Name | Description | |||
|switch | client is capable of responding to multiple `open` messages without restarting | |||
|dirty | client knows when it has unsaved changes | |||
|switch | client is capable of responding to multiple `open` messages without restarting | |||
|dirty | client knows when it has unsaved changes | |||
|progress | client can send progress updates during time-consuming operations | |||
|message | client can send textual status updates | |||
|optional-gui | client has an optional GUI | |||
@@ -243,8 +249,8 @@ Presently, the server `capabilities` are: | |||
|Name | Description | |||
|server-control | client-to-server control | |||
|broadcast | server responds to /nsm/server/broadcast message | |||
|server-control | client-to-server control | |||
|broadcast | server responds to /nsm/server/broadcast message | |||
|optional-gui | server responds to optional-gui messages. If this capability is not present then clients with optional-guis MUST always keep them visible | |||
|=== | |||
@@ -268,7 +274,7 @@ The following table defines possible values of `error_code`: | |||
|Code | Meaning | |||
|ERR_GENERAL | General Error | |||
|ERR_GENERAL | General Error | |||
|ERR_INCOMPATIBLE_API | Incompatible API version | |||
|ERR_BLACKLISTED | Client has been blacklisted. | |||
@@ -401,7 +407,7 @@ Or | |||
|Code | Meaning | |||
|ERR | General Error | |||
|ERR | General Error | |||
|ERR_BAD_PROJECT | An existing project file was found to be corrupt | |||
|ERR_CREATE_FAILED | A new project could not be created | |||
|ERR_UNSAVED_CHANGES | Unsaved changes would be lost | |||
@@ -441,7 +447,7 @@ Or | |||
|Code | Meaning | |||
|ERR | General Error | |||
|ERR | General Error | |||
|ERR_SAVE_FAILED | Project could not be saved | |||
|ERR_NOT_NOW | Operation cannot be completed at this time | |||
@@ -573,18 +579,18 @@ string. | |||
[options="header", stripes=even] | |||
|=== | |||
|Symbolic Name | Integer Value | |||
|Symbolic Name | Integer Value | |||
|ERR_GENERAL | -1 | |||
|ERR_INCOMPATIBLE_API | -2 | |||
|ERR_BLACKLISTED | -3 | |||
|ERR_LAUNCH_FAILED | -4 | |||
|ERR_NO_SUCH_FILE | -5 | |||
|ERR_NO_SESSION_OPEN | -6 | |||
|ERR_UNSAVED_CHANGES | -7 | |||
|ERR_NOT_NOW | -8 | |||
|ERR_BAD_PROJECT | -9 | |||
|ERR_CREATE_FAILED | -10 | |||
|ERR_GENERAL | -1 | |||
|ERR_INCOMPATIBLE_API | -2 | |||
|ERR_BLACKLISTED | -3 | |||
|ERR_LAUNCH_FAILED | -4 | |||
|ERR_NO_SUCH_FILE | -5 | |||
|ERR_NO_SESSION_OPEN | -6 | |||
|ERR_UNSAVED_CHANGES | -7 | |||
|ERR_NOT_NOW | -8 | |||
|ERR_BAD_PROJECT | -9 | |||
|ERR_CREATE_FAILED | -10 | |||
|=== | |||
@@ -624,12 +630,12 @@ The possible errors are: | |||
[options="header", stripes=even] | |||
|=== | |||
|Code |Meaning | |||
|Code |Meaning | |||
|ERR_GENERAL | General Error | |||
|ERR_LAUNCH_FAILED | Launch failed | |||
|ERR_NO_SUCH_FILE | No such file | |||
|ERR_NO_SESSION | No session is open | |||
|ERR_GENERAL | General Error | |||
|ERR_LAUNCH_FAILED | Launch failed | |||
|ERR_NO_SUCH_FILE | No such file | |||
|ERR_NO_SESSION | No session is open | |||
|ERR_UNSAVED_CHANGES | Unsaved changes would be lost | |||
|=== | |||
@@ -723,14 +729,14 @@ PATCH version when you make backwards compatible bug fixes. | |||
[options="header", stripes=even] | |||
|=== | |||
|Subject | Version | |||
|Subject | Version | |||
|Non Session Manager at moment of fork | 1.2 (June 2020) | |||
|Non Session Manager API | 1.0 link:https://github.com/original-male/non/blob/master/session-manager/src/nsmd.C[NON nsmd.C] | |||
|Original API Document | 1.0 link:http://non.tuxfamily.org/nsm/API.html[non.tuxfamily.org/nsm/API.html] | |||
|New Session Manager | 1.4.0 | |||
|Non Session Manager at moment of fork | 1.2 (June 2020) | |||
|Non Session Manager API | 1.0 link:https://github.com/original-male/non/blob/master/session-manager/src/nsmd.C[NON nsmd.C] | |||
|Original API Document | 1.0 link:http://non.tuxfamily.org/nsm/API.html[non.tuxfamily.org/nsm/API.html] | |||
|New Session Manager | 1.4.0 | |||
|New Session Manager API | 1.1.0 link:https://github.com/linuxaudio/new-session-manager/blob/master/src/nsmd.cpp[NEW nsmd.cpp] | |||
|New API Document | 1.4.0 link:#[Here] | |||
|New API Document | 1.4.0 link:#[Here] | |||
|=== | |||
@@ -0,0 +1,10 @@ | |||
# New Session Manager - Extras | |||
Each subdirectory in /extras holds additional libraries, software, documentation etc. | |||
They are included for convenience, e.g. the library pynsm is useful for client-programmers, | |||
and also developed by the same author as New-Session-Manager. | |||
Each "extra" is standalone regarding license and build process. They are not build or installed | |||
through nsm(d) meson build. The main programs in this repository do not depend on files in /extra in | |||
any way. From a technical point of view `/extras` could be safely deleted. |
@@ -0,0 +1,11 @@ | |||
https://www.isc.org/licenses/ | |||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without | |||
fee is hereby granted, provided that the above copyright notice and this permission notice appear | |||
in all copies. | |||
THE SOFTWARE IS PROVIDED “AS IS” AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE | |||
INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE | |||
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING | |||
FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS | |||
ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. |
@@ -1,5 +1,5 @@ | |||
/*************************************************************************/ | |||
/*******************************************A******************************/ | |||
/* Copyright (C) 2012 Jonathan Moore Liles */ | |||
/* Copyright (C) 2020- Nils Hilbricht */ | |||
/* */ |
@@ -0,0 +1,18 @@ | |||
MIT License | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |||
associated documentation files (the "Software"), to deal in the Software without restriction, | |||
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all copies or | |||
substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT | |||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
@@ -0,0 +1,85 @@ | |||
# pynsm | |||
NSM/New Session Manager client library in Python - No dependencies except Python3. | |||
PyNSMClient - A New Session Manager Client-Library in one file. | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
This library is licensed under the MIT license. Please check the file LICENSE for more information. | |||
This library has no version numbers, or releases. It is a "rolling" git. Copy it into your source and update only if you need new features. | |||
## Short Instructions | |||
Before you start a word about control flow: NSM and any client, like this one, can be | |||
considered automation and remote-control of GUI operations, normally done by the user. That means | |||
this module needs to be included in your GUI code. The author personally creates the pynsm-object | |||
as early as possible in his PyQt Mainwindow class. | |||
Copy nsmclient.py to your own program and import and initialize it as early as possible (see below) | |||
Then add nsmClient.reactToMessage to your existing event loop. | |||
from nsmclient import NSMClient | |||
nsmClient = NSMClient(prettyName = niceTitle, #will raise an error and exit if this example is not run from NSM. | |||
saveCallback = saveCallbackFunction, | |||
openOrNewCallback = openOrNewCallbackFunction, | |||
supportsSaveStatus = False, # Change this to True if your program announces it's save status to NSM | |||
exitProgramCallback = exitCallbackFunction, | |||
hideGUICallback = None, #replace with your hiding function. You need to answer in your function with nsmClient.announceGuiVisibility(False) | |||
showGUICallback = None, #replace with your showing function. You need to answer in your function with nsmClient.announceGuiVisibility(True) | |||
broadcastCallback = None, #give a function that reacts to any broadcast by any other client. | |||
sessionIsLoadedCallback = None, #give a function that reacts to the one-time state when a session has fully loaded. | |||
loggingLevel = "info", #"info" for development or debugging, "error" for production. default is error. | |||
) | |||
Don't forget to add nsmClient.reactToMessage to your event loop. | |||
* Each of the callbacks "save", "open/new" and "exit" receive three parameters: ourPath, sessionName, ourClientNameUnderNSM. | |||
* openOrNew gets called first. Init your jack client there with ourClientNameUnderNSM as name. | |||
* exitProgramCallback is the place to gracefully exit your program, including jack-client closing. | |||
* saveCallback gets called all the time. Use ourPath either as filename or as directory. | |||
* If you choose filename add an extension. | |||
* If you choose directory make sure that the filenames inside are static, no matter what project/session. The user must have no influence over file naming | |||
* broadcastCallback receives five parameters. The three standard: ourPath, sessionName, ourClientNameUnderNSM. And additionally messagePath and listOfArguments. MessagePath is entirely program specific, the number and type of arguments depend on the sender. | |||
* sessionIsLoadedCallback receives no parameters. It is ONLY send once, after the session is fully loaded. This is NOT the place to delay your announce. If you add your client to a running session (which must happen at some point) sessionLoaded will NOT get called. | |||
* Additional callbacks are: hideGUICallback and showGUICallback. These receive no parameters and need to answer with the function: nsmClient.announceGuiVisibility(bool). That means you can decline show or hide, dependending on the state of your program. | |||
The nsmClient object has methods and variables such as: | |||
* nsmClient.ourClientNameUnderNSM | |||
* Always use this name for your program | |||
* nsmClient.announceSaveStatus(False) | |||
* Announce your save status (dirty = False / clean = True), If your program sends those messages set supportsSaveStatus = True when intializing NSMClient with both hideGUICallback and showGUICallback | |||
* nsmClient.sessionName | |||
* nsmClient.ourOscUrl = osc.udp://{ip}:{port}/ Use this to broadcast your presence, to handshake communication between different programs | |||
* nsmClient.announceGuiVisibility(bool) | |||
* Announce if your GUI is visible (True) or not (False). Only works if you initialized NSMClient with both hideGUICallback and showGUICallback. Don't forget to send it once for your state after starting your program. | |||
* nsmcClient.changeLabel(prettyName) | |||
* Tell the GUI to append (prettyName) to our name. This is not saved by NSM but you need to send it yourself each startup. | |||
* nsmClient.serverSendSaveToSelf() | |||
* A clean solution to use the nsm save callbacks from within your program (Ctrl+S or File->Save). No need for redundant save mechanism. | |||
* nsmClient.serverSendExitToSelf() | |||
* A clean quit, without "client died unexpectedly". Use sys.exit() to exit your program in your nms-quit callback. | |||
* nsmClient.importResource(filepath) | |||
* Use this to load external resources, for example a sample file. It links the sample file into the session dir, according to the NSM rules, and returns the path of the linked file. | |||
* nsmClient.debugResetDataAndExit() | |||
* Deletes self.ourpath, which is the session save file or directory, recursively and exits the client. This is only meant for debugging and testing. | |||
## Long Instructions | |||
* Read and start example.py, then read and understand nsmclient.py. It requires PyQt5 to execute and a brain to read. | |||
* There are several very minimal and basic clients in the directory `minimalClients/` | |||
* For your own program read and learn the NSM API: http://non.tuxfamily.org/nsm/API.html | |||
* The hard part about session management is not to use this lib or write your own but to make your program comply to the strict rules of session management. | |||
## Additional Examples | |||
More examples can be found in `/minimalClients`. This mimics a minimal, but functional program. | |||
To actually run the programs and attach them to a running session execute the inluced file `source_me_with_port.bash <PORT>` | |||
where <PORT> is the NSM port the server printed out after starting. | |||
You can see that nsmclient.py is included in this dir again, to avoid redundancy only as symlink. | |||
In your real program this would be the real file. | |||
## Sources and Influences | |||
* The New-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ | |||
* New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager | |||
* With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) |
@@ -0,0 +1,112 @@ | |||
#! /usr/bin/env python3 | |||
# -*- coding: utf-8 -*- | |||
""" | |||
PyNSMClient - A New Session Manager Client-Library in one file. | |||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ | |||
New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager | |||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) | |||
MIT License | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |||
associated documentation files (the "Software"), to deal in the Software without restriction, | |||
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all copies or | |||
substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT | |||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||
""" | |||
from time import sleep # main event loop at the bottom of this file | |||
# nsm only | |||
from nsmclient import NSMClient # will raise an error and exit if this example is not run from NSM. | |||
######################################################################## | |||
# General | |||
######################################################################## | |||
niceTitle = "myNSMprogram" # This is the name of your program. This will display in NSM and can be used in your save file | |||
######################################################################## | |||
# Prepare the NSM Client | |||
# This has to be done as the first thing because NSM needs to get the paths | |||
# and may quit if NSM environment var was not found. | |||
# | |||
# This is also where to set up your functions that react to messages from NSM, | |||
######################################################################## | |||
# Here are some variables you can use: | |||
# ourPath = Full path to your NSM save file/directory with serialized NSM extension | |||
# ourClientNameUnderNSM = Your NSM save file/directory with serialized NSM extension and no path information | |||
# sessionName = The name of your session with no path information | |||
######################################################################## | |||
def saveCallback(ourPath, sessionName, ourClientNameUnderNSM): | |||
# Put your code to save your config file in here | |||
print("saveCallback"); | |||
def openCallback(ourPath, sessionName, ourClientNameUnderNSM): | |||
# Put your code to open your config file in here | |||
print("openCallback"); | |||
def exitProgram(ourPath, sessionName, ourClientNameUnderNSM): | |||
"""This function is a callback for NSM. | |||
We have a chance to close our clients and open connections here. | |||
If not nsmclient will just kill us no matter what | |||
""" | |||
print("exitProgram"); | |||
# Exit is done by NSM kill. | |||
def showGUICallback(): | |||
# Put your code that shows your GUI in here | |||
print("showGUICallback"); | |||
nsmClient.announceGuiVisibility(isVisible=True) # Inform NSM that the GUI is now visible. Put this at the end. | |||
def hideGUICallback(): | |||
# Put your code that hides your GUI in here | |||
print("hideGUICallback"); | |||
nsmClient.announceGuiVisibility(isVisible=False) # Inform NSM that the GUI is now hidden. Put this at the end. | |||
nsmClient = NSMClient(prettyName = niceTitle, | |||
saveCallback = saveCallback, | |||
openOrNewCallback = openCallback, | |||
showGUICallback = showGUICallback, # Comment this line out if your program does not have an optional GUI | |||
hideGUICallback = hideGUICallback, # Comment this line out if your program does not have an optional GUI | |||
supportsSaveStatus = False, # Change this to True if your program announces it's save status to NSM | |||
exitProgramCallback = exitProgram, | |||
loggingLevel = "info", # "info" for development or debugging, "error" for production. default is error. | |||
) | |||
# If NSM did not start up properly the program quits here. | |||
######################################################################## | |||
# If your project uses JACK, activate your client here | |||
# You can use jackClientName or create your own | |||
######################################################################## | |||
jackClientName = nsmClient.ourClientNameUnderNSM | |||
######################################################################## | |||
# Start main program loop. | |||
######################################################################## | |||
# showGUICallback() # If you want your GUI to be shown by default, uncomment this line | |||
print("Entering main loop") | |||
while True: | |||
nsmClient.reactToMessage() # Make sure this exists somewhere in your main loop | |||
# nsmClient.announceSaveStatus(False) # Announce your save status (dirty = False / clean = True) | |||
sleep(0.05) |
@@ -0,0 +1,213 @@ | |||
#! /usr/bin/env python3 | |||
# -*- coding: utf-8 -*- | |||
""" | |||
PyNSMClient - A New Session Manager Client-Library in one file. | |||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ | |||
New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager | |||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) | |||
MIT License | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |||
associated documentation files (the "Software"), to deal in the Software without restriction, | |||
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all copies or | |||
substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT | |||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||
""" | |||
import sys #for qt app args. | |||
from os import getpid #we use this as jack meta data | |||
from PyQt5 import QtWidgets, QtCore | |||
#jack only | |||
import ctypes | |||
from random import uniform #to generate samples between -1.0 and 1.0 for Jack. | |||
#nsm only | |||
from nsmclient import NSMClient | |||
######################################################################## | |||
#Prepare the Qt Window | |||
######################################################################## | |||
class Main(QtWidgets.QWidget): | |||
def __init__(self, qtApp): | |||
super().__init__() | |||
self.qtApp = qtApp | |||
self.layout = QtWidgets.QVBoxLayout() | |||
self.layout.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) | |||
self.setLayout(self.layout) | |||
niceTitle = "PyNSM v2 Example - JACK Noise" | |||
self.title = QtWidgets.QLabel("") | |||
self.saved = QtWidgets.QLabel("") | |||
self._value = QtWidgets.QSlider(orientation = 1) #horizontal | |||
self._value.setMinimum(0) | |||
self._value.setMaximum(100) | |||
self._value.setValue(50) #only visible for the first start. | |||
self.valueLabel = QtWidgets.QLabel("Noise Volume: " + str(self._value.value())) | |||
self._value.valueChanged.connect(lambda new: self.valueLabel.setText("Noise Volume: " + str(new))) | |||
self.layout.addWidget(self.title) | |||
self.layout.addWidget(self.saved) | |||
self.layout.addWidget(self._value) | |||
self.layout.addWidget(self.valueLabel) | |||
#Prepare the NSM Client | |||
#This has to be done as soon as possible because NSM provides paths and names for us. | |||
#and may quit if NSM environment var was not found. | |||
self.nsmClient = NSMClient(prettyName = niceTitle, #will raise an error and exit if this example is not run from NSM. | |||
supportsSaveStatus = True, | |||
saveCallback = self.saveCallback, | |||
openOrNewCallback = self.openOrNewCallback, | |||
exitProgramCallback = self.exitCallback, | |||
loggingLevel = "info", #"info" for development or debugging, "error" for production. default is error. | |||
) | |||
#If NSM did not start up properly the program quits here with an error message from NSM. | |||
#No JACK client gets created, no Qt window can be seen. | |||
self.title.setText("<b>" + self.nsmClient.ourClientNameUnderNSM + "</b>") | |||
self.eventLoop = QtCore.QTimer() | |||
self.eventLoop.start(100) #10ms-20ms is smooth for "real time" feeling. 100ms is still ok. | |||
self.eventLoop.timeout.connect(self.nsmClient.reactToMessage) | |||
#self.show is called as the new/open callback. | |||
@property | |||
def value(self): | |||
return str(self._value.value()) | |||
@value.setter | |||
def value(self, new): | |||
new = int(new) | |||
self._value.setValue(new) | |||
def saveCallback(self, ourPath, sessionName, ourClientNameUnderNSM): #parameters are filled in by NSM. | |||
if self.value: | |||
with open(ourPath, "w") as f: #ourpath is taken as a filename here. We have this path name at our disposal and we know we only want one file. So we don't make a directory. This way we don't have to create a dir first. | |||
f.write(self.value) | |||
def openOrNewCallback(self, ourPath, sessionName, ourClientNameUnderNSM): #parameters are filled in by NSM. | |||
try: | |||
with open(ourPath, "r") as f: | |||
savedValue = f.read() #a string | |||
self.saved.setText("{}: {}".format(ourPath, savedValue)) | |||
self.value = savedValue #internal casting to int. Sets the slider. | |||
except FileNotFoundError: | |||
self.saved.setText("{}: No save file found. Normal for first start.".format(ourPath)) | |||
finally: | |||
self.show() | |||
def exitCallback(self, ourPath, sessionName, ourClientNameUnderNSM): | |||
"""This function is a callback for NSM. | |||
We have a chance to close our clients and open connections here. | |||
If not nsmclient will just kill us no matter what | |||
""" | |||
cjack.jack_remove_properties(ctypesJackClient, ctypesJackUuid) #clean our metadata | |||
cjack.jack_client_close(ctypesJackClient) #omitting this introduces problems. in Jack1 this would mute all jack clients for several seconds. | |||
exit() #or get SIGKILLed through NSM | |||
def closeEvent(self, event): | |||
"""Qt likes to quits on its own. For example when the window manager closes the | |||
main window. Ignore that request and instead send a roundtrip through NSM""" | |||
self.nsmClient.serverSendExitToSelf() | |||
event.ignore() | |||
#Prepare the window instance. Gets executed at the end of this file. | |||
qtApp = QtWidgets.QApplication(sys.argv) | |||
ourClient = Main(qtApp) | |||
######################################################################## | |||
#Prepare the JACK Client | |||
#We need the client name from NSM first. | |||
######################################################################## | |||
cjack = ctypes.cdll.LoadLibrary("libjack.so.0") | |||
clientName = ourClient.nsmClient.prettyName #the nsm client is in the qt instance here. But in your program it can be anywhere. | |||
options = 0 | |||
status = None | |||
class jack_client_t(ctypes.Structure): | |||
_fields_ = [] | |||
cjack.jack_client_open.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)] #the two ints are enum and pointer to enum. #http://jackaudio.org/files/docs/html/group__ClientFunctions.html#gab8b16ee616207532d0585d04a0bd1d60 | |||
cjack.jack_client_open.restype = ctypes.POINTER(jack_client_t) | |||
ctypesJackClient = cjack.jack_client_open(clientName.encode("ascii"), options, status) | |||
#Create one output port | |||
class jack_port_t(ctypes.Structure): | |||
_fields_ = [] | |||
JACK_DEFAULT_AUDIO_TYPE = "32 bit float mono audio".encode("ascii") #http://jackaudio.org/files/docs/html/types_8h.html | |||
JACK_PORT_IS_OUTPUT = 0x2 #http://jackaudio.org/files/docs/html/types_8h.html | |||
portname = "output".encode("ascii") | |||
cjack.jack_port_register.argtypes = [ctypes.POINTER(jack_client_t), ctypes.c_char_p, ctypes.c_char_p, ctypes.c_ulong, ctypes.c_ulong] #http://jackaudio.org/files/docs/html/group__PortFunctions.html#ga3e21d145c3c82d273a889272f0e405e7 | |||
cjack.jack_port_register.restype = ctypes.POINTER(jack_port_t) | |||
outputPort = cjack.jack_port_register(ctypesJackClient, portname, JACK_DEFAULT_AUDIO_TYPE, JACK_PORT_IS_OUTPUT, 0) | |||
cjack.jack_client_close.argtypes = [ctypes.POINTER(jack_client_t),] | |||
#Create the callback | |||
#http://jackaudio.org/files/docs/html/group__ClientCallbacks.html#gafb5ec9fb4b736606d676c135fb97888b | |||
jack_nframes_t = ctypes.c_uint32 | |||
cjack.jack_port_get_buffer.argtypes = [ctypes.POINTER(jack_port_t), jack_nframes_t] | |||
cjack.jack_port_get_buffer.restype = ctypes.POINTER(ctypes.c_float) #this is only valid for audio, not for midi. C Jack has a pointer to void here. | |||
def pythonJackCallback(nframes, void): #types: jack_nframes_t (ctypes.c_uint32), pointer to void | |||
"""http://jackaudio.org/files/docs/html/simple__client_8c.html#a01271cc6cf692278ae35d0062935d7ae""" | |||
out = cjack.jack_port_get_buffer(outputPort, nframes) #out should be a pointer to jack_default_audio_sample_t (float, ctypes.POINTER(ctypes.c_float)) | |||
#For each required sample | |||
for i in range(nframes): | |||
factor = ourClient._value.value() / 100 | |||
val = ctypes.c_float(round(uniform(-0.5, 0.5) * factor, 10)) | |||
out[i]= val | |||
return 0 # 0 on success, otherwise a non-zero error code, causing JACK to remove that client from the process() graph. | |||
JACK_CALLBACK_TYPE = ctypes.CFUNCTYPE(ctypes.c_int, jack_nframes_t, ctypes.c_void_p) #the first parameter is the return type, the following are input parameters | |||
callbackFunction = JACK_CALLBACK_TYPE(pythonJackCallback) | |||
cjack.jack_set_process_callback.argtypes = [ctypes.POINTER(jack_client_t), JACK_CALLBACK_TYPE, ctypes.c_void_p] | |||
cjack.jack_set_process_callback.restype = ctypes.c_uint32 #I think this is redundant since ctypes has int as default result type | |||
cjack.jack_set_process_callback(ctypesJackClient, callbackFunction, 0) | |||
#Ready. Activate the client. | |||
cjack.jack_activate(ctypesJackClient) | |||
#The Jack Processing functions gets called by jack in another thread. We just have to keep this program itself running. Qt does the job. | |||
#Jack Metadata - Inform the jack server about our program. Optional but has benefits when used with other programs that rely on metadata. | |||
#http://jackaudio.org/files/docs/html/group__Metadata.html | |||
jack_uuid_t = ctypes.c_uint64 | |||
cjack.jack_set_property.argtypes = [ctypes.POINTER(jack_client_t), jack_uuid_t, ctypes.c_char_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)] #client(we), subject/uuid,key,value/data,type | |||
cjack.jack_remove_properties.argtypes = [ctypes.POINTER(jack_client_t), jack_uuid_t] #for cleaning up when the program stops. the jack server can do it in newer jack versions, but this is safer. | |||
cjack.jack_get_uuid_for_client_name.argtypes = [ctypes.POINTER(jack_client_t), ctypes.c_char_p] | |||
cjack.jack_get_uuid_for_client_name.restype = ctypes.c_char_p | |||
ourJackUuid = cjack.jack_get_uuid_for_client_name(ctypesJackClient, clientName.encode("ascii")) | |||
ourJackUuid = int(ourJackUuid.decode("UTF-8")) | |||
ctypesJackUuid = jack_uuid_t(ourJackUuid) | |||
cjack.jack_set_property(ctypesJackClient, ctypesJackUuid, ctypes.c_char_p(b"pid"), ctypes.c_char_p(str(getpid()).encode()), None) | |||
################## | |||
#Start everything | |||
qtApp.exec_() |
@@ -0,0 +1,96 @@ | |||
#! /usr/bin/env python3 | |||
# -*- coding: utf-8 -*- | |||
""" | |||
PyNSMClient - A New Session Manager Client-Library in one file. | |||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ | |||
New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager | |||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) | |||
MIT License | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |||
associated documentation files (the "Software"), to deal in the Software without restriction, | |||
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all copies or | |||
substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT | |||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||
""" | |||
#/nsm/server/announce #A hack to get this into Agordejo launcher discovery | |||
from nsmclient import NSMClient | |||
import sys | |||
from time import sleep | |||
import os | |||
from threading import Timer | |||
sys.path.append(os.getcwd()) | |||
class BaseClient(object): | |||
def saveCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): | |||
print (__file__, "save") | |||
def openOrNewCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): | |||
print (__file__,"open/new") | |||
def exitCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): | |||
print (__file__, "quit") | |||
sys.exit() | |||
def broadcastCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM, messagePath, listOfArguments): | |||
print (__file__, "broadcast") | |||
def event(self, nsmClient): | |||
pass | |||
def __init__(self, name, delayedFunctions=[], eventFunction=None): | |||
"""delayedFunctions are a (timer delay in seconds, function call) list of tuples. They will | |||
be executed once. | |||
If the function is a string instead it will be evaluated in the BaseClient context, | |||
providing self. Do not give a lambda! | |||
Give eventFunction for repeated execution.""" | |||
self.nsmClient = NSMClient(prettyName = name, #will raise an error and exit if this example is not run from NSM. | |||
saveCallback = self.saveCallbackFunction, | |||
openOrNewCallback = self.openOrNewCallbackFunction, | |||
supportsSaveStatus = False, # Change this to True if your program announces it's save status to NSM | |||
exitProgramCallback = self.exitCallbackFunction, | |||
broadcastCallback = self.broadcastCallbackFunction, | |||
hideGUICallback = None, #replace with your hiding function. You need to answer in your function with nsmClient.announceGuiVisibility(False) | |||
showGUICallback = None, #replace with your showing function. You need to answer in your function with nsmClient.announceGuiVisibility(True) | |||
loggingLevel = "info", #"info" for development or debugging, "error" for production. default is error. | |||
) | |||
if eventFunction: | |||
self.event = eventFunction | |||
for delay, func in delayedFunctions: | |||
if type(func) is str: | |||
func = eval('lambda self=self: ' + func ) | |||
t = Timer(interval=delay, function=func, args=()) | |||
t.start() | |||
while True: | |||
self.nsmClient.reactToMessage() | |||
self.event(self.nsmClient) | |||
sleep(0.05) | |||
if __name__ == '__main__': | |||
"""This is the most minimal nsm client in existence""" | |||
BaseClient(name="testclient_base") #this never returns an object. | |||
@@ -0,0 +1,49 @@ | |||
#! /usr/bin/env python3 | |||
# -*- coding: utf-8 -*- | |||
""" | |||
PyNSMClient - A New Session Manager Client-Library in one file. | |||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ | |||
New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager | |||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) | |||
MIT License | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |||
associated documentation files (the "Software"), to deal in the Software without restriction, | |||
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all copies or | |||
substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT | |||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||
""" | |||
#/nsm/server/announce #A hack to get this into Agordejo launcher discovery | |||
import base | |||
from time import sleep | |||
def hello(nsmClient): | |||
nsmClient.broadcast("/index/counter", [hello.i]) | |||
hello.i += 1 | |||
sleep(1) | |||
hello.i = 0 | |||
class BroadcastClient(base.BaseClient): | |||
def broadcastCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM, messagePath, listOfArguments): | |||
print (f"We are only an example client, but surely it would be valuable to react to {messagePath} for {listOfArguments}") | |||
if __name__ == '__main__': | |||
BroadcastClient(name="testclient_broadcast", eventFunction=hello) #this never returns an object. |
@@ -0,0 +1,40 @@ | |||
#! /usr/bin/env python3 | |||
# -*- coding: utf-8 -*- | |||
""" | |||
PyNSMClient - A New Session Manager Client-Library in one file. | |||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ | |||
New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager | |||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) | |||
MIT License | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |||
associated documentation files (the "Software"), to deal in the Software without restriction, | |||
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all copies or | |||
substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT | |||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||
""" | |||
#/nsm/server/announce #A hack to get this into Agordejo launcher discovery | |||
import base | |||
if __name__ == '__main__': | |||
funcs = [ | |||
(1, 'self.nsmClient.changeLabel("Pretty Name")'), | |||
#(4, lambda: print ("four")), | |||
] | |||
base.BaseClient(name="testclient_label", delayedFunctions=funcs) #this never returns an object. |
@@ -0,0 +1 @@ | |||
../nsmclient.py |
@@ -0,0 +1,9 @@ | |||
#!/bin/bash | |||
if [[ -z "$1" ]]; then | |||
echo "Give NSM OSC Port as only parameter. Afterwards you can run the executable pythons in this dir, directly, without ./" | |||
exit 1 | |||
fi | |||
export NSM_URL=osc.udp://0.0.0.0:$1/ | |||
export PATH=$(pwd):$PATH |
@@ -0,0 +1,713 @@ | |||
#! /usr/bin/env python3 | |||
# -*- coding: utf-8 -*- | |||
""" | |||
PyNSMClient - A New Session Manager Client-Library in one file. | |||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ | |||
New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager | |||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) | |||
MIT License | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |||
associated documentation files (the "Software"), to deal in the Software without restriction, | |||
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all copies or | |||
substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT | |||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||
""" | |||
import logging; | |||
logger = None #filled by init with prettyName | |||
import struct | |||
import socket | |||
from os import getenv, getpid, kill | |||
import os | |||
import os.path | |||
import shutil | |||
from uuid import uuid4 | |||
from sys import argv | |||
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. | |||
from urllib.parse import urlparse | |||
class _IncomingMessage(object): | |||
"""Representation of a parsed datagram representing an OSC message. | |||
An OSC message consists of an OSC Address Pattern followed by an OSC | |||
Type Tag String followed by zero or more OSC Arguments. | |||
""" | |||
def __init__(self, dgram): | |||
#NSM Broadcasts are bundles, but very simple ones. We only need to care about the single message it contains. | |||
#Therefore we can strip the bundle prefix and handle it as normal message. | |||
if b"#bundle" in dgram: | |||
bundlePrefix, singleMessage = dgram.split(b"/", maxsplit=1) | |||
dgram = b"/" + singleMessage # / eaten by split | |||
self.isBroadcast = True | |||
else: | |||
self.isBroadcast = False | |||
self.LENGTH = 4 #32 bit | |||
self._dgram = dgram | |||
self._parameters = [] | |||
self.parse_datagram() | |||
def get_int(self, dgram, start_index): | |||
"""Get a 32-bit big-endian two's complement integer from the datagram. | |||
Args: | |||
dgram: A datagram packet. | |||
start_index: An index where the integer starts in the datagram. | |||
Returns: | |||
A tuple containing the integer and the new end index. | |||
Raises: | |||
ValueError if the datagram could not be parsed. | |||
""" | |||
try: | |||
if len(dgram[start_index:]) < self.LENGTH: | |||
raise ValueError('Datagram is too short') | |||
return ( | |||
struct.unpack('>i', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH) | |||
except (struct.error, TypeError) as e: | |||
raise ValueError('Could not parse datagram %s' % e) | |||
def get_string(self, dgram, start_index): | |||
"""Get a python string from the datagram, starting at pos start_index. | |||
We receive always the full string, but handle only the part from the start_index internally. | |||
In the end return the offset so it can be added to the index for the next parameter. | |||
Each subsequent call handles less of the same string, starting further to the right. | |||
According to the specifications, a string is: | |||
"A sequence of non-null ASCII characters followed by a null, | |||
followed by 0-3 additional null characters to make the total number | |||
of bits a multiple of 32". | |||
Args: | |||
dgram: A datagram packet. | |||
start_index: An index where the string starts in the datagram. | |||
Returns: | |||
A tuple containing the string and the new end index. | |||
Raises: | |||
ValueError if the datagram could not be parsed. | |||
""" | |||
#First test for empty string, which is nothing, followed by a terminating \x00 padded by three additional \x00. | |||
if dgram[start_index:].startswith(b"\x00\x00\x00\x00"): | |||
return "", start_index + 4 | |||
#Otherwise we have a non-empty string that must follow the rules of the docstring. | |||
offset = 0 | |||
try: | |||
while dgram[start_index + offset] != 0: | |||
offset += 1 | |||
if offset == 0: | |||
raise ValueError('OSC string cannot begin with a null byte: %s' % dgram[start_index:]) | |||
# Align to a byte word. | |||
if (offset) % self.LENGTH == 0: | |||
offset += self.LENGTH | |||
else: | |||
offset += (-offset % self.LENGTH) | |||
# Python slices do not raise an IndexError past the last index, | |||
# do it ourselves. | |||
if offset > len(dgram[start_index:]): | |||
raise ValueError('Datagram is too short') | |||
data_str = dgram[start_index:start_index + offset] | |||
return data_str.replace(b'\x00', b'').decode('utf-8'), start_index + offset | |||
except IndexError as ie: | |||
raise ValueError('Could not parse datagram %s' % ie) | |||
except TypeError as te: | |||
raise ValueError('Could not parse datagram %s' % te) | |||
def get_float(self, dgram, start_index): | |||
"""Get a 32-bit big-endian IEEE 754 floating point number from the datagram. | |||
Args: | |||
dgram: A datagram packet. | |||
start_index: An index where the float starts in the datagram. | |||
Returns: | |||
A tuple containing the float and the new end index. | |||
Raises: | |||
ValueError if the datagram could not be parsed. | |||
""" | |||
try: | |||
return (struct.unpack('>f', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH) | |||
except (struct.error, TypeError) as e: | |||
raise ValueError('Could not parse datagram %s' % e) | |||
def parse_datagram(self): | |||
try: | |||
self._address_regexp, index = self.get_string(self._dgram, 0) | |||
if not self._dgram[index:]: | |||
# No params is legit, just return now. | |||
return | |||
# Get the parameters types. | |||
type_tag, index = self.get_string(self._dgram, index) | |||
if type_tag.startswith(','): | |||
type_tag = type_tag[1:] | |||
# Parse each parameter given its type. | |||
for param in type_tag: | |||
if param == "i": # Integer. | |||
val, index = self.get_int(self._dgram, index) | |||
elif param == "f": # Float. | |||
val, index = self.get_float(self._dgram, index) | |||
elif param == "s": # String. | |||
val, index = self.get_string(self._dgram, index) | |||
else: | |||
logger.warning("Unhandled parameter type: {0}".format(param)) | |||
continue | |||
self._parameters.append(val) | |||
except ValueError as pe: | |||
#raise ValueError('Found incorrect datagram, ignoring it', pe) | |||
# Raising an error is not ignoring it! | |||
logger.warning("Found incorrect datagram, ignoring it. {}".format(pe)) | |||
@property | |||
def oscpath(self): | |||
"""Returns the OSC address regular expression.""" | |||
return self._address_regexp | |||
@staticmethod | |||
def dgram_is_message(dgram): | |||
"""Returns whether this datagram starts as an OSC message.""" | |||
return dgram.startswith(b'/') | |||
@property | |||
def size(self): | |||
"""Returns the length of the datagram for this message.""" | |||
return len(self._dgram) | |||
@property | |||
def dgram(self): | |||
"""Returns the datagram from which this message was built.""" | |||
return self._dgram | |||
@property | |||
def params(self): | |||
"""Convenience method for list(self) to get the list of parameters.""" | |||
return list(self) | |||
def __iter__(self): | |||
"""Returns an iterator over the parameters of this message.""" | |||
return iter(self._parameters) | |||
class _OutgoingMessage(object): | |||
def __init__(self, oscpath): | |||
self.LENGTH = 4 #32 bit | |||
self.oscpath = oscpath | |||
self._args = [] | |||
def write_string(self, val): | |||
dgram = val.encode('utf-8') | |||
diff = self.LENGTH - (len(dgram) % self.LENGTH) | |||
dgram += (b'\x00' * diff) | |||
return dgram | |||
def write_int(self, val): | |||
return struct.pack('>i', val) | |||
def write_float(self, val): | |||
return struct.pack('>f', val) | |||
def add_arg(self, argument): | |||
t = {str:"s", int:"i", float:"f"}[type(argument)] | |||
self._args.append((t, argument)) | |||
def build(self): | |||
dgram = b'' | |||
#OSC Path | |||
dgram += self.write_string(self.oscpath) | |||
if not self._args: | |||
dgram += self.write_string(',') | |||
return dgram | |||
# Write the parameters. | |||
arg_types = "".join([arg[0] for arg in self._args]) | |||
dgram += self.write_string(',' + arg_types) | |||
for arg_type, value in self._args: | |||
f = {"s":self.write_string, "i":self.write_int, "f":self.write_float}[arg_type] | |||
dgram += f(value) | |||
return dgram | |||
class NSMNotRunningError(Exception): | |||
"""Error raised when environment variable $NSM_URL was not found.""" | |||
class NSMClient(object): | |||
"""The representation of the host programs as NSM sees it. | |||
Technically consists of an udp server and a udp client. | |||
Does not run an event loop itself and depends on the host loop. | |||
E.g. a Qt timer or just a simple while True: sleep(0.1) in Python.""" | |||
def __init__(self, prettyName, supportsSaveStatus, saveCallback, openOrNewCallback, exitProgramCallback, hideGUICallback=None, showGUICallback=None, broadcastCallback=None, sessionIsLoadedCallback=None, loggingLevel = "info"): | |||
self.nsmOSCUrl = self.getNsmOSCUrl() #this fails and raises NSMNotRunningError if NSM is not available. Host programs can ignore it or exit their program. | |||
self.realClient = True | |||
self.cachedSaveStatus = None #save status checks for this. | |||
global logger | |||
logger = logging.getLogger(prettyName) | |||
logger.info("import") | |||
if loggingLevel == "info" or loggingLevel == 20: | |||
logging.basicConfig(level=logging.INFO) #development | |||
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 | |||
elif loggingLevel == "error" or loggingLevel == 40: | |||
logging.basicConfig(level=logging.ERROR) #production | |||
else: | |||
logging.warning("Unknown logging level: {}. Choose 'info' or 'error'".format(loggingLevel)) | |||
logging.basicConfig(level=logging.INFO) #development | |||
#given parameters, | |||
self.prettyName = prettyName #keep this consistent! Settle for one name. | |||
self.supportsSaveStatus = supportsSaveStatus | |||
self.saveCallback = saveCallback | |||
self.exitProgramCallback = exitProgramCallback | |||
self.openOrNewCallback = openOrNewCallback #The host needs to: Create a jack client with ourClientNameUnderNSM - Open the saved file and all its resources | |||
self.broadcastCallback = broadcastCallback | |||
self.hideGUICallback = hideGUICallback | |||
self.showGUICallback = showGUICallback | |||
self.sessionIsLoadedCallback = sessionIsLoadedCallback | |||
#Reactions get the raw _IncomingMessage OSC object | |||
#A client can add to reactions. | |||
self.reactions = { | |||
"/nsm/client/save" : self._saveCallback, | |||
"/nsm/client/show_optional_gui" : lambda msg: self.showGUICallback(), | |||
"/nsm/client/hide_optional_gui" : lambda msg: self.hideGUICallback(), | |||
"/nsm/client/session_is_loaded" : self._sessionIsLoadedCallback, | |||
#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. | |||
#broadcast is handled directly by the function because it has more parameters | |||
} | |||
#self.discardReactions = set(["/nsm/client/session_is_loaded"]) | |||
self.discardReactions = set() | |||
#Networking and Init | |||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #internet, udp | |||
self.sock.bind(('', 0)) #pick a free port on localhost. | |||
ip, port = self.sock.getsockname() | |||
self.ourOscUrl = f"osc.udp://{ip}:{port}/" | |||
self.executableName = self.getExecutableName() | |||
#UNIX Signals. Used for quit. | |||
signal(SIGTERM, self.sigtermHandler) #NSM sends only SIGTERM. #TODO: really? pynsm version 1 handled sigkill as well. | |||
signal(SIGINT, self.sigtermHandler) | |||
#The following instance parameters are all set in announceOurselves | |||
self.serverFeatures = None | |||
self.sessionName = None | |||
self.ourPath = None | |||
self.ourClientNameUnderNSM = None | |||
self.ourClientId = None # the "file extension" of ourClientNameUnderNSM | |||
self.isVisible = None #set in announceGuiVisibility | |||
self.saveStatus = True # true is clean. false means we need saving. | |||
self.announceOurselves() | |||
assert self.serverFeatures, self.serverFeatures | |||
assert self.sessionName, self.sessionName | |||
assert self.ourPath, self.ourPath | |||
assert self.ourClientNameUnderNSM, self.ourClientNameUnderNSM | |||
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. | |||
#After this point the host must include self.reactToMessage in its event loop | |||
#We assume we are save at startup. | |||
self.announceSaveStatus(isClean = True) | |||
logger.info("NSMClient client init complete. Going into listening mode.") | |||
def reactToMessage(self): | |||
"""This is the main loop message. It is added to the clients event loop.""" | |||
try: | |||
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. | |||
except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not. | |||
return None | |||
msg = _IncomingMessage(data) | |||
if msg.oscpath in self.reactions: | |||
self.reactions[msg.oscpath](msg) | |||
elif msg.oscpath in self.discardReactions: | |||
pass | |||
elif msg.oscpath == "/reply" and msg.params == ["/nsm/server/open", "Loaded."]: #NSM sends that all programs of the session were loaded. | |||
logger.info ("Got /reply Loaded from NSM Server") | |||
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 | |||
logger.info ("Got /reply Saved from NSM Server") | |||
elif msg.isBroadcast: | |||
if self.broadcastCallback: | |||
logger.info (f"Got broadcast with messagePath {msg.oscpath} and listOfArguments {msg.params}") | |||
self.broadcastCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM, msg.oscpath, msg.params) | |||
else: | |||
logger.info (f"No callback for broadcast! Got messagePath {msg.oscpath} and listOfArguments {msg.params}") | |||
elif msg.oscpath == "/error": | |||
logger.warning("Got /error from NSM Server. Path: {} , Parameter: {}".format(msg.oscpath, msg.params)) | |||
else: | |||
logger.warning("Reaction not implemented:. Path: {} , Parameter: {}".format(msg.oscpath, msg.params)) | |||
def send(self, path:str, listOfParameters:list, host=None, port=None): | |||
"""Send any osc message. Defaults to nsmd URL. | |||
Will not wait for an answer but return None.""" | |||
if host and port: | |||
url = (host, port) | |||
else: | |||
url = self.nsmOSCUrl | |||
msg = _OutgoingMessage(path) | |||
for arg in listOfParameters: | |||
msg.add_arg(arg) #type is auto-determined by outgoing message | |||
self.sock.sendto(msg.build(), url) | |||
def getNsmOSCUrl(self): | |||
"""Return and save the nsm osc url or raise an error""" | |||
nsmOSCUrl = getenv("NSM_URL") | |||
if not nsmOSCUrl: | |||
raise NSMNotRunningError("New-Session-Manager environment variable $NSM_URL not found.") | |||
else: | |||
#osc.udp://hostname:portnumber/ | |||
o = urlparse(nsmOSCUrl) | |||
return o.hostname, o.port | |||
def getExecutableName(self): | |||
"""Finding the actual executable name can be a bit hard | |||
in Python. NSM wants the real starting point, even if | |||
it was a bash script. | |||
""" | |||
#TODO: I really don't know how to find out the name of the bash script | |||
fullPath = argv[0] | |||
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. | |||
executableName = os.path.basename(fullPath) | |||
assert not "/" in executableName, executableName #see above. | |||
return executableName | |||
def announceOurselves(self): | |||
"""Say hello to NSM and tell it we are ready to receive | |||
instructions | |||
/nsm/server/announce s:application_name s:capabilities s:executable_name i:api_version_major i:api_version_minor i:pid""" | |||
def buildClientFeaturesString(): | |||
#:dirty:switch:progress: | |||
result = [] | |||
if self.supportsSaveStatus: | |||
result.append("dirty") | |||
if self.hideGUICallback and self.showGUICallback: | |||
result.append("optional-gui") | |||
if result: | |||
return ":".join([""] + result + [""]) | |||
else: | |||
return "" | |||
logger.info("Sending our NSM-announce message") | |||
announce = _OutgoingMessage("/nsm/server/announce") | |||
announce.add_arg(self.prettyName) #s:application_name | |||
announce.add_arg(buildClientFeaturesString()) #s:capabilities | |||
announce.add_arg(self.executableName) #s:executable_name | |||
announce.add_arg(1) #i:api_version_major | |||
announce.add_arg(2) #i:api_version_minor | |||
announce.add_arg(int(getpid())) #i:pid | |||
hostname, port = self.nsmOSCUrl | |||
assert hostname, self.nsmOSCUrl | |||
assert port, self.nsmOSCUrl | |||
self.sock.sendto(announce.build(), self.nsmOSCUrl) | |||
#Wait for /reply (aka 'Howdy, what took you so long?) | |||
data, addr = self.sock.recvfrom(1024) | |||
msg = _IncomingMessage(data) | |||
if msg.oscpath == "/error": | |||
originalMessage, errorCode, reason = msg.params | |||
logger.error("Code {}: {}".format(errorCode, reason)) | |||
quit() | |||
elif msg.oscpath == "/reply": | |||
nsmAnnouncePath, welcomeMessage, managerName, self.serverFeatures = msg.params | |||
assert nsmAnnouncePath == "/nsm/server/announce", nsmAnnouncePath | |||
logger.info("Got /reply " + welcomeMessage) | |||
#Wait for /nsm/client/open | |||
data, addr = self.sock.recvfrom(1024) | |||
msg = _IncomingMessage(data) | |||
assert msg.oscpath == "/nsm/client/open", msg.oscpath | |||
self.ourPath, self.sessionName, self.ourClientNameUnderNSM = msg.params | |||
self.ourClientId = os.path.splitext(self.ourClientNameUnderNSM)[1][1:] | |||
logger.info("Got '/nsm/client/open' from NSM. Telling our client to load or create a file with name {}".format(self.ourPath)) | |||
self.openOrNewCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM) #Host function to either load an existing session or create a new one. | |||
logger.info("Our client should be done loading or creating the file {}".format(self.ourPath)) | |||
replyToOpen = _OutgoingMessage("/reply") | |||
replyToOpen.add_arg("/nsm/client/open") | |||
replyToOpen.add_arg("{} is opened or created".format(self.prettyName)) | |||
self.sock.sendto(replyToOpen.build(), self.nsmOSCUrl) | |||
else: | |||
raise ValueError("Unexpected message path after announce: {}".format((msg.oscpath, msg.params))) | |||
def announceGuiVisibility(self, isVisible): | |||
message = "/nsm/client/gui_is_shown" if isVisible else "/nsm/client/gui_is_hidden" | |||
self.isVisible = isVisible | |||
guiVisibility = _OutgoingMessage(message) | |||
logger.info("Telling NSM that our clients switched GUI visibility to: {}".format(message)) | |||
self.sock.sendto(guiVisibility.build(), self.nsmOSCUrl) | |||
def announceSaveStatus(self, isClean): | |||
"""Only send to the NSM Server if there was really a change""" | |||
if not self.supportsSaveStatus: | |||
return | |||
if not isClean == self.cachedSaveStatus: | |||
message = "/nsm/client/is_clean" if isClean else "/nsm/client/is_dirty" | |||
self.cachedSaveStatus = isClean | |||
saveStatus = _OutgoingMessage(message) | |||
logger.info("Telling NSM that our clients save state is now: {}".format(message)) | |||
self.sock.sendto(saveStatus.build(), self.nsmOSCUrl) | |||
def _saveCallback(self, msg): | |||
logger.info("Telling our client to save as {}".format(self.ourPath)) | |||
self.saveCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM) | |||
replyToSave = _OutgoingMessage("/reply") | |||
replyToSave.add_arg("/nsm/client/save") | |||
replyToSave.add_arg("{} saved".format(self.prettyName)) | |||
self.sock.sendto(replyToSave.build(), self.nsmOSCUrl) | |||
#it is assumed that after saving the state is clear | |||
self.announceSaveStatus(isClean = True) | |||
def _sessionIsLoadedCallback(self, msg): | |||
if self.sessionIsLoadedCallback: | |||
logger.info("Received 'Session is Loaded'. Our client supports it. Forwarding message...") | |||
self.sessionIsLoadedCallback() | |||
else: | |||
logger.info("Received 'Session is Loaded'. Our client does not support it, which is the default. Discarding message...") | |||
def sigtermHandler(self, signal, frame): | |||
"""Wait for the user to quit the program | |||
The user function does not need to exit itself. | |||
Just shutdown audio engines etc. | |||
It is possible, that the client does not implement quit | |||
properly. In that case NSM protocol demands that we quit anyway. | |||
No excuses. | |||
Achtung GDB! If you run your program with | |||
gdb --args python foo.py | |||
the Python signal handler will not work. This has nothing to do with this library. | |||
""" | |||
logger.info("Telling our client to quit.") | |||
self.exitProgramCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM) | |||
#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. | |||
#If we reach this point we have reached the point of no return. Say goodbye. | |||
logger.warning("Client did not quit on its own. Sending SIGKILL.") | |||
kill(getpid(), SIGKILL) | |||
logger.error("SIGKILL did nothing. Do it manually.") | |||
def debugResetDataAndExit(self): | |||
"""This is solely meant for debugging and testing. The user way of action should be to | |||
remove the client from the session and add a new instance, which will get a different | |||
NSM-ID. | |||
Afterwards we perform a clean exit.""" | |||
logger.warning("debugResetDataAndExit will now delete {} and then request an exit.".format(self.ourPath)) | |||
if os.path.exists(self.ourPath): | |||
if os.path.isfile(self.ourPath): | |||
try: | |||
os.remove(self.ourPath) | |||
except Exception as e: | |||
logger.info(e) | |||
elif os.path.isdir(self.ourPath): | |||
try: | |||
shutil.rmtree(self.ourPath) | |||
except Exception as e: | |||
logger.info(e) | |||
else: | |||
logger.info("{} does not exist.".format(self.ourPath)) | |||
self.serverSendExitToSelf() | |||
def serverSendExitToSelf(self): | |||
"""If you want a very strict client you can block any non-NSM quit-attempts, like ignoring a | |||
qt closeEvent, and instead send the NSM Server a request to close this client. | |||
This method is a shortcut to do just that. | |||
""" | |||
logger.info("Sending SIGTERM to ourselves to trigger the exit callback.") | |||
#if "server-control" in self.serverFeatures: | |||
# message = _OutgoingMessage("/nsm/server/stop") | |||
# message.add_arg("{}".format(self.ourClientId)) | |||
# self.sock.sendto(message.build(), self.nsmOSCUrl) | |||
#else: | |||
kill(getpid(), SIGTERM) #this calls the exit callback | |||
def serverSendSaveToSelf(self): | |||
"""Some clients want to offer a manual Save function, mostly for psychological reasons. | |||
We offer a clean solution in calling this function which will trigger a round trip over the | |||
NSM server so our client thinks it received a Save instruction. This leads to a clean | |||
state with a good saveStatus and no required extra functionality in the client.""" | |||
logger.info("instructing the NSM-Server to send Save to ourselves.") | |||
if "server-control" in self.serverFeatures: | |||
#message = _OutgoingMessage("/nsm/server/save") # "Save All" Command. | |||
message = _OutgoingMessage("/nsm/gui/client/save") | |||
message.add_arg("{}".format(self.ourClientId)) | |||
self.sock.sendto(message.build(), self.nsmOSCUrl) | |||
else: | |||
logger.warning("...but the NSM-Server does not support server control. Server only supports: {}".format(self.serverFeatures)) | |||
def changeLabel(self, label:str): | |||
"""This function is implemented because it is provided by NSM. However, it does not much. | |||
The message gets received but is not saved. | |||
The official NSM GUI uses it but then does not save it. | |||
We would have to send it every startup ourselves. | |||
This is fine for us as clients, but you need to provide a GUI field to enter that label.""" | |||
logger.info("Telling the NSM-Server that our label is now " + label) | |||
message = _OutgoingMessage("/nsm/client/label") | |||
message.add_arg(label) #s:label | |||
self.sock.sendto(message.build(), self.nsmOSCUrl) | |||
def broadcast(self, path:str, arguments:list): | |||
"""/nsm/server/broadcast s:path [arguments...] | |||
We, as sender, will not receive the broadcast back. | |||
Broadcasts starting with /nsm are not allowed and will get discarded by the server | |||
""" | |||
if path.startswith("/nsm"): | |||
logger.warning("Attempted broadbast starting with /nsm. Not allwoed") | |||
else: | |||
logger.info("Sending broadcast " + path + repr(arguments)) | |||
message = _OutgoingMessage("/nsm/server/broadcast") | |||
message.add_arg(path) | |||
for arg in arguments: | |||
message.add_arg(arg) #type autodetect | |||
self.sock.sendto(message.build(), self.nsmOSCUrl) | |||
def importResource(self, filePath): | |||
"""aka. import into session | |||
ATTENTION! You will still receive an absolute path from this function. You need to make | |||
sure yourself that this path will not be saved in your save file, but rather use a place- | |||
holder that gets replaced by the actual session path each time. A good point is after | |||
serialisation. search&replace for the session prefix ("ourPath") and replace it with a tag | |||
e.g. <sessionDirectory>. The opposite during load. | |||
Only such a behaviour will make your session portable. | |||
Do not use the following pattern: An alternative that comes to mind is to only work with | |||
relative paths and force your programs workdir to the session directory. Better work with | |||
absolute paths internally . | |||
Symlinks given path into session dir and returns the linked path relative to the ourPath. | |||
It can handles single files as well as whole directories. | |||
if filePath is already a symlink we do not follow it. os.path.realpath or os.readlink will | |||
not be used. | |||
Multilayer links may indicate a users ordering system that depends on | |||
abstractions. e.g. with mounted drives under different names which get symlinked to a | |||
reliable path. | |||
Basically do not question the type of our input filePath. | |||
tar with the follow symlink option has os.path.realpath behaviour and therefore is able | |||
to follow multiple levels of links anyway. | |||
A hardlink does not count as a link and will be detected and treated as real file. | |||
Cleaning up a session directory is either responsibility of the user | |||
or of our client program. We do not provide any means to unlink or delete files from the | |||
session directory. | |||
""" | |||
#Even if the project was not saved yet now it is time to make our directory in the NSM dir. | |||
if not os.path.exists(self.ourPath): | |||
os.makedirs(self.ourPath) | |||
filePath = os.path.abspath(filePath) #includes normalisation | |||
if not os.path.exists(self.ourPath):raise FileNotFoundError(self.ourPath) | |||
if not os.path.isdir(self.ourPath): raise NotADirectoryError(self.ourPath) | |||
if not os.access(self.ourPath, os.W_OK): raise PermissionError("not writable", self.ourPath) | |||
if not os.path.exists(filePath):raise FileNotFoundError(filePath) | |||
if os.path.isdir(filePath): raise IsADirectoryError(filePath) | |||
if not os.access(filePath, os.R_OK): raise PermissionError("not readable", filePath) | |||
filePathInOurSession = os.path.commonprefix([filePath, self.ourPath]) == self.ourPath | |||
linkedPath = os.path.join(self.ourPath, os.path.basename(filePath)) | |||
linkedPathAlreadyExists = os.path.exists(linkedPath) | |||
if not os.access(os.path.dirname(linkedPath), os.W_OK): raise PermissionError("not writable", os.path.dirname(linkedPath)) | |||
if filePathInOurSession: | |||
#loadResource from our session dir. Portable session, manually copied beforehand or just loading a link again. | |||
linkedPath = filePath #we could return here, but we continue to get the tests below. | |||
logger.info(f"tried to import external resource {filePath} but this is already in our session directory. We use this file directly instead. ") | |||
elif linkedPathAlreadyExists and os.readlink(linkedPath) == filePath: | |||
#the imported file already exists as link in our session dir. We do not link it again but simply report the existing link. | |||
#We only check for the first target of the existing link and do not follow it through to a real file. | |||
#This way all user abstractions and file structures will be honored. | |||
linkedPath = linkedPath | |||
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} ") | |||
elif linkedPathAlreadyExists: | |||
#A new file shall be imported but it would create a linked name which already exists in our session dir. | |||
#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 | |||
firstpart, extension = os.path.splitext(linkedPath) | |||
uniqueLinkedPath = firstpart + "." + uuid4().hex + extension | |||
assert not os.path.exists(uniqueLinkedPath) | |||
os.symlink(filePath, uniqueLinkedPath) | |||
logger.info(self.ourClientNameUnderNSM + f":pysm2: tried to import external resource {filePath} but potential target link {linkedPath} already exists. Linked to {uniqueLinkedPath} instead.") | |||
linkedPath = uniqueLinkedPath | |||
else: #this is the "normal" case. External resources will be linked. | |||
assert not os.path.exists(linkedPath) | |||
os.symlink(filePath, linkedPath) | |||
logger.info(f"imported external resource {filePath} as link {linkedPath}") | |||
assert os.path.exists(linkedPath), linkedPath | |||
return linkedPath | |||
class NullClient(object): | |||
"""Use this as a drop-in replacement if your program has a mode without NSM but you don't want | |||
to change the code itself. | |||
This was originally written for programs that have a core-engine and normal mode of operations | |||
is a GUI with NSM but they also support commandline-scripts and batch processing. | |||
For these you don't want NSM.""" | |||
def __init__(self, *args, **kwargs): | |||
self.realClient = False | |||
self.ourClientNameUnderNSM = "NSM Null Client" | |||
def announceSaveStatus(self, *args): | |||
pass | |||
def announceGuiVisibility(self, *args): | |||
pass | |||
def reactToMessage(self): | |||
pass | |||
def importResource(self): | |||
return "" | |||
def serverSendExitToSelf(self): | |||
quit() |
@@ -0,0 +1,131 @@ | |||
#! /usr/bin/env python3 | |||
# -*- coding: utf-8 -*- | |||
""" | |||
PyNSMClient - A New Session Manager Client-Library in one file. | |||
The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/ | |||
New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager | |||
With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 ) | |||
MIT License | |||
Copyright (c) since 2014: Laborejo Software Suite <info@laborejo.org>, All rights reserved. | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and | |||
associated documentation files (the "Software"), to deal in the Software without restriction, | |||
including without limitation the rights to use, copy, modify, merge, publish, distribute, | |||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all copies or | |||
substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT | |||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | |||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT | |||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||
""" | |||
#Test file for the resource import function in nsmclient.py | |||
if __name__ == "__main__": | |||
from nsmclient import NSMClient | |||
#We do not start nsmclient, just the the function with temporary files | |||
importResource = NSMClient.importResource | |||
import os, os.path | |||
import logging | |||
logging.basicConfig(level=logging.INFO) | |||
from inspect import currentframe | |||
def get_linenumber(): | |||
cf = currentframe() | |||
return cf.f_back.f_lineno | |||
from tempfile import mkdtemp | |||
class self(object): | |||
ourPath = mkdtemp() | |||
ourClientNameUnderNSM = "Loader Test" | |||
assert os.path.isdir(self.ourPath) | |||
#First a meta test to see if our system is working: | |||
assert os.path.exists("/etc/hostname") | |||
try: | |||
result = importResource(self, "/etc/hostname") #should not fail! | |||
except FileNotFoundError: | |||
pass | |||
else: | |||
print (f"Meta Test System works as of line {get_linenumber()}") | |||
print ("""You should not see any "Test Error" messages""") | |||
print ("Working in", self.ourPath) | |||
print (f"Removing {result} for a clean test environment") | |||
os.remove(result) | |||
print() | |||
#Real tests | |||
try: | |||
importResource(self, "/floot/nonexistent") #should fail | |||
except FileNotFoundError: | |||
pass | |||
else: | |||
print (f"Test Error in line {get_linenumber()}") | |||
try: | |||
importResource(self, "////floot//nonexistent/") #should fail | |||
except FileNotFoundError: | |||
pass | |||
else: | |||
print (f"Test Error in line {get_linenumber()}") | |||
try: | |||
importResource(self, "/etc/shadow") #reading not possible | |||
except PermissionError: | |||
pass | |||
else: | |||
print (f"Test Error in line {get_linenumber()}") | |||
assert os.path.exists("/etc/hostname") | |||
try: | |||
org = self.ourPath | |||
self.ourPath = "/" #writing not possible | |||
importResource(self, "/etc/hostname") | |||
except PermissionError: | |||
self.ourPath = org | |||
else: | |||
print (f"Test Error in line {get_linenumber()}") | |||
from tempfile import NamedTemporaryFile | |||
tmpf = NamedTemporaryFile() | |||
assert os.path.exists("/etc/hostname") | |||
try: | |||
org = self.ourPath | |||
self.ourPath = tmpf.name #writable, but not a dir | |||
importResource(self, "/etc/hostname") | |||
except NotADirectoryError: | |||
self.ourPath = org | |||
else: | |||
print (f"Test Error in line {get_linenumber()}") | |||
#Test the real purpose | |||
result = importResource(self, "/etc/hostname") | |||
print ("imported to", result) | |||
#Test what happens if we try to import already imported resource again | |||
result = importResource(self, result) | |||
print ("imported to", result) | |||
#Test what happens if we try to import a resource that would result in a name collision | |||
result = importResource(self, "/etc/hostname") | |||
print ("imported to", result) | |||
#Count the number of resulting files. | |||
assert len(os.listdir(self.ourPath)) == 2 | |||