summaryrefslogtreecommitdiff
path: root/support/texliveonfly/texliveonfly.py
blob: fa809ff07a5b1f50926e7de793ca7dca645fbc0e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
#!/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)