#!/usr/bin/env python

# default options; feel free to change!
defaultCompiler = "pdflatex"
defaultArguments = "-synctex=1 -interaction=nonstopmode"
defaultSpeechSetting = "never"

#
# texliveonfly.py (formerly lualatexonfly.py) - "Downloading on the fly"
#     (similar to miktex) for texlive.
#
# Given a .tex file, runs lualatex (by default) repeatedly, using error messages
#     to install missing packages.
#
#
# Version 1.2 ; October 4, 2011
#
# Written on Ubuntu 10.04 with TexLive 2011
# Python 2.6+ or 3
# Should work on Linux and OS X
#
# Copyright (C) 2011 Saitulaa Naranong
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/copyleft/gpl.html>.

import re, subprocess, os, time,  optparse, sys, shlex

scriptName = os.path.basename(__file__)     #the name of this script file
py3 = sys.version_info[0]  >= 3

#functions to support python3's usage of bytes in some places where 2 uses strings
tobytesifpy3 = lambda s = None  : s.encode() if py3 and s != None else s
frombytesifpy3 = lambda b = None : b.decode("UTF-8") if py3 and b != None else b

#version of Popen.communicate that always takes and returns strings
#regardless of py version
def communicateStr ( process,  s = None ):
   (a,b) = process.communicate( tobytesifpy3(s) )
   return ( frombytesifpy3(a), frombytesifpy3(b) )

subprocess.Popen.communicateStr = communicateStr

#global variables (necessary in py2; for py3 should use nonlocal)
installation_initialized = False
installing = False

def generateSudoer(this_terminal_only = False,  tempDirectory = os.path.join(os.getenv("HOME"), ".texliveonfly") ):
   lockfilePath = os.path.join(tempDirectory,  "newterminal_lock")
   #NOTE: double-escaping \\ is neccessary for a slash to appear in the bash command
   # in particular, double quotations in the command need to be written \\"
   def spawnInNewTerminal(bashCommand):
       #makes sure the temp directory exists
       try:
           os.mkdir(tempDirectory)
       except OSError:
           print("\n" + scriptName + ": Our temp directory " + tempDirectory +  " already exists; good.")

       #creates lock file
       lockfile = open(lockfilePath, 'w')
       lockfile.write( "Terminal privilege escalator running.")
       lockfile.close()

       #adds intro and line to remove lock
       bashCommand = '''echo \\"The graphical privilege escalator failed for some reason; we'll try asking for your administrator password here instead.\\n{0}\\n\\";{1}; rm \\"{2}\\"'''.format("-"*18,  bashCommand, lockfilePath)

       #runs the bash command in a new terminal
       try:
           subprocess.Popen ( ['x-terminal-emulator', '-e',  'sh -c "{0}"'.format(bashCommand) ]  )
       except OSError:
           try:
               subprocess.Popen ( ['xterm', '-e',  'sh -c "{0}"'.format(bashCommand) ]  )
           except OSError:
               os.remove(lockfilePath)
               raise

       #doesn't let us proceed until the lock file has been removed by the bash command
       while os.path.exists(lockfilePath):
           time.sleep(0.1)

   def runSudoCommand(bashCommand):
       if this_terminal_only:
           process = subprocess.Popen( ['sudo'] + shlex.split(bashCommand) )
           process.wait()
       elif os.name == "mac":
           process = subprocess.Popen(['osascript'], stdin=subprocess.PIPE )
           process.communicateStr( '''do shell script "{0}" with administrator privileges'''.format(bashCommand) )
       else:
           #raises OSError if neither exist
           try:
               process = subprocess.Popen( ['gksudo', bashCommand] )
           except OSError:
               process = subprocess.Popen( ['kdesudo', bashCommand] )

           process.wait()

   # First tries one-liner graphical/terminal sudo, then opens extended command in new terminal
   # raises OSError if both do
   def attemptSudo(oneLiner, newTerminalCommand = ""):
       try:
           runSudoCommand(oneLiner)
       except OSError:
           if this_terminal_only:
               print("The sudo command has failed and we can't launch any more terminals.")
               raise
           else:
               print("Default graphical priviledge escalator has failed for some reason.")
               print("A new terminal will open and you may be prompted for your sudo password.")
               spawnInNewTerminal(newTerminalCommand)

   return attemptSudo

#speech_setting = "never" prioritized over all others: "always", "install", "fail"
def generateSpeakers(speech_setting):
   speech_setting = speech_setting.lower()
   doNothing = lambda x, failure = None : None

   #most general inputs, always speaks
   generalSpeaker = lambda expression,  failure = False : speakerFunc(expression)

   if "never" in speech_setting:
       return (doNothing, doNothing)

   try:
       if os.name == "mac":
           speaker = subprocess.Popen(['say'], stdin=subprocess.PIPE )
       else:
           speaker = subprocess.Popen(['espeak'], stdin=subprocess.PIPE )
   except:
       return (doNothing, doNothing)

   def speakerFunc(expression):
       if not expression.endswith("\n"):
           expression += "\n"
       try:
           speaker.stdin.write(tobytesifpy3(expression))
           speaker.stdin.flush()
       except: #very tolerant of errors here
           print("An error has occurred when using the speech synthesizer.")

   #if this is called, we're definitely installing.
   def installationSpeaker(expression):
       global installing
       installing = True   #permanantly sets installing (for the endSpeaker)
       if "install" in speech_setting:
           speakerFunc(expression)

   def endSpeaker(expression,  failure = False):
       if installing and "install" in speech_setting or failure and "fail" in speech_setting:
           speakerFunc(expression)

   if "always" in speech_setting:
       return (generalSpeaker, generalSpeaker)
   else:
       return (installationSpeaker, endSpeaker)

#generates speaker for installing packages and an exit function
def generateSpeakerFuncs(speech_setting):
   (installspeaker,  exitspeaker) = generateSpeakers(speech_setting)

   def exiter(code = 0):
       exitspeaker("Compilation{0}successful.".format(", un" if code != 0 else " "),  failure = code != 0 )
       sys.exit(code)

   return (installspeaker, exiter)

def generateTLMGRFuncs(tlmgr, speaker, sudoFunc):
   #checks that tlmgr is installed, raises OSError otherwise
   #also checks whether we need to escalate permissions, using fake remove command
   process = subprocess.Popen( [ tlmgr,  "remove" ], stdin=subprocess.PIPE, stdout = subprocess.PIPE,  stderr=subprocess.PIPE  )
   (tlmgr_out,  tlmgr_err) = process.communicateStr()

   #does our default user have update permissions?
   default_permission = "don't have permission" not in tlmgr_err

   #always call on first update; updates tlmgr and checks permissions
   def initializeInstallation():
       updateInfo = "Updating tlmgr prior to installing packages\n(this is necessary to avoid complaints from itself)."
       print( scriptName + ": " + updateInfo)

       if default_permission:
           process = subprocess.Popen( [tlmgr,  "update",  "--self" ] )
           process.wait()
       else:
           print( "\n{0}: Default user doesn't have permission to modify the TeX Live distribution; upgrading to superuser for installation mode.\n".format(scriptName) )
           basicCommand = ''''{0}' update --self'''.format(tlmgr)
           sudoFunc( basicCommand, '''echo \\"This is {0}'s 'install packages on the fly' feature.\\n\\n{1}\\n\\" ; sudo {2}'''.format(scriptName, updateInfo, basicCommand ) )

   def installPackages(packages):
       if len(packages) == 0:
           return

       global installation_initialized
       if not installation_initialized:
           initializeInstallation()
           installation_initialized = True

       packagesString = " ".join(packages)
       print("{0}: Attempting to install LaTex package(s): {1}".format( scriptName, packagesString ) )

       if default_permission:
           process = subprocess.Popen( [ tlmgr,  "install"] + packages , stdin=subprocess.PIPE )
           process.wait()
       else:
           basicCommand = ''''{0}' install {1}'''.format(tlmgr,  packagesString)
           bashCommand='''echo \\"This is {0}'s 'install packages on the fly' feature.\\n\\nAttempting to install LaTeX package(s): {1} \\"
echo \\"(Some of them might not be real.)\\n\\"
sudo {2}'''.format(scriptName, packagesString, basicCommand)

           sudoFunc(basicCommand, bashCommand)

   #strictmatch requires an entire /file match in the search results
   def getSearchResults(preamble, term, strictMatch):
       fontOrFile =  "font" if "font" in preamble else "file"
       speaker("Searching for missing {0}: {1} ".format(fontOrFile, term))
       print( "{0}: Searching repositories for missing {1} {2}".format(scriptName, fontOrFile,  term) )

       process = subprocess.Popen([ tlmgr, "search", "--global", "--file", term], stdin=subprocess.PIPE, stdout = subprocess.PIPE, stderr=subprocess.PIPE )
       ( output ,  stderrdata ) = process.communicateStr()
       outList = output.split("\n")

       results = ["latex"]    #latex 'result' for removal later

       for line in outList:
           line = line.strip()
           if line.startswith(preamble) and (not strictMatch or line.endswith("/" + term)):
               #filters out the package in:
               #   texmf-dist/.../package/file
               #and adds it to packages
               results.append(line.split("/")[-2].strip())
               results.append(line.split("/")[-3].strip()) #occasionally the package is one more slash before

       results = list(set(results))    #removes duplicates
       results.remove("latex")     #removes most common fake result

       if len(results) == 0:
           speaker("File not found.")
           print("{0}: No results found for {1}".format( scriptName, term ) )
       else:
           speaker("Installing.")

       return results

   def searchFilePackage(file):
       return getSearchResults("texmf-dist/", file, True)

   def searchFontPackage(font):
       font = re.sub(r"\((.*)\)", "", font)    #gets rid of parentheses
       results = getSearchResults("texmf-dist/fonts/", font , False)

       #allow for possibility of lowercase
       if len(results) == 0:
           return [] if font.islower() else searchFontPackage(font.lower())
       else:
           return results

   def searchAndInstall(searcher,  entry):
       installPackages(searcher(entry))
       return entry    #returns the entry just installed

   return ( lambda entry : searchAndInstall(searchFilePackage,  entry),  lambda entry : searchAndInstall(searchFontPackage,  entry) )

def generateCompiler(compiler, arguments, texDoc, exiter):
   def compileTexDoc():
       try:
           process = subprocess.Popen( [compiler] + shlex.split(arguments) + [texDoc], stdin=sys.stdin, stdout = subprocess.PIPE )
           return readFromProcess(process)
       except OSError:
           print( "{0}: Unable to start {1}; are you sure it is installed?{2}".format(scriptName, compiler,
               "  \n\n(Or run " + scriptName + " --help for info on how to choose a different compiler.)" if compiler == defaultCompiler else "" )
               )
           exiter(1)

   def readFromProcess(process):
       getProcessLine = lambda : frombytesifpy3(process.stdout.readline())

       output = ""
       line = getProcessLine()
       while line != '':
           output += line
           sys.stdout.write(line)
           line = getProcessLine()

       returnCode = None
       while returnCode == None:
           returnCode = process.poll()

       return (output, returnCode)

   return compileTexDoc

### MAIN PROGRAM ###

if __name__ == '__main__':
   # Parse command line
   parser = optparse.OptionParser(
       usage="\n\n\t%prog [options] file.tex\n\nUse option --help for more info.",
       description = 'This program downloads TeX Live packages "on the fly" while compiling .tex documents.  ' +
           'Some of its default options can be directly changed in {0}.  For example, the default compiler can be edited on line 4.'.format(scriptName) ,
       version='1.2',
       epilog = 'Copyright (C) 2011 Saitulaa Naranong.  This program comes with ABSOLUTELY NO WARRANTY; see the GNU General Public License v3 for more info.' ,
       conflict_handler='resolve'
   )

   parser.add_option('-h', '--help', action='help', help='print this help text and exit')
   parser.add_option('-c', '--compiler', dest='compiler', metavar='COMPILER',
       help='your LaTeX compiler; defaults to {0}'.format(defaultCompiler), default=defaultCompiler)
   parser.add_option('-a', '--arguments', dest='arguments', metavar='ARGS',
       help='arguments to pass to compiler; default is: "{0}"'.format(defaultArguments) , default=defaultArguments)
   parser.add_option('--texlive_bin', dest='texlive_bin', metavar='LOCATION',
       help='Custom location for the TeX Live bin folder', default="")
   parser.add_option('--terminal_only', action = "store_true" , dest='terminal_only', default=False,
       help="Forces us to assume we can run only in this terminal.  Permission escalators will appear here rather than graphically or in a new terminal.")
   parser.add_option('-s',  '--speech_when' , dest='speech_setting', metavar="OPTION",  default=defaultSpeechSetting ,
       help='Toggles speech-synthesized notifications (where supported).  OPTION can be "always", "never", "installing", "failed", or some combination.')
   parser.add_option('-f', '--fail_silently', action = "store_true" , dest='fail_silently',
       help="If tlmgr cannot be found, compile document anyway.", default=False)

   (options, args) = parser.parse_args()

   if len(args) == 0:
       parser.error( "{0}: You must specify a .tex file to compile.".format(scriptName) )

   texDoc = args[0]
   compiler_path = os.path.join( options.texlive_bin, options.compiler)

   (installSpeaker, exitScript) = generateSpeakerFuncs(options.speech_setting)
   compileTex = generateCompiler( compiler_path, options.arguments, texDoc, exitScript)

   #initializes tlmgr, responds if the program not found
   try:
       tlmgr_path = os.path.join(options.texlive_bin, "tlmgr")
       (installFile,  installFont) = generateTLMGRFuncs(tlmgr_path,  installSpeaker,  generateSudoer(options.terminal_only))
   except OSError:
       if options.fail_silently:
           (output, returnCode)  = compileTex()
           exitScript(returnCode)
       else:
           parser.error( "{0}: It appears {1} is not installed.  {2}".format(scriptName, tlmgr_path,
               "Are you sure you have TeX Live 2010 or later?" if tlmgr_path == "tlmgr" else "" ) )

   #loop constraints
   done = False
   previousFile = ""
   previousFontFile = ""
   previousFont =""

   #keeps running until all missing font/file errors are gone, or the same ones persist in all categories
   while not done:
       (output, returnCode)  = compileTex()

       #most reliable: searches for missing file
       filesSearch = re.findall(r"! LaTeX Error: File `([^`']*)' not found" , output) + re.findall(r"! I can't find file `([^`']*)'." , output)
       filesSearch = [ name for name in filesSearch if name != texDoc ]  #strips our .tex doc from list of files
       #next most reliable: infers filename from font error
       fontsFileSearch = [ name + ".tfm" for name in re.findall(r"! Font \\[^=]*=([^\s]*)\s", output) ]
       #brute force search for font name in files
       fontsSearch =  re.findall(r"! Font [^\n]*file\:([^\:\n]*)\:", output) + re.findall(r"! Font \\[^/]*/([^/]*)/", output)

       try:
           if len(filesSearch) > 0 and filesSearch[0] != previousFile:
               previousFile = installFile(filesSearch[0] )
           elif len(fontsFileSearch) > 0 and fontsFileSearch[0] != previousFontFile:
               previousFontFile = installFile(fontsFileSearch[0])
           elif len(fontsSearch) > 0 and fontsSearch[0] != previousFont:
               previousFont = installFont(fontsSearch[0])
           else:
               done = True
       except OSError:
           print("\n{0}: Unable to update; all privilege escalation attempts have failed!".format(scriptName) )
           print("We've already compiled the .tex document, so there's nothing else to do.\n  Exiting..")
           exitScript(returnCode)

   exitScript(returnCode)