1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3
4"""Automate Audacity via mod-script-pipe.
5
6Pipe Client may be used as a command-line script to send commands to
7Audacity via the mod-script-pipe interface, or loaded as a module.
8Requires Python 2.7 or later. Python 3 strongly recommended.
9
10======================
11Command Line Interface
12======================
13
14    usage: pipeclient.py [-h] [-t] [-s ] [-d]
15
16Arguments
17---------
18    -h,--help: optional
19        show short help and exit
20    -t, --timeout: float, optional
21        timeout for reply in seconds (default: 10)
22    -s, --show-time: bool, optional
23        show command execution time (default: True)
24    -d, --docs: optional
25        show this documentation and exit
26
27Example
28-------
29    $ python3 pipeclient.py -t 20 -s False
30
31    Launches command line interface with 20 second time-out for
32    returned message, and don't show the execution time.
33
34    When prompted, enter the command to send (not quoted), or 'Q' to quit.
35
36    $ Enter command or 'Q' to quit: GetInfo: Type=Tracks Format=LISP
37
38============
39Module Usage
40============
41
42Note that on a critical error (such as broken pipe), the module just exits.
43If a more graceful shutdown is required, replace the sys.exit()'s with
44exceptions.
45
46Example
47-------
48
49    # Import the module:
50    >>> import pipeclient
51
52    # Create a client instance:
53    >>> client = pipeclient.PipeClient()
54
55    # Send a command:
56    >>> client.write("Command", timer=True)
57
58    # Read the last reply:
59    >>> print(client.read())
60
61See Also
62--------
63PipeClient.write : Write a command to _write_pipe.
64PipeClient.read : Read Audacity's reply from pipe.
65
66Copyright Steve Daulton 2018
67Released under terms of the GNU General Public License version 2:
68<http://www.gnu.org/licenses/old-licenses/gpl-2.0.html />
69
70"""
71
72import os
73import sys
74import threading
75import time
76import errno
77import argparse
78
79
80if sys.version_info[0] < 3 and sys.version_info[1] < 7:
81    sys.exit('PipeClient Error: Python 2.7 or later required')
82
83# Platform specific constants
84if sys.platform == 'win32':
85    WRITE_NAME = '\\\\.\\pipe\\ToSrvPipe'
86    READ_NAME = '\\\\.\\pipe\\FromSrvPipe'
87    EOL = '\r\n\0'
88else:
89    # Linux or Mac
90    PIPE_BASE = '/tmp/audacity_script_pipe.'
91    WRITE_NAME = PIPE_BASE + 'to.' + str(os.getuid())
92    READ_NAME = PIPE_BASE + 'from.' + str(os.getuid())
93    EOL = '\n'
94
95
96class PipeClient():
97    """Write / read client access to Audacity via named pipes.
98
99    Normally there should be just one instance of this class. If
100    more instances are created, they all share the same state.
101
102    __init__ calls _write_thread_start() and _read_thread_start() on
103    first instantiation.
104
105    Parameters
106    ----------
107        None
108
109    Attributes
110    ----------
111        reader_pipe_broken : event object
112            Set if pipe reader fails. Audacity may have crashed
113        reply_ready : event object
114            flag cleared when command sent and set when response received
115        timer : bool
116            When true, time the command execution (default False)
117        reply : string
118            message received when Audacity completes the command
119
120    See Also
121    --------
122    write : Write a command to _write_pipe.
123    read : Read Audacity's reply from pipe.
124
125    """
126
127    reader_pipe_broken = threading.Event()
128    reply_ready = threading.Event()
129
130    _shared_state = {}
131
132    def __new__(cls, *p, **k):
133        self = object.__new__(cls, *p, **k)
134        self.__dict__ = cls._shared_state
135        return self
136
137    def __init__(self):
138        self.timer = False
139        self._start_time = 0
140        self._write_pipe = None
141        self.reply = ''
142        if not self._write_pipe:
143            self._write_thread_start()
144        self._read_thread_start()
145
146    def _write_thread_start(self):
147        """Start _write_pipe thread"""
148        # Pipe is opened in a new thread so that we don't
149        # freeze if Audacity is not running.
150        write_thread = threading.Thread(target=self._write_pipe_open)
151        write_thread.daemon = True
152        write_thread.start()
153        # Allow a little time for connection to be made.
154        time.sleep(0.1)
155        if not self._write_pipe:
156            sys.exit('PipeClientError: Write pipe cannot be opened.')
157
158    def _write_pipe_open(self):
159        """Open _write_pipe."""
160        self._write_pipe = open(WRITE_NAME, 'w')
161
162    def _read_thread_start(self):
163        """Start read_pipe thread."""
164        read_thread = threading.Thread(target=self._reader)
165        read_thread.daemon = True
166        read_thread.start()
167
168    def write(self, command, timer=False):
169        """Write a command to _write_pipe.
170
171        Parameters
172        ----------
173            command : string
174                The command to send to Audacity
175            timer : bool, optional
176                If true, time the execution of the command
177
178        Example
179        -------
180            write("GetInfo: Type=Labels", timer=True):
181
182        """
183        self.timer = timer
184        print('Sending command:', command)
185        self._write_pipe.write(command + EOL)
186        # Check that read pipe is alive
187        if PipeClient.reader_pipe_broken.isSet():
188            sys.exit('PipeClient: Read-pipe error.')
189        try:
190            self._write_pipe.flush()
191            if self.timer:
192                self._start_time = time.time()
193            self.reply = ''
194            PipeClient.reply_ready.clear()
195        except IOError as err:
196            if err.errno == errno.EPIPE:
197                sys.exit('PipeClient: Write-pipe error.')
198            else:
199                raise
200
201    def _reader(self):
202        """Read FIFO in worker thread."""
203        # Thread will wait at this read until it connects.
204        # Connection should occur as soon as _write_pipe has connected.
205        read_pipe = open(READ_NAME, 'r')
206        message = ''
207        pipe_ok = True
208        while pipe_ok:
209            line = read_pipe.readline()
210            # Stop timer as soon as we get first line of response.
211            stop_time = time.time()
212            while pipe_ok and line != '\n':
213                message += line
214                line = read_pipe.readline()
215                if line == '':
216                    # No data in read_pipe indicates that the pipe is broken
217                    # (Audacity may have crashed).
218                    PipeClient.reader_pipe_broken.set()
219                    pipe_ok = False
220            if self.timer:
221                xtime = (stop_time - self._start_time) * 1000
222                message += 'Execution time: {0:.2f}ms'.format(xtime)
223            self.reply = message
224            PipeClient.reply_ready.set()
225            message = ''
226        read_pipe.close()
227
228    def read(self):
229        """Read Audacity's reply from pipe.
230
231        Returns
232        -------
233        string
234            The reply from the last command sent to Audacity, or null string
235            if reply not received. Null string usually indicates that Audacity
236            is still processing the last command.
237
238        """
239        if not PipeClient.reply_ready.isSet():
240            return ''
241        return self.reply
242
243
244def bool_from_string(strval):
245    """Return boolean value from string"""
246    if strval.lower() in ('true', 't', '1', 'yes', 'y'):
247        return True
248    if strval.lower() in ('false', 'f', '0', 'no', 'n'):
249        return False
250    raise argparse.ArgumentTypeError('Boolean value expected.')
251
252
253def main():
254    """Interactive command-line for PipeClient"""
255
256    parser = argparse.ArgumentParser()
257    parser.add_argument('-t', '--timeout', type=float, metavar='', default=10,
258                        help="timeout for reply in seconds (default: 10")
259    parser.add_argument('-s', '--show-time', metavar='True/False',
260                        nargs='?', type=bool_from_string,
261                        const='t', default='t', dest='show',
262                        help='show command execution time (default: True)')
263    parser.add_argument('-d', '--docs', action='store_true',
264                        help='show documentation and exit')
265    args = parser.parse_args()
266
267    if args.docs:
268        print(__doc__)
269        sys.exit(0)
270
271    client = PipeClient()
272    while True:
273        reply = ''
274        if sys.version_info[0] < 3:
275            message = raw_input("\nEnter command or 'Q' to quit: ")
276        else:
277            message = input("\nEnter command or 'Q' to quit: ")
278        start = time.time()
279        if message.upper() == 'Q':
280            sys.exit(0)
281        elif message == '':
282            pass
283        else:
284            client.write(message, timer=args.show)
285            while reply == '':
286                time.sleep(0.1)  # allow time for reply
287                if time.time() - start > args.timeout:
288                    reply = 'PipeClient: Reply timed-out.'
289                else:
290                    reply = client.read()
291            print(reply)
292
293
294if __name__ == '__main__':
295    main()
296