1# -*- coding: utf-8 -*-
2# ------------------------------------------------------------------------------
3# Name:         configure.py
4# Purpose:      Installation and Configuration Utilities
5#
6# Authors:      Christopher Ariza
7#
8# Copyright:    Copyright © 2011-2019 Michael Scott Cuthbert and the music21 Project
9# License:      BSD, see license.txt
10# ------------------------------------------------------------------------------
11import os
12import pathlib
13import re
14import time
15import sys
16import unittest
17import textwrap
18import webbrowser
19
20from typing import List
21
22from importlib import reload  # Python 3.4
23
24import io
25
26# assume that we will manually add this dir to sys.path top get access to
27# all modules before installation
28from music21 import common
29from music21 import environment
30from music21 import exceptions21
31
32_MOD = 'configure'
33environLocal = environment.Environment(_MOD)
34
35_DOC_IGNORE_MODULE_OR_PACKAGE = True
36IGNORECASE = re.RegexFlag.IGNORECASE
37
38# ------------------------------------------------------------------------------
39# match finale name, which may be directory or something else
40reFinaleApp = re.compile(r'Finale.*\.app', IGNORECASE)
41reSibeliusApp = re.compile(r'Sibelius\.app', IGNORECASE)
42reFinaleExe = re.compile(r'Finale.*\.exe', IGNORECASE)
43reSibeliusExe = re.compile(r'Sibelius\.exe', IGNORECASE)
44reFinaleReaderApp = re.compile(r'Finale Reader\.app', IGNORECASE)
45reMuseScoreApp = re.compile(r'MuseScore.*\.app', IGNORECASE)
46reMuseScoreExe = re.compile(r'Musescore.*\\bin\\MuseScore.*\.exe', IGNORECASE)
47
48urlMusic21 = 'https://web.mit.edu/music21'
49urlMuseScore = 'http://musescore.org'
50urlGettingStarted = 'https://web.mit.edu/music21/doc/'
51urlMusic21List = 'https://groups.google.com/g/music21list'
52
53LINE_WIDTH = 78
54
55# ------------------------------------------------------------------------------
56# class Action(threading.Thread):
57#     '''
58#     A thread-based action for performing remote actions, like downloading
59#     or opening in a webbrowser.
60#     '''
61#     def __init__ (self, prompt, timeOutTime):
62#         super().__init__()
63#         self.status = None
64#
65#     def run(self):
66#         pass
67
68
69# ------------------------------------------------------------------------------
70
71
72def writeToUser(msg, wrapLines=True, linesPerPage=20):
73    '''
74    Display a message to the user, handling multiple lines as necessary and wrapping text
75    '''
76    # wrap everything to 60 lines
77    if common.isListLike(msg):
78        lines = msg
79    else:
80        # divide into lines if lines breaks are already in place
81        lines = msg.split('\n')
82    # print(lines)
83    post = []
84    if wrapLines:
85        for sub in lines:
86            if sub == '':
87                post.append('')
88            elif sub == ' ':
89                post.append(' ')
90            else:
91                # concatenate lines
92                post += textwrap.wrap(sub, LINE_WIDTH)
93    else:
94        post = lines
95
96    # print(post)
97    lineCount = 0
98    for i, line in enumerate(post):
99        if line == '':  # treat an empty line as a break
100            line = '\n'
101        # if first and there is more than one line
102        elif i == 0 and len(post) > 1:
103            # add a leading space
104            line = f'\n{line} \n'
105        # if only one line
106        elif i == 0 and len(post) == 1:
107            line = f'\n{line} '
108        elif i < len(post) - 1:  # if not last
109            line = f'{line} \n'
110        else:  # if last, add trailing space, do not add trailing return
111            line = f'{line} '
112        if lineCount > 0 and lineCount % linesPerPage == 0:
113            # ask user to continue
114            d = AnyKey(promptHeader='Pausing for page.')
115            d.askUser()
116        sys.stdout.write(line)
117        sys.stdout.flush()
118        lineCount += 1
119
120
121def getSitePackages():
122    import distutils.sysconfig
123    return distutils.sysconfig.get_python_lib()
124
125
126def findInstallations():
127    '''
128    Find all music21 references found in site packages, or
129    possibly look at the running code as well.
130    '''
131    found = []
132    sitePackages = getSitePackages()
133    for fn in sorted(os.listdir(sitePackages)):
134        if fn.startswith('music21'):
135            found.append(os.path.join(sitePackages, fn))
136    try:
137        # see if we can import music21
138        import music21  # pylint: disable=redefined-outer-name
139        found.append(music21.__path__[0])  # list, get first item
140    except ImportError:
141        pass
142    return found
143
144
145def findInstallationsEggInfo():
146    '''
147    Find all music21 eggs found in site packages, or possibly look
148    at the running code as well.
149    '''
150    found = findInstallations()
151    # only get those that end w/ egg-info
152    post = []
153    for fp in found:
154        unused_dir, fn = os.path.split(fp)
155        if fn.endswith('egg-info') or fn.endswith('egg'):
156            post.append(fn)
157    return post
158
159
160def findInstallationsEggInfoStr():
161    '''
162    Return a string presentation, or the string None
163    '''
164    found = findInstallationsEggInfo()
165    if not found:
166        return 'None'
167    else:
168        return ','.join(found)
169
170
171def getUserData():
172    '''
173    Return a dictionary with user data
174    '''
175    post = {}
176    try:
177        import music21  # pylint: disable=redefined-outer-name
178        post['music21.version'] = music21.VERSION_STR
179    except ImportError:
180        post['music21.version'] = 'None'
181
182    post['music21 egg-info current'] = findInstallationsEggInfoStr()
183
184    if hasattr(os, 'uname'):
185        uname = os.uname()
186        post['os.uname'] = f'{uname[0]}, {uname[2]}, {uname[4]}'
187    else:  # catch all
188        post['os.uname'] = 'None'
189
190    post['time.gmtime'] = time.strftime('%a, %d %b %Y %H:%M:%S', time.gmtime())
191    post['time.timezone'] = time.timezone
192
193    tzname = time.tzname
194    if len(tzname) == 2 and tzname[1] not in [None, 'None', '']:
195        post['time.tzname'] = tzname[1]
196    else:
197        post['time.tzname'] = tzname[0]
198    return post
199
200
201def _crawlPathUpward(start, target):
202    '''
203    Ascend up paths given a start; return when target file has been found.
204    '''
205    lastDir = start
206    thisDir = lastDir
207    match = None
208    # first, ascend upward
209    while True:
210        environLocal.printDebug(f'at dir: {thisDir}')
211        if match is not None:
212            break
213        for fn in sorted(os.listdir(thisDir)):
214            if fn == target:
215                match = os.path.join(thisDir, fn)
216                break
217        lastDir = thisDir
218        thisDir, junk = os.path.split(thisDir)
219        if thisDir == lastDir:  # at top level
220            break
221    return match
222
223
224def findSetup():
225    '''
226    Find the setup.py script and returns the path to the setup.py file.
227    '''
228    # find setup.py
229    # look in current directory and ascending
230    match = _crawlPathUpward(start=os.getcwd(), target='setup.py')
231    # if no match, search downward if music21 is in this directory
232    if match is None:
233        if 'music21' in os.listdir(os.getcwd()):
234            sub = os.path.join(os.getcwd(), 'music21')
235            if 'setup.py' in os.listdir(sub):
236                match = os.path.join(sub, 'setup.py')
237
238    # if still not found, try to get from importing music21.
239    # this may not be correct, as this might be a previous music21 installation
240    # if match is None:
241    #     try:
242    #         import music21
243    #         fpMusic21 = music21.__path__[0]  # list, get first item
244    #     except ImportError:
245    #         fpMusic21 = None
246    #     if fpMusic21 is not None:
247    #         match = _crawlPathUpward(start=fpMusic21, target='setup.py')
248
249    environLocal.printDebug([f'found setup.py: {match}'])
250    return match
251
252
253# ------------------------------------------------------------------------------
254# error objects, not exceptions
255class DialogError:
256    '''
257    DialogError is a normal object, not an Exception.
258    '''
259    def __init__(self, src=None):
260        self.src = src
261
262    def __repr__(self):
263        return f'<music21.configure.{self.__class__.__name__}: {self.src}>'
264
265
266class KeyInterruptError(DialogError):
267    '''
268    Subclass of DialogError that deals with Keyboard Interruptions.
269    '''
270
271    def __init__(self, src=None):
272        super().__init__(src=src)
273
274
275class IncompleteInput(DialogError):
276    '''
277    Subclass of DialogError that runs when the user has provided
278    incomplete input that cannot be understood.
279    '''
280
281    def __init__(self, src=None):
282        super().__init__(src=src)
283
284
285class NoInput(DialogError):
286    '''
287    Subclass of DialogError for when the user has provided no input, and there is not a default.
288    '''
289
290    def __init__(self, src=None):
291        super().__init__(src=src)
292
293
294class BadConditions(DialogError):
295    '''
296    Subclass of DialogError for when the user's system does support the
297    action of the dialog: something is missing or
298    otherwise prohibits operation.
299    '''
300
301    def __init__(self, src=None):
302        super().__init__(src=src)
303
304
305# ------------------------------------------------------------------------------
306class DialogException(exceptions21.Music21Exception, DialogError):
307    pass
308
309# ------------------------------------------------------------------------------
310
311
312class Dialog:
313    '''
314    Model a dialog as a question and response. Have different subclasses for
315    different types of questions. Store all in a Conversation, or multiple dialog passes.
316
317    A `default`, if provided, is returned if the users provides no input and just enters return.
318
319    The `tryAgain` option determines if, if a user provides incomplete or no response,
320    and there is no default (for no response), whether the user is given another chance
321    to provide valid input.
322
323    The `promptHeader` is a string header that is placed in front of any common header
324    for this dialog.
325    '''
326
327    def __init__(self, default=None, tryAgain=True, promptHeader=None):
328        # store the result obtained from the user
329        self._result = None
330        # store a previously entered value, permitting undoing an action
331        self._resultPrior = None
332        # set the default
333        # parse the default to permit expressive flexibility
334        defaultCooked = self._parseUserInput(default)
335        # if not any class of error:
336        # environLocal.printDebug(['Dialog: defaultCooked:', defaultCooked])
337
338        if not isinstance(defaultCooked, DialogError):
339            self._default = defaultCooked
340        else:
341            # default is None by default; this cannot be a default value then
342            self._default = None
343        # if we try again
344        self._tryAgain = tryAgain
345        self._promptHeader = promptHeader
346
347        # how many times to ask the user again and again for the same thing
348        self._maxAttempts = 8
349
350        # set platforms this dialog should run in
351        self._platforms = ['win', 'darwin', 'nix']
352
353    def _writeToUser(self, msg):
354        '''Write output to user. Call module-level function
355        '''
356        writeToUser(msg)
357
358    def _readFromUser(self):
359        '''Collect from user; return None if an empty response.
360        '''
361        # noinspection PyBroadException
362        try:
363            post = input()
364            return post
365        except KeyboardInterrupt:
366            # store as own class so as a subclass of dialog error
367            return KeyInterruptError()
368        except Exception:  # pylint: disable=broad-except
369            return DialogError()
370
371    def prependPromptHeader(self, msg):
372        '''Add a message to the front of the stored prompt header.
373
374
375        >>> d = configure.Dialog()
376        >>> d.prependPromptHeader('test')
377        >>> d._promptHeader
378        'test'
379
380        >>> d = configure.Dialog(promptHeader='this is it')
381        >>> d.prependPromptHeader('test')
382        >>> d._promptHeader
383        'test this is it'
384        '''
385        msg = msg.strip()
386        if self._promptHeader is not None:
387            self._promptHeader = f'{msg} {self._promptHeader}'
388        else:
389            self._promptHeader = msg
390
391    def appendPromptHeader(self, msg):
392        '''
393
394        >>> d = configure.Dialog()
395        >>> d.appendPromptHeader('test')
396        >>> d._promptHeader
397        'test'
398
399        >>> d = configure.Dialog(promptHeader='this is it')
400        >>> d.appendPromptHeader('test')
401        >>> d._promptHeader
402        'this is it test'
403        '''
404        msg = msg.strip()
405        if self._promptHeader is not None:
406            self._promptHeader = f'{self._promptHeader} {msg}'
407        else:
408            self._promptHeader = msg
409
410    def _askTryAgain(self, default=True, force=None):
411        '''
412        What to do if input is incomplete
413
414        >>> prompt = configure.YesOrNo(default=True)
415        >>> prompt._askTryAgain(force='yes')
416        True
417        >>> prompt._askTryAgain(force='n')
418        False
419        >>> prompt._askTryAgain(force='')  # gets default
420        True
421        >>> prompt._askTryAgain(force='blah')  # error gets false
422        False
423        '''
424        # need to call a yes or no on using default
425        d = YesOrNo(default=default, tryAgain=False,
426                    promptHeader='Your input was not understood. Try Again?')
427        d.askUser(force=force)
428        post = d.getResult()
429        # if any errors are found, return False
430        if isinstance(post, DialogError):
431            return False
432        else:
433            return post
434
435    def _rawQueryPrepareHeader(self, msg=''):
436        '''Prepare the header, given a string.
437
438        >>> from music21 import configure
439        >>> d = configure.Dialog()
440        >>> d._rawQueryPrepareHeader('test')
441        'test'
442        >>> d = configure.Dialog(promptHeader='what are you doing?')
443        >>> d._rawQueryPrepareHeader('test')
444        'what are you doing? test'
445        '''
446        if self._promptHeader is not None:
447            header = self._promptHeader.strip()
448            if header.endswith('?') or header.endswith('.'):
449                div = ''
450            else:
451                div = ':'
452
453            if self._promptHeader.endswith('\n\n'):
454                div += '\n\n'
455            elif self._promptHeader.endswith('\n'):
456                div += '\n'
457            else:
458                div += ' '
459            msg = f'{header}{div}{msg}'
460        return msg
461
462    def _rawQueryPrepareFooter(self, msg=''):
463        '''Prepare the end of the query message
464        '''
465        if self._default is not None:
466            msg = msg.strip()
467            if msg.endswith(':'):
468                div = ':'
469                msg = msg[:-1]
470                msg.strip()
471            else:
472                div = ''
473            default = self._formatResultForUser(self._default)
474            # leave a space at end
475            msg = f'{msg} (default is {default}){div} '
476        return msg
477
478    def _rawIntroduction(self):
479        '''Return a multiline presentation of an introduction.
480        '''
481        return None
482
483    def _rawQuery(self):
484        '''Return a multiline presentation of the question.
485        '''
486        pass
487
488    def _formatResultForUser(self, result):
489        '''
490        For various result options, we may need to at times convert the internal
491        representation of the result into something else. For example, we might present
492        the user with 'Yes' or 'No' but store the result as True or False.
493        '''
494        # override in subclass
495        return result
496
497    def _parseUserInput(self, raw):
498        '''
499        Translate string to desired output. Pass None through
500        (as no input), convert '' to None, and pass all other
501        outputs as IncompleteInput objects.
502        '''
503        return raw
504
505    def _evaluateUserInput(self, raw):
506        '''
507        Evaluate the user's string entry after parsing; do not return None:
508        either return a valid response, default if available, or IncompleteInput object.
509        '''
510        pass
511        # define in subclass
512
513    def _preAskUser(self, force=None):
514        '''
515        Call this method immediately before calling askUser.
516        Can be used for configuration getting additional information.
517        '''
518        pass
519        # define in subclass
520
521    def askUser(self, force=None, *, skipIntro=False):
522        '''
523        Ask the user, display the query. The force argument can
524        be provided to test. Sets self._result; does not return a value.
525        '''
526        # if an introduction is defined, try to use it
527        intro = self._rawIntroduction()  # pylint: disable=assignment-from-none
528        if intro is not None and not skipIntro:
529            self._writeToUser(intro)
530
531        # always call preAskUser: can customize in subclass. must return True
532        # or False. if False, askUser cannot continue
533        post = self._preAskUser(force=force)  # pylint: disable=assignment-from-no-return
534        if post is False:
535            self._result = BadConditions()
536            return
537
538        # ten attempts; not using a while so will ultimately break
539        for i in range(self._maxAttempts):
540            # in some cases, the query might not be able to be formed:
541            # for example, in selecting values from a list, and not having
542            # any values. thus, query may be an error
543            query = self._rawQuery()  # pylint: disable=assignment-from-no-return
544            if isinstance(query, DialogError):
545                # set result as error
546                self._result = query
547                break
548
549            if force is None:
550                self._writeToUser(query)
551                rawInput = self._readFromUser()
552            else:
553                environLocal.printDebug(['writeToUser:', query])
554                rawInput = force
555
556            # rawInput here could be an error or a value
557            # environLocal.printDebug(['received as rawInput', rawInput])
558            # check for errors and handle
559            if isinstance(rawInput, KeyInterruptError):
560                # set as result KeyInterruptError
561                self._result = rawInput
562                break
563
564            # need to not catch no NoInput nor IncompleteInput classes, as they
565            # will be handled in evaluation
566            # pylint: disable=assignment-from-no-return
567            cookedInput = self._evaluateUserInput(rawInput)
568            # environLocal.printDebug(['post _evaluateUserInput() cookedInput', cookedInput])
569
570            # if no default and no input, we get here (default supplied in
571            # evaluate
572            if isinstance(cookedInput, (NoInput, IncompleteInput)):
573                # set result to these objects whether or not try again
574                self._result = cookedInput
575                if self._tryAgain:
576                    # only returns True or False
577                    if self._askTryAgain():
578                        pass
579                    else:  # this will keep whatever the cooked was
580                        break
581                else:
582                    break
583            else:
584                # should be in proper format after evaluation
585                self._result = cookedInput
586                break
587        # self._result may still be None
588
589    def getResult(self, simulate=True):
590        '''
591        Return the result, or None if not set. This may also do a
592        processing routine that is part of the desired result.
593        '''
594        return self._result
595
596    def _performAction(self, simulate=False):
597        '''
598        does nothing; redefine in subclass
599        '''
600        pass
601
602    def performAction(self, simulate=False):
603        '''
604        After getting a result, the query might require an action
605        to be performed. If result is None, this will use whatever
606        value is found in _result.
607
608        If simulate is True, no action will be taken.
609        '''
610        dummy = self.getResult()
611        if isinstance(self._result, DialogError):
612            environLocal.printDebug(
613                f'performAction() called, but result is an error: {self._result}')
614            self._writeToUser(['No action taken.', ' '])
615
616        elif simulate:  # do not operate
617            environLocal.printDebug(
618                f'performAction() called, but in simulation mode: {self._result}')
619        else:
620            try:
621                self._performAction(simulate=simulate)
622            except DialogException:  # pylint: disable=catching-non-exception
623                # in some cases, the action selected requires exciting the
624                # configuration assistant
625                # pylint: disable=raising-non-exception,raise-missing-from
626                raise DialogException('perform action raised a dialog exception')
627
628
629# ------------------------------------------------------------------------------
630class AnyKey(Dialog):
631    '''
632    Press any key to continue
633    '''
634
635    def __init__(self, default=None, tryAgain=False, promptHeader=None):
636        super().__init__(default=default, tryAgain=tryAgain, promptHeader=promptHeader)
637
638    def _rawQuery(self):
639        '''
640        Return a multiline presentation of the question.
641        '''
642        msg = 'Press return to continue.'
643        msg = self._rawQueryPrepareHeader(msg)
644        # footer provides default; here, ignore
645        # msg = self._rawQueryPrepareFooter(msg)
646        return msg
647
648    def _parseUserInput(self, raw):
649        '''
650        Always returns True
651        '''
652        return True
653
654
655# ------------------------------------------------------------------------------
656class YesOrNo(Dialog):
657    '''
658    Ask a yes or no question.
659
660    >>> d = configure.YesOrNo(default=True)
661    >>> d.askUser('yes')  # force arg for testing
662    >>> d.getResult()
663    True
664
665    >>> d = configure.YesOrNo(tryAgain=False)
666    >>> d.askUser('junk')  # force arg for testing
667    >>> d.getResult()
668     <music21.configure.IncompleteInput: junk>
669    '''
670
671    def __init__(self, default=None, tryAgain=True, promptHeader=None):
672        super().__init__(default=default, tryAgain=tryAgain, promptHeader=promptHeader)
673
674    def _formatResultForUser(self, result):
675        '''
676        For various result options, we may need to at times convert
677        the internal representation of the result into something else.
678        For example, we might present the user with 'Yes' or 'No' but
679        store the result as True or False.
680        '''
681        if result is True:
682            return 'Yes'
683        elif result is False:
684            return 'No'
685        # while a result might be an error object, this method should probably
686        # never be called with such objects.
687        else:
688            raise DialogException(f'attempting to format result for user: {result}')
689
690    def _rawQuery(self):
691        '''
692        Return a multiline presentation of the question.
693
694        >>> d = configure.YesOrNo(default=True)
695        >>> d._rawQuery()
696        'Enter Yes or No (default is Yes): '
697        >>> d = configure.YesOrNo(default=False)
698        >>> d._rawQuery()
699        'Enter Yes or No (default is No): '
700
701        >>> d = configure.YesOrNo(default=True, promptHeader='Would you like more time?')
702        >>> d._rawQuery()
703        'Would you like more time? Enter Yes or No (default is Yes): '
704        '''
705        msg = 'Enter Yes or No: '
706        msg = self._rawQueryPrepareHeader(msg)
707        msg = self._rawQueryPrepareFooter(msg)
708        return msg
709
710    def _parseUserInput(self, raw):
711        '''
712        Translate string to desired output. Pass None and '' (as no input), as
713        NoInput objects, and pass all other outputs as IncompleteInput objects.
714
715        >>> d = configure.YesOrNo()
716        >>> d._parseUserInput('y')
717        True
718        >>> d._parseUserInput('')
719        <music21.configure.NoInput: None>
720        >>> d._parseUserInput('asdf')
721        <music21.configure.IncompleteInput: asdf>
722        '''
723        if raw is None:
724            return NoInput()
725        # string;
726        raw = str(raw)
727        raw = raw.strip()
728        raw = raw.lower()
729        if raw == '':
730            return NoInput()
731
732        if raw in ['yes', 'y', '1', 'true']:
733            return True
734        elif raw in ['no', 'n', '0', 'false']:
735            return False
736        # if no match, or an empty string
737        return IncompleteInput(raw)
738
739    def _evaluateUserInput(self, raw):
740        '''
741        Evaluate the user's string entry after parsing;
742        do not return None: either return a valid response,
743        default if available, IncompleteInput, NoInput objects.
744
745        >>> d = configure.YesOrNo()
746        >>> d._evaluateUserInput('y')
747        True
748        >>> d._evaluateUserInput('False')
749        False
750        >>> d._evaluateUserInput('')  # there is no default,
751        <music21.configure.NoInput: None>
752        >>> d._evaluateUserInput('wer')  # there is no default,
753        <music21.configure.IncompleteInput: wer>
754
755        >>> d = configure.YesOrNo('yes')
756        >>> d._evaluateUserInput('')  # there is a default
757        True
758        >>> d._evaluateUserInput('wbf')  # there is a default
759        <music21.configure.IncompleteInput: wbf>
760
761        >>> d = configure.YesOrNo('n')
762        >>> d._evaluateUserInput('')  # there is a default
763        False
764        >>> d._evaluateUserInput(None)  # None is processed as NoInput
765        False
766        >>> d._evaluateUserInput('blah')  # None is processed as NoInput
767        <music21.configure.IncompleteInput: blah>
768        '''
769        rawParsed = self._parseUserInput(raw)
770        # means no answer: return default
771        if isinstance(rawParsed, NoInput):
772            if self._default is not None:
773                return self._default
774        # could be IncompleteInput, NoInput, or a proper, valid answer
775        return rawParsed
776
777
778# ------------------------------------------------------------------------------
779class AskOpenInBrowser(YesOrNo):
780    '''
781    Ask the user if the want to open a URL in a browser.
782
783
784    >>> d = configure.AskOpenInBrowser('http://mit.edu/music21')
785    '''
786
787    def __init__(self, urlTarget, default=True, tryAgain=True,
788                 promptHeader=None, prompt=None):
789        super().__init__(default=default, tryAgain=tryAgain, promptHeader=promptHeader)
790
791        self._urlTarget = urlTarget
792        # try to directly set prompt header
793        if prompt is not None:
794            # override whatever is already in the prompt
795            self._promptHeader = prompt
796        else:  # else, append
797            msg = f'Open the following URL ({self._urlTarget}) in a web browser?\n'
798            self.appendPromptHeader(msg)
799
800    def _performAction(self, simulate=False):
801        '''The action here is to open the stored URL in a browser, if the user agrees.
802        '''
803        result = self.getResult()
804        if result is True:
805            webbrowser.open_new(self._urlTarget)
806        elif result is False:
807            pass
808            # self._writeToUser(['No URL is opened.', ' '])
809
810        # perform action
811
812
813class AskInstall(YesOrNo):
814    '''
815    Ask the user if they want to move music21 to the normal place...
816    '''
817    def __init__(self, default=True, tryAgain=True,
818                 promptHeader=None):
819        super().__init__(default=default, tryAgain=tryAgain, promptHeader=promptHeader)
820
821        # define platforms that this will run on
822        self._platforms = ['darwin', 'nix']
823
824        msg = (
825            'Would you like to install music21 in the normal '
826            + 'place for Python packages (i.e., site-packages)?'
827        )
828        self.appendPromptHeader(msg)
829
830    def _performActionNix(self, simulate=False):
831        fp = findSetup()
832        if fp is None:
833            return None
834
835        self._writeToUser(['You must authorize writing in the following directory:',
836                           getSitePackages(),
837                           ' ',
838                           'Please provide your user password to complete this operation.',
839                           ''])
840
841        stdoutSrc = sys.stdout
842        # stderrSrc = sys.stderr
843
844        fileLikeOpen = io.StringIO()
845        sys.stdout = fileLikeOpen
846
847        directory, unused_fn = os.path.split(fp)
848        pyPath = sys.executable
849        cmd = f'cd {directory!r}; sudo {pyPath!r} setup.py install'
850        post = os.system(cmd)
851
852        fileLikeOpen.close()
853        sys.stdout = stdoutSrc
854        # sys.stderr = stderrSrc
855        return post
856
857    def _performAction(self, simulate=False):
858        '''The action here is to install in site packages, if the user agrees.
859        '''
860        result = self.getResult()
861        if result is not True:
862            return None
863
864        platform = common.getPlatform()
865        if platform == 'win':
866            post = None
867        elif platform == 'darwin':
868            post = self._performActionNix()
869        elif platform == 'nix':
870            post = self._performActionNix()
871        else:
872            post = self._performActionNix()
873        return post
874
875
876class AskSendInstallationReport(YesOrNo):
877    '''
878    Ask the user if they want to send a report
879    regarding their system and usage.
880    '''
881    def __init__(self, default=True, tryAgain=True,
882                 promptHeader=None, additionalEntries=None):
883        super().__init__(default=default, tryAgain=tryAgain, promptHeader=promptHeader)
884
885        if additionalEntries is None:
886            additionalEntries = {}
887        self._additionalEntries = additionalEntries
888
889        msg = ('Would you like to send a pre-formatted email to music21 regarding your '
890               'installation? Installation reports help us make music21 work better for you')
891        self.appendPromptHeader(msg)
892
893    def _getMailToStr(self):
894        # noinspection PyListCreation
895        body = []
896        body.append('Please send the following email; your return email address '
897                    'will never be used in any way.')
898        body.append('')
899        body.append('The following information on your installation '
900                    'will be used only for research.')
901        body.append('')
902
903        userData = getUserData()
904        # add any additional entries; this is used for adding the original egg info
905        userData.update(self._additionalEntries)
906        for key in sorted(userData):
907            body.append(f'{key} // {userData[key]}')
908        body.append('python version:')
909        body.append(sys.version)
910
911        body.append('')
912        body.append('Below, please provide a few words about what sorts of tasks '
913                    'or problems you plan to explore with music21. Any information on '
914                    'your background is also appreciated (e.g., amateur musician, '
915                    'computer programmer, professional music researcher). Thanks!')
916        body.append('')
917
918        platform = common.getPlatform()
919        if platform == 'win':  # need to add proper return carriage for win
920            body = '%0D%0A'.join(body)
921        else:
922            body = '\n'.join(body)
923
924        msg = f'''mailto:music21stats@gmail.com?subject=music21 Installation Report&body={body}'''
925        return msg  # pass this to webbrowser
926
927    def _performAction(self, simulate=False):
928        '''
929        The action here is to open the stored URL in a browser, if the user agrees.
930        '''
931        result = self.getResult()
932        if result is True:
933            webbrowser.open(self._getMailToStr())
934
935
936# ------------------------------------------------------------------------------
937class SelectFromList(Dialog):
938    '''
939    General class to select values from a list.
940
941    >>> d = configure.SelectFromList()  # empty selection list
942    >>> d.askUser('no')  # results in bad condition
943    >>> d.getResult()
944    <music21.configure.BadConditions: None>
945
946    >>> d = configure.SelectFromList()  # empty selection list
947    >>> def validResults(force=None):
948    ...     return range(5)
949    >>> d._getValidResults = validResults  # provide alt function for testing
950    >>> d.askUser(2)  # results in bad condition
951    >>> d.getResult()
952    2
953    '''
954
955    def __init__(self, default=None, tryAgain=True, promptHeader=None):
956        super().__init__(default=default, tryAgain=tryAgain, promptHeader=promptHeader)
957
958    def _getValidResults(self, force=None):
959        '''
960        Return a list of valid results that are possible and should be displayed to the user.
961        '''
962        # this might need to be cached
963        # customize in subclass
964        if force is not None:
965            return force
966        else:
967            return []
968
969    def _formatResultForUser(self, result):
970        '''Reduce each complete file path to stub, or otherwise compact display
971        '''
972        return result
973
974    def _askFillEmptyList(self, default=None, force=None):
975        '''
976        What to do if the selection list is empty. Only return True or False:
977        if we should continue or not.
978
979        >>> prompt = configure.SelectFromList(default=True)
980        >>> prompt._askFillEmptyList(force='yes')
981        True
982        >>> prompt._askFillEmptyList(force='n')
983        False
984        >>> prompt._askFillEmptyList(force='')  # no default, returns False
985        False
986        >>> prompt._askFillEmptyList(force='blah')  # error gets false
987        False
988        '''
989        # this does not do anything: customize in subclass
990        d = YesOrNo(default=default,
991                    tryAgain=False,
992                    promptHeader='The selection list is empty. Try Again?')
993        d.askUser(force=force)
994        post = d.getResult()
995        # if any errors are found, return False
996        if isinstance(post, DialogError):
997            return False
998        else:  # must be True or False
999            if post not in [True, False]:
1000                # this should never happen...
1001                raise DialogException(
1002                    '_askFillEmptyList(): sub-command returned non True/False value')
1003            return post
1004
1005    def _preAskUser(self, force=None):
1006        '''
1007        Before we ask user, we need to to run _askFillEmptyList list if the list is empty.
1008
1009        >>> d = configure.SelectFromList()
1010        >>> d._preAskUser('no')  # force for testing
1011        False
1012        >>> d._preAskUser('yes')  # force for testing
1013        True
1014        >>> d._preAskUser('')  # no default, returns False
1015        False
1016        >>> d._preAskUser('x')  # bad input returns False
1017        False
1018        '''
1019        options = self._getValidResults()
1020        if not options:
1021            # must return True/False,
1022            post = self._askFillEmptyList(force=force)
1023            return post
1024        else:  # if we have options, return True
1025            return True
1026
1027    def _rawQuery(self, force=None):
1028        '''
1029        Return a multiline presentation of the question.
1030
1031        >>> d = configure.SelectFromList()
1032        >>> d._rawQuery(['a', 'b', 'c'])
1033        ['[1] a', '[2] b', '[3] c', ' ', 'Select a number from the preceding options: ']
1034
1035        >>> d = configure.SelectFromList(default=1)
1036        >>> d._default
1037        1
1038        >>> d._rawQuery(['a', 'b', 'c'])
1039        ['[1] a', '[2] b', '[3] c', ' ',
1040         'Select a number from the preceding options (default is 1): ']
1041        '''
1042        head = []
1043        i = 1
1044        options = self._getValidResults(force=force)
1045        # if no options, cannot form query: return bad conditions
1046        if not options:
1047            return BadConditions('no options available')
1048
1049        for entry in options:
1050            sub = self._formatResultForUser(entry)
1051            head.append(f'[{i}] {sub}')
1052            i += 1
1053
1054        tail = 'Select a number from the preceding options: '
1055        tail = self._rawQueryPrepareHeader(tail)
1056        tail = self._rawQueryPrepareFooter(tail)
1057        return head + [' ', tail]
1058
1059    def _parseUserInput(self, raw):
1060        '''
1061        Convert all values to an integer, or return NoInput or IncompleteInput.
1062        Do not yet evaluate whether the number is valid in the context of the selection choices.
1063
1064        >>> d = configure.SelectFromList()
1065        '''
1066        # environLocal.printDebug(['SelectFromList', '_parseUserInput', 'raw', raw])
1067        if raw is None:
1068            return NoInput()
1069        if raw == '':
1070            return NoInput()
1071        # accept yes as 1
1072
1073        if raw in ['yes', 'y', '1', 'true']:
1074            post = 1
1075        else:  # try to convert string into a number
1076            try:
1077                post = int(raw)
1078            # catch all problems
1079            except (ValueError, TypeError, ZeroDivisionError):
1080                return IncompleteInput(raw)
1081        return post
1082
1083    def _evaluateUserInput(self, raw):
1084        rawParsed = self._parseUserInput(raw)
1085
1086        # means no answer: return default
1087        if isinstance(rawParsed, NoInput):
1088            if self._default is not None:
1089                return self._default
1090
1091        # could be IncompleteInput, NoInput, or a proper, valid answer
1092        return rawParsed
1093
1094
1095class AskAutoDownload(SelectFromList):
1096    '''
1097    General class to select values from a list.
1098    '''
1099
1100    def __init__(self, default=1, tryAgain=True, promptHeader=None):
1101        super().__init__(default=default, tryAgain=tryAgain, promptHeader=promptHeader)
1102
1103    def _rawIntroduction(self):
1104        '''Return a multiline presentation of an introduction.
1105        '''
1106        return ['The BSD-licensed music21 software is distributed with a corpus of encoded '
1107                'compositions which are distributed with the permission of the encoders '
1108                '(and, where needed, the composers or arrangers) and where permitted under '
1109                'United States copyright law. Some encodings included in the corpus may not '
1110                'be used for commercial uses or have other restrictions: please see the '
1111                'licenses embedded in individual compositions or directories for more details.',
1112                ' ',
1113                'In addition to the corpus distributed with music21, other pieces are not '
1114                'included in this distribution, but are indexed as links to other web sites '
1115                'where they can be downloaded (the "virtual corpus"). If you would like, music21 '
1116                'can help your computer automatically resolve these links and bring them to your '
1117                'hard drive for analysis. '
1118                # 'See corpus/virtual.py for a list of sites that music21 '
1119                # 'might index.',
1120                ' ',
1121                'To the best of our knowledge, the music (if not the encodings) in the corpus are '
1122                'either out of copyright in the United States and/or are licensed for '
1123                'non-commercial use. These works, along with any works linked to in the virtual '
1124                'corpus, may or may not be free in your jurisdiction. If you believe this message '
1125                'to be in error regarding one or more works please contact '
1126                'Michael Cuthbert at cuthbert@mit.edu.',
1127                ' ',
1128                'Would you like to:'
1129                ]
1130
1131    def _getValidResults(self, force=None):
1132        '''Just return number options
1133        '''
1134        if force is not None:
1135            return force
1136        else:
1137            return [
1138                'Acknowledge these terms and allow music21 to aid in finding pieces in the corpus',
1139                'Acknowledge these terms and block the virtual corpus',
1140                'Do not agree to these terms and will not use music21 (agreeing to the terms of '
1141                + 'the corpus is mandatory for using the system).'
1142            ]
1143
1144    def _evaluateUserInput(self, raw):
1145        '''Evaluate the user's string entry after parsing; do not return None:
1146        either return a valid response, default if available, IncompleteInput, NoInput objects.
1147        '''
1148        rawParsed = self._parseUserInput(raw)
1149        # if NoInput: and a default, return default
1150        if isinstance(rawParsed, NoInput):
1151            if self._default is not None:
1152                # do not return the default, as this here is a number
1153                # and proper results are file paths. thus, set rawParsed
1154                # to default; will get converted later
1155                rawParsed = self._default
1156
1157        # could be IncompleteInput, NoInput, or a proper, valid answer
1158        if isinstance(rawParsed, DialogError):  # keep as is
1159            return rawParsed
1160
1161        if 1 <= rawParsed <= 3:
1162            return rawParsed
1163        else:
1164            return IncompleteInput(rawParsed)
1165
1166    def _performAction(self, simulate=False):
1167        '''
1168        override base.
1169        '''
1170        result = self.getResult()
1171        if result in [1, 2, 3]:
1172            # noinspection PyTypeChecker
1173            reload(environment)
1174            # us = environment.UserSettings()
1175            if result == 1:
1176                # calling this function will check to see if a file is created
1177                environment.set('autoDownload', 'allow')
1178                # us['autoDownload'] = 'allow'  # automatically writes
1179            elif result == 2:
1180                # us['autoDownload'] = 'deny'  # automatically writes
1181                environment.set('autoDownload', 'deny')
1182            elif result == 3:
1183                raise DialogException('user selected an option that terminates installer.')
1184
1185        if result in [1, 2]:
1186            self._writeToUser([f"Auto Download set to: {environment.get('autoDownload')}", ' '])
1187
1188
1189class SelectFilePath(SelectFromList):
1190    '''
1191    General class to select values from a list.
1192    '''
1193
1194    def __init__(self, default=None, tryAgain=True, promptHeader=None):
1195        super().__init__(default=default, tryAgain=tryAgain, promptHeader=promptHeader)
1196
1197    def _getAppOSIndependent(self, comparisonFunction, path0: str, post: List[str],
1198                             *,
1199                             glob: str = '**/*'):
1200        '''
1201        Uses comparisonFunction to see if a file in path0 matches
1202        the RE embedded in comparisonFunction and if so manipulate the list
1203        in post
1204
1205        comparisonFunction = function (lambda function on path returning True/False)
1206        path0 = os-specific string
1207        post = list of matching results to fill
1208        glob = string to glob for (default **/*, but **/*.exe on Windows and * on Mac).
1209        '''
1210        path0_as_path = pathlib.Path(path0)
1211        for path1 in path0_as_path.glob(glob):
1212            if comparisonFunction(str(path1)):
1213                post.append(str(path1))
1214
1215    def _getDarwinApp(self, comparisonFunction) -> List[str]:
1216        '''
1217        Provide a comparison function that returns True or False based on the file name.
1218        This looks at everything in Applications, as well as every directory in Applications
1219        '''
1220        post: List[str] = []
1221        for path0 in ('/Applications', common.cleanpath('~/Applications')):
1222            self._getAppOSIndependent(comparisonFunction, path0, post, glob='*')
1223        return post
1224
1225    def _getWinApp(self, comparisonFunction) -> List[str]:
1226        '''
1227        Provide a comparison function that returns True or False based on the file name.
1228        '''
1229        # provide a similar method to _getDarwinApp
1230        post: List[str] = []
1231        environKeys = ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432')
1232        for possibleEnvironKey in environKeys:
1233            if possibleEnvironKey not in os.environ:
1234                continue  # Many do not define ProgramW6432
1235            environPath = os.environ[possibleEnvironKey]
1236            if environPath == '':
1237                continue
1238            self._getAppOSIndependent(comparisonFunction, environPath, post, glob='**/*.exe')
1239
1240        return post
1241
1242    def _evaluateUserInput(self, raw):
1243        '''
1244        Evaluate the user's string entry after parsing;
1245        do not return None: either return a valid response, default if available,
1246        IncompleteInput, NoInput objects.
1247
1248        Here, we convert the user-selected number into a file path
1249        '''
1250        rawParsed = self._parseUserInput(raw)
1251        # if NoInput: and a default, return default
1252        if isinstance(rawParsed, NoInput):
1253            if self._default is not None:
1254                # do not return the default, as this here is a number
1255                # and proper results are file paths. thus, set rawParsed
1256                # to default; will get converted later
1257                rawParsed = self._default
1258
1259        # could be IncompleteInput, NoInput, or a proper, valid answer
1260        if isinstance(rawParsed, DialogError):  # keep as is
1261            return rawParsed
1262
1263        # else, translate a number into a file path; assume zero is 1
1264        options = self._getValidResults()
1265        if 1 <= rawParsed <= len(options):
1266            return options[rawParsed - 1]
1267        else:
1268            return IncompleteInput(rawParsed)
1269
1270
1271class SelectMusicXMLReader(SelectFilePath):
1272    '''
1273    Select a MusicXML Reader by presenting a user a list of options.
1274    '''
1275
1276    def __init__(self, default=None, tryAgain=True, promptHeader=None):
1277        SelectFilePath.__init__(self,
1278                                default=default,
1279                                tryAgain=tryAgain,
1280                                promptHeader=promptHeader)
1281
1282        # define platforms that this will run on
1283        self._platforms = ['darwin', 'win']
1284
1285    def _rawIntroduction(self):
1286        '''
1287        Return a multiline presentation of an introduction.
1288        '''
1289        return [
1290            'Defining an XML Reader permits automatically opening '
1291            + 'music21-generated MusicXML in an editor for display and manipulation when calling '
1292            + 'the show() method. Setting this option is highly recommended. ',
1293            ' '
1294        ]
1295
1296    def _getMusicXMLReaderDarwin(self):
1297        '''
1298        Get all possible MusicXML Reader paths on Darwin (i.e., macOS)
1299        '''
1300        def comparisonFinale(x):
1301            return reFinaleApp.search(x) is not None
1302
1303        def comparisonMuseScore(x):
1304            return reMuseScoreApp.search(x) is not None
1305
1306        def comparisonFinaleReader(x):
1307            return reFinaleReaderApp.search(x) is not None
1308
1309        def comparisonSibelius(x):
1310            return reSibeliusApp.search(x) is not None
1311
1312        # order here results in ranks
1313        results = self._getDarwinApp(comparisonMuseScore)
1314        results += self._getDarwinApp(comparisonFinale)
1315        results += self._getDarwinApp(comparisonFinaleReader)
1316        results += self._getDarwinApp(comparisonSibelius)
1317
1318        # de-duplicate
1319        res = []
1320        for one_path in results:
1321            if one_path not in res:
1322                res.append(one_path)
1323
1324        return res
1325
1326    def _getMusicXMLReaderWin(self):
1327        '''
1328        Get all possible MusicXML Reader paths on Windows
1329        '''
1330        def comparisonFinale(x):
1331            return reFinaleExe.search(x) is not None
1332
1333        def comparisonMuseScore(x):
1334            return reMuseScoreExe.search(x) is not None and 'crash-reporter' not in x
1335
1336        def comparisonSibelius(x):
1337            return reSibeliusExe.search(x) is not None
1338
1339        # order here results in ranks
1340        results = self._getWinApp(comparisonMuseScore)
1341        results += self._getWinApp(comparisonFinale)
1342        results += self._getWinApp(comparisonSibelius)
1343
1344        # de-duplicate (Windows especially can put the same environment var twice)
1345        res = []
1346        for one_path in results:
1347            if one_path not in res:
1348                res.append(one_path)
1349
1350        return res
1351
1352    def _getMusicXMLReaderNix(self):
1353        '''
1354        Get all possible MusicXML Reader paths on Unix
1355        '''
1356        return []
1357
1358    def _getValidResults(self, force=None):
1359        '''
1360        Return a list of valid results that are possible and
1361        should be displayed to the user.
1362        These will be processed by _formatResultForUser before usage.
1363        '''
1364        # customize in subclass
1365        if force is not None:
1366            return force
1367
1368        platform = common.getPlatform()
1369        if platform == 'win':
1370            post = self._getMusicXMLReaderWin()
1371        elif platform == 'darwin':
1372            post = self._getMusicXMLReaderDarwin()
1373        elif platform == 'nix':
1374            post = self._getMusicXMLReaderNix()
1375        else:
1376            post = self._getMusicXMLReaderNix()
1377        return post
1378
1379    def _askFillEmptyList(self, default=None, force=None):
1380        '''
1381        If we do not have an musicxml readers, ask user if they want to download.
1382        '''
1383        urlTarget = urlMuseScore
1384
1385        # this does not do anything: customize in subclass
1386        d = AskOpenInBrowser(
1387            urlTarget=urlTarget,
1388            default=True,
1389            tryAgain=False,
1390            promptHeader='No available MusicXML readers are found on your system. '
1391            + 'We recommend downloading and installing a reader before continuing.\n\n')
1392        d.askUser(force=force)
1393        post = d.getResult()
1394        # can call regardless of result; will only function if result is True
1395        d.performAction()
1396        # if any errors are found, return False; this will end execution of
1397        # askUser and return a BadConditions error
1398        if isinstance(post, DialogError):
1399            return False
1400        else:  # must be True or False
1401            # if user selected to open web page, give them time to download
1402            # and install; so ask if ready to continue
1403            if post is True:
1404                for dummy in range(self._maxAttempts):
1405                    d = YesOrNo(default=True, tryAgain=False,
1406                                promptHeader='Are you ready to continue?')
1407                    d.askUser(force=force)
1408                    post = d.getResult()
1409                    if post is True:
1410                        break
1411                    elif isinstance(post, DialogError):
1412                        break
1413
1414            return post
1415
1416    def _performAction(self, simulate=False):
1417        '''
1418        The action here is to open the stored URL in a browser, if the user agrees.
1419        '''
1420        result = self.getResult()
1421        if result is not None and not isinstance(result, DialogError):
1422            # noinspection PyTypeChecker
1423            reload(environment)
1424            # us = environment.UserSettings()
1425            # us['musicxmlPath'] = result  # automatically writes
1426            environment.set('musicxmlPath', result)
1427            musicXmlNew = environment.get('musicxmlPath')
1428            self._writeToUser([f'MusicXML Reader set to: {musicXmlNew}', ' '])
1429
1430
1431# ------------------------------------------------------------------------------
1432class ConfigurationAssistant:
1433    '''
1434    Class for managing numerous configuration tasks.
1435    '''
1436    def __init__(self, simulate=False):
1437        self._simulate = simulate
1438        self._platform = common.getPlatform()
1439
1440        # get and store if there is a current egg-info files
1441        self._lastEggInfo = findInstallationsEggInfoStr()
1442
1443        # add dialogs to list
1444        self._dialogs = []
1445        self.getDialogs()
1446
1447    def getDialogs(self):
1448        if 'site-packages' not in common.getSourceFilePath().parts:
1449            d = AskInstall(default=True)
1450            self._dialogs.append(d)
1451
1452        d = SelectMusicXMLReader(default=1)
1453        self._dialogs.append(d)
1454
1455        d = AskAutoDownload(default=True)
1456        self._dialogs.append(d)
1457
1458        # provide original egg info files
1459        additionalEntries = {'music21 egg-info previous': self._lastEggInfo}
1460        d = AskSendInstallationReport(default=True, additionalEntries=additionalEntries)
1461        self._dialogs.append(d)
1462
1463        d = AskOpenInBrowser(
1464            urlTarget=urlMusic21List,
1465            prompt='The music21 discussion group provides a forum for '
1466            + 'asking questions and getting help. Would you like to see the '
1467            + 'music21 discussion list or sign up for updates?')
1468        self._dialogs.append(d)
1469
1470        # note: this is the on-line URL:
1471        # might be better to find local documentation
1472        d = AskOpenInBrowser(
1473            urlTarget=urlGettingStarted,
1474            prompt='Would you like to view the music21 documentation in a web browser?')
1475        self._dialogs.append(d)
1476
1477        d = AnyKey(promptHeader='The music21 Configuration Assistant is complete.')
1478        self._dialogs.append(d)
1479
1480    def _introduction(self):
1481        msg = []
1482        msg.append('Welcome the music21 Configuration Assistant. You will be guided '
1483                   + 'through a number of questions to install and setup music21. '
1484                   + 'Simply pressing return at a prompt will select a default, if available.')
1485        msg.append('')  # will cause a line break
1486        msg.append('You may run this configuration again at a later time '
1487                   + 'by running music21/configure.py.')
1488        msg.append(' ')  # will cause a blank line
1489
1490        writeToUser(msg)
1491
1492    def _conclusion(self):
1493        pass
1494
1495    def _hr(self):
1496        '''
1497        Draw a line
1498        '''
1499        msg = []
1500        msg.append('_' * LINE_WIDTH)
1501        msg.append(' ')  # add a space
1502        writeToUser(msg)
1503
1504    def run(self, forceList=None):
1505        '''
1506        The forceList, if provided, is a list of string arguments
1507        passed in order to the included dialogs. Used for testing.
1508        '''
1509        if forceList is None:
1510            forceList = []
1511        self._hr()
1512        self._introduction()
1513
1514        for i, d in enumerate(self._dialogs):
1515            # if this platform is not in those defined for the dialog, continue
1516            if self._platform not in d._platforms:
1517                continue
1518
1519            self._hr()
1520            if len(forceList) > i:
1521                force = forceList[i]
1522            else:
1523                force = None
1524
1525            d.askUser(force=force)
1526            unused_post = d.getResult()
1527            # post may be an error; no problem calling perform action anyways
1528            try:
1529                d.performAction(simulate=self._simulate)
1530            except DialogException:
1531                # a user may have selected an option that requires breaking
1532                break
1533
1534        # self._hr()
1535        self._conclusion()
1536
1537
1538# ------------------------------------------------------------------------------
1539# for time-out gather of arguments: possibly look at:
1540# https://code.activestate.com/recipes/576780/
1541# http://www.garyrobinson.net/2009/10/non-blocking-raw_input-for-python.html
1542# class Prompt(threading.Thread):
1543#     def __init__ (self, prompt, timeOutTime):
1544#         super().__init__()
1545#         self.status = None
1546#         self.timeLeft = timeOutTime
1547#         self.prompt = prompt
1548#
1549#     def removeTime(self, value):
1550#         self.timeLeft -= value
1551#
1552#     def printPrompt(self):
1553#         sys.stdout.write('%s: ' % self.prompt)
1554#
1555#     def run(self):
1556#         self.printPrompt()  # print on first call
1557#         self.status = input()
1558#
1559#
1560# def getResponseOrTimeout(prompt='provide a value', timeOutTime=16):
1561#
1562#     current = Prompt(prompt=prompt, timeOutTime=timeOutTime)
1563#     current.start()
1564#     reportInterval = 4
1565#     updateInterval = 1
1566#     intervalCount = 0
1567#
1568#     post = None
1569#     while True:
1570#     # for host in range(60, 70):
1571#         if not current.isAlive() or current.status is not None:
1572#             break
1573#         if current.timeLeft <= 0:
1574#             break
1575#         time.sleep(updateInterval)
1576#         current.removeTime(updateInterval)
1577#
1578#         if intervalCount % reportInterval == reportInterval - 1:
1579#             sys.stdout.write('\n time out in %s seconds\n' % current.timeLeft)
1580#             current.printPrompt()
1581#
1582#         intervalCount += 1
1583#     # for o in objList:
1584#         # can have timeout argument, otherwise blocks
1585#         # o.join()  # wait until the thread terminates
1586#
1587#     post = current.status
1588#     # this thread will remain active until the user provides values
1589#
1590#     if post == None:
1591#         print('got no value')
1592#     else:
1593#         print('got: %s' % post)
1594# ------------------------------------------------------------------------------
1595# define presented order in documentation
1596_DOC_ORDER = []
1597
1598
1599class TestUserInput(unittest.TestCase):  # pragma: no cover
1600
1601    def testYesOrNo(self):
1602        print()
1603        print('starting: YesOrNo()')
1604        d = YesOrNo()
1605        d.askUser()
1606        print('getResult():', d.getResult())
1607
1608        print()
1609        print('starting: YesOrNo(default=True)')
1610        d = YesOrNo(default=True)
1611        d.askUser()
1612        print('getResult():', d.getResult())
1613
1614        print()
1615        print('starting: YesOrNo(default=False)')
1616        d = YesOrNo(default=False)
1617        d.askUser()
1618        print('getResult():', d.getResult())
1619
1620    def testSelectMusicXMLReader(self):
1621        print()
1622        print('starting: SelectMusicXMLReader()')
1623        d = SelectMusicXMLReader()
1624        d.askUser()
1625        print('getResult():', d.getResult())
1626
1627    def testSelectMusicXMLReaderDefault(self):
1628        print()
1629        print('starting: SelectMusicXMLReader(default=1)')
1630        d = SelectMusicXMLReader(default=1)
1631        d.askUser()
1632        print('getResult():', d.getResult())
1633
1634    def testOpenInBrowser(self):
1635        print()
1636        d = AskOpenInBrowser('http://mit.edu/music21')
1637        d.askUser()
1638        print('getResult():', d.getResult())
1639        d.performAction()
1640
1641    def testSelectMusicXMLReader2(self):
1642        print()
1643        print('starting: SelectMusicXMLReader()')
1644        d = SelectMusicXMLReader()
1645        d.askUser()
1646        print('getResult():', d.getResult())
1647        d.performAction()
1648
1649        print()
1650        print('starting: SelectMusicXMLReader() w/o results')
1651        d = SelectMusicXMLReader()
1652        # force request to user by returning no valid results
1653
1654        def getValidResults(force=None):
1655            return []
1656
1657        d._getValidResults = getValidResults
1658        d.askUser()
1659        print('getResult():', d.getResult())
1660        d.performAction()
1661
1662    def testConfigurationAssistant(self):
1663        print('Running ConfigurationAssistant all')
1664        configAsst = ConfigurationAssistant(simulate=True)
1665        configAsst.run()
1666
1667
1668class Test(unittest.TestCase):
1669
1670    def testYesOrNo(self):
1671        from music21 import configure
1672        d = configure.YesOrNo(default=True, tryAgain=False,
1673                              promptHeader='Are you ready to continue?')
1674        d.askUser('n')
1675        self.assertEqual(str(d.getResult()), 'False')
1676        d.askUser('y')
1677        self.assertEqual(str(d.getResult()), 'True')
1678        d.askUser('')  # gets default
1679        self.assertEqual(str(d.getResult()), 'True')
1680        d.askUser('blah')  # gets default
1681        self.assertEqual(str(d.getResult()), '<music21.configure.IncompleteInput: blah>')
1682
1683        d = configure.YesOrNo(default=None, tryAgain=False,
1684                              promptHeader='Are you ready to continue?')
1685        d.askUser('n')
1686        self.assertEqual(str(d.getResult()), 'False')
1687        d.askUser('y')
1688        self.assertEqual(str(d.getResult()), 'True')
1689        d.askUser('')  # gets default
1690        self.assertEqual(str(d.getResult()), '<music21.configure.NoInput: None>')
1691        d.askUser('blah')  # gets default
1692        self.assertEqual(str(d.getResult()), '<music21.configure.IncompleteInput: blah>')
1693
1694    def testSelectFromList(self):
1695        from music21 import configure
1696        d = configure.SelectFromList(default=1)
1697        self.assertEqual(d._default, 1)
1698
1699    def testSelectMusicXMLReaders(self):
1700        from music21 import configure
1701        d = configure.SelectMusicXMLReader()
1702        # force request to user by returning no valid results
1703
1704        def getValidResults(force=None):
1705            return []
1706
1707        d._getValidResults = getValidResults
1708        d.askUser(force='n', skipIntro=True)  # reject option to open in a browser
1709        post = d.getResult()
1710        # returns a bad condition b/c there are no options and user entered 'n'
1711        self.assertIsInstance(post, configure.BadConditions)
1712
1713    def testRe(self):
1714        g = reFinaleApp.search('Finale 2011.app')
1715        self.assertEqual(g.group(0), 'Finale 2011.app')
1716
1717        self.assertEqual(reFinaleApp.search('final blah 2011'), None)
1718
1719        g = reFinaleApp.search('Finale.app')
1720        self.assertEqual(g.group(0), 'Finale.app')
1721
1722        self.assertEqual(reFinaleApp.search('Final Cut 2017.app'), None)
1723
1724    def testConfigurationAssistant(self):
1725        unused_ca = ConfigurationAssistant(simulate=True)
1726
1727    def testAskInstall(self):
1728        unused_d = AskInstall()
1729        # d.askUser()
1730        # d.getResult()
1731        # d.performAction()
1732
1733    def testGetUserData(self):
1734        unused_d = AskSendInstallationReport()
1735        # d.askUser()
1736        # d.getResult()
1737        # d.performAction()
1738
1739    def testGetUserData2(self):
1740        unused_d = AskAutoDownload()
1741        # d.askUser()
1742        # d.getResult()
1743        # d.performAction()
1744
1745    def testAnyKey(self):
1746        unused_d = AnyKey()
1747        # d.askUser()
1748        # d.getResult()
1749        # d.performAction()
1750
1751
1752def run():
1753    ca = ConfigurationAssistant()
1754    ca.run()
1755
1756
1757if __name__ == '__main__':
1758    if len(sys.argv) == 1:  # normal conditions
1759        # music21.mainTest(Test)
1760        run()
1761
1762    else:
1763        # only if running tests
1764        t = Test()
1765        te = TestUserInput()
1766
1767        if len(sys.argv) < 2 or sys.argv[1] in ['all', 'test']:
1768            import music21
1769            music21.mainTest(Test)
1770
1771        # arg[1] is test to launch
1772        elif sys.argv[1] == 'te':
1773            # run test user input
1774            getattr(te, sys.argv[2])()
1775        # just run named Test
1776        elif hasattr(t, sys.argv[1]):
1777            getattr(t, sys.argv[1])()
1778