Snakefile 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. # -*- coding: utf-8; -*-
  2. import locale
  3. import os
  4. import os.path
  5. import regex
  6. import urllib.parse
  7. import os.path
  8. import bibtexparser
  9. import pypandoc
  10. from collections.abc import Iterable, Mapping
  11. from distutils.spawn import find_executable
  12. from fnmatch import fnmatch
  13. from subprocess import check_output, check_call
  14. from tempfile import NamedTemporaryFile
  15. from bibtexparser.bibdatabase import BibDatabase
  16. from lxml import html
  17. from snakemake.utils import min_version
  18. min_version('3.7.1')
  19. try:
  20. from os import scandir, walk
  21. except ImportError:
  22. from scandir import scandir, walk
  23. def unnest(*args):
  24. '''Un-nest list- and tuple-like elements in arguments.
  25. "List-like" means anything with a len() and whose elments can be
  26. accessed with numeric indexing, except for string-like elements. It
  27. must also be an instance of the collections.Iterable abstract class.
  28. Dict-like elements and iterators/generators are not affected.
  29. This function always returns a list, even if it is passed a single
  30. scalar argument.
  31. '''
  32. result = []
  33. for arg in args:
  34. if isinstance(arg, str):
  35. # String
  36. result.append(arg)
  37. elif isinstance(arg, Mapping):
  38. # Dict-like
  39. result.append(arg)
  40. elif isinstance(arg, Iterable):
  41. try:
  42. # Duck-typing test for list-ness (a stricter condition
  43. # than just "iterable")
  44. for i in range(len(arg)):
  45. result.append(arg[i])
  46. except TypeError:
  47. # Iterable but not list-like
  48. result.append(arg)
  49. else:
  50. # Not iterable
  51. result.append(arg)
  52. return result
  53. def check_output_decode(*args, encoding=locale.getpreferredencoding(), **kwargs):
  54. '''Shortcut for check.output + str.decode'''
  55. return check_output(*args, **kwargs).decode(encoding)
  56. def find_mac_app(name):
  57. try:
  58. result = \
  59. check_output_decode(
  60. ['mdfind',
  61. 'kMDItemDisplayName=="{name}.app"c&&kMDItemKind==Application'.format(name=name)]).split('\n')[0] or \
  62. check_output_decode(
  63. ['mdfind',
  64. 'kMDItemDisplayName=="{name}"c&&kMDItemKind==Application'.format(name=name)]).split('\n')[0]
  65. if result:
  66. return result
  67. else:
  68. raise Exception("Not found")
  69. except Exception:
  70. return None
  71. def find_executable_on_mac(executable, path=None, app_name=None, app_paths=None):
  72. # Easy case
  73. found_executable = find_executable(executable, path)
  74. if found_executable:
  75. return found_executable
  76. # Ok, now we search
  77. if app_paths is None:
  78. app_paths = []
  79. if app_name is None:
  80. app_name = executable
  81. found_app_path = find_mac_app(app_name)
  82. if found_app_path:
  83. app_paths.append(found_app_path)
  84. if app_paths:
  85. new_search_path = ":".join(os.path.join(p, 'Contents/MacOS') for p in app_paths)
  86. return find_executable(executable, path=new_search_path)
  87. else:
  88. return None
  89. # Fallback to /bin/false to trigger an error when run (we don't want
  90. # to trigger an error now, while building the rules)
  91. LYX_PATH = find_executable_on_mac('lyx') or '/bin/false'
  92. # GIMP_PATH = find_executable_on_mac('gimp', app_name='gimp*') or '/bin/false'
  93. PDFINFO_PATH = find_executable('pdfinfo')
  94. def print_pdfinfo(filename):
  95. if PDFINFO_PATH:
  96. shell('''{PDFINFO_PATH} {filename:q}''')
  97. def glob_recursive(pattern, top='.', include_hidden=False, *args, **kwargs):
  98. '''Combination of glob.glob and os.walk.
  99. Reutrns the relative path to every file or directory matching the
  100. pattern anywhere in the specified directory hierarchy. Defaults to the
  101. current working directory. Any additional arguments are passed to
  102. os.walk.'''
  103. for (path, dirs, files) in walk(top, *args, **kwargs):
  104. for f in dirs + files:
  105. if include_hidden or f.startswith('.'):
  106. continue
  107. if fnmatch(f, pattern):
  108. yield os.path.normpath(os.path.join(path, f))
  109. def rsync_list_files(*paths, extra_rsync_args=(), include_dirs=False):
  110. '''Iterate over the files in path that rsync would copy.
  111. By default, only files are listed, not directories, since doit doesn't
  112. like dependencies on directories because it can't hash them.
  113. This uses "rsync --list-only" to make rsync directly indicate which
  114. files it would copy, so any exclusion/inclusion rules are taken into
  115. account.
  116. '''
  117. rsync_list_cmd = [ 'rsync', '-r', '--list-only' ] + unnest(extra_rsync_args) + unnest(paths) + [ '.' ]
  118. rsync_out = check_output_decode(rsync_list_cmd).splitlines()
  119. for line in rsync_out:
  120. s = regex.search('^(-|d)(?:\S+\s+){4}(.*)', line)
  121. if s is not None:
  122. if include_dirs or s.group(1) == '-':
  123. yield s.group(2)
  124. def lyx_input_deps(lyxfile):
  125. '''Return an iterator over all tex files included by a Lyx file.'''
  126. try:
  127. with open(lyxfile) as f:
  128. lyx_text = f.read()
  129. for m in regex.finditer('\\\\(?:input|loadglsentries){(.*?[.]tex)}', lyx_text):
  130. yield m.group(1)
  131. except FileNotFoundError:
  132. pass
  133. def lyx_bib_deps(lyxfile):
  134. '''Return an iterator over all bib files referenced by a Lyx file.
  135. This will only return the names of existing files, so it will be
  136. unreliable in the case of an auto-generated bib file.
  137. '''
  138. try:
  139. with open(lyxfile) as f:
  140. lyx_text = f.read()
  141. bib_names = regex.search('bibfiles "(.*?)"', lyx_text).group(1).split(',')
  142. # Unfortunately LyX doesn't indicate which bib names refer to
  143. # files in the current directory and which don't. Currently that's
  144. # not a problem for me since all my refs are in bib files in the
  145. # current directory.
  146. for bn in bib_names:
  147. bib_path = bn + '.bib'
  148. yield bib_path
  149. except FileNotFoundError:
  150. pass
  151. def lyx_gfx_deps(lyxfile):
  152. '''Return an iterator over all graphics files included by a LyX file.'''
  153. try:
  154. with open(lyxfile) as f:
  155. lyx_text = f.read()
  156. for m in regex.finditer('\\\\begin_inset Graphics\\s+filename (.*?)$', lyx_text, regex.MULTILINE):
  157. yield m.group(1)
  158. except FileNotFoundError:
  159. pass
  160. def lyx_hrefs(lyxfile):
  161. '''Return an iterator over hrefs in a LyX file.'''
  162. try:
  163. pattern = '''
  164. (?xsm)
  165. ^ LatexCommand \\s+ href \\s* \\n
  166. (?: name \\b [^\\n]+ \\n )?
  167. target \\s+ "(.*?)" $
  168. '''
  169. with open(lyxfile) as f:
  170. return (urllib.parse.unquote(m.group(1)) for m in
  171. re.finditer(pattern, f.read()))
  172. except FileNotFoundError:
  173. pass
  174. def tex_gfx_extensions(tex_format = 'xetex'):
  175. '''Return the ordered list of graphics extensions.
  176. This yields the list of extensions that TeX will try for an
  177. \\includegraphics path.
  178. '''
  179. try:
  180. cmdout = check_output_decode(['texdef', '-t', tex_format, '-p', 'graphicx', 'Gin@extensions'])
  181. m = regex.search('^macro:->(.*?)$', cmdout, regex.MULTILINE)
  182. return m.group(1).split(',')
  183. except FileNotFoundError:
  184. return ()
  185. def get_latex_included_gfx(fname):
  186. '''Return list of all graphics included from '''
  187. try:
  188. with open(fname) as infile:
  189. beamer_latex = infile.read()
  190. # Remove comments
  191. beamer_latex = regex.sub('^%.*$','', beamer_latex)
  192. # Find graphics included
  193. return [ m.group(1) for m in regex.finditer(r'\includegraphics(?:\[.*?\])?\{(.+?)\}', beamer_latex) ]
  194. except FileNotFoundError:
  195. return ()
  196. rsync_common_args = ['-rL', '--size-only', '--delete', '--exclude', '.DS_Store', '--delete-excluded',]
  197. rule build_all:
  198. input: 'thesis.pdf', 'thesis-final.pdf', 'presentation.pdf'
  199. # Note: Any rule that generates an input LyX file for this rule must
  200. # be marked as a checkpoint. See
  201. # https://snakemake.readthedocs.io/en/stable/snakefiles/rules.html#data-dependent-conditional-execution
  202. rule thesis_lyx_to_pdf:
  203. '''Produce PDF output for a LyX file.'''
  204. input: lyxfile = '{basename}.lyx',
  205. gfx_deps = lambda wildcards: lyx_gfx_deps(wildcards.basename + '.lyx'),
  206. bib_deps = lambda wildcards: lyx_bib_deps(wildcards.basename + '.lyx'),
  207. tex_deps = lambda wildcards: lyx_input_deps(wildcards.basename + '.lyx'),
  208. output: pdf='{basename,thesis.*}.pdf'
  209. run:
  210. if not LYX_PATH or LYX_PATH == '/bin/false':
  211. raise Exception('Path to LyX executable could not be found.')
  212. shell('''{LYX_PATH:q} -batch --verbose --export-to pdf4 {output.pdf:q} {input.lyxfile:q}''')
  213. print_pdfinfo(output.pdf)
  214. checkpoint lyx_add_final:
  215. '''Copy LyX file and add final option.'''
  216. input: lyxfile = '{basename}.lyx'
  217. # Ensure we can't get file-final-final-final-final.lyx
  218. output: lyxtemp = temp('{basename,(?!graphics/).*(?<!-final)}-final.lyx')
  219. run:
  220. with open(input.lyxfile, 'r') as infile, \
  221. open(output.lyxtemp, 'w') as outfile:
  222. lyx_text = infile.read()
  223. if not regex.search('\\\\options final', lyx_text):
  224. lyx_text = regex.sub('\\\\use_default_options true', '\\\\options final\n\\\\use_default_options true', lyx_text)
  225. outfile.write(lyx_text)
  226. # TODO: Remove all URLs from entries with a DOI
  227. rule process_bib:
  228. '''Preprocess bib file for LaTeX.
  229. For entries with a DOI, all URLs are stripped, since the DOI already
  230. provides a clickable link. For entries with no DOI, all but one URL is
  231. discarded, since LyX can't handle entries with multiple URLs. The
  232. shortest URL is kept.'''
  233. input: '{basename}.bib'
  234. output: '{basename,.*(?<!-PROCESSED)}-PROCESSED.bib'
  235. run:
  236. with open(input[0]) as infile:
  237. bib_db = bibtexparser.load(infile)
  238. entries = bib_db.entries
  239. for entry in entries:
  240. if 'doi' in entry:
  241. try:
  242. del entry['url']
  243. except KeyError:
  244. pass
  245. else:
  246. try:
  247. entry_urls = regex.split('\\s+', entry['url'])
  248. shortest_url = min(entry_urls, key=len)
  249. # Need to fix e.g. 'http://www.pubmedcentral.nih.gov/articlerender.fcgi?artid=55329{\\&}tool=pmcentrez{\\&}rendertype=abstract'
  250. shortest_url = re.sub('\\{\\\\(.)\\}', '\\1', shortest_url)
  251. entry['url'] = shortest_url
  252. except KeyError:
  253. pass
  254. new_db = BibDatabase()
  255. new_db.entries = entries
  256. with open(output[0], 'w') as outfile:
  257. bibtexparser.dump(new_db, outfile)
  258. rule pdf_extract_page:
  259. '''Extract a single page from a multi-page PDF.'''
  260. # Input is a PDF whose basename doesn't already have a page number
  261. input: pdf = 'graphics/{basename}.pdf'
  262. output: pdf = 'graphics/{basename}-PAGE{pagenum,[1-9][0-9]*}.pdf'
  263. run:
  264. # This could be done with a regex constraint on basename,
  265. # except that variable width lookbehind isn't supported.
  266. # Unfortunately, that makes this a runtime error instead of an
  267. # error during DAG construction.
  268. if regex.search('-PAGE[0-9]+$', wildcards.basename):
  269. raise ValueError("Can't extract page from extracted page PDF.")
  270. shell('pdfseparate -f {wildcards.pagenum:q} -l {wildcards.pagenum:q} {input:q} {output:q}')
  271. rule pdf_crop:
  272. '''Crop away empty margins from a PDF.'''
  273. input: pdf = 'graphics/{basename}.pdf'
  274. output: pdf = 'graphics/{basename,.*(?<!-CROP)}-CROP.pdf'
  275. shell: 'pdfcrop --resolution 300 {input:q} {output:q}'
  276. rule pdf_raster:
  277. '''Rasterize PDF to PNG at 600 PPI.
  278. The largest dimension is scaled '''
  279. input: pdf = 'graphics/{basename}.pdf'
  280. output: png = 'graphics/{basename}-RASTER.png'
  281. shell: 'pdftoppm -singlefile -r 600 {input:q} | convert - {output:q}'
  282. rule pdf_raster_res:
  283. '''Rasterize PDF to PNG at specific PPI.
  284. The largest dimension is scaled '''
  285. input: pdf = 'graphics/{basename}.pdf'
  286. output: png = 'graphics/{basename}-RASTER{res,[1-9][0-9]+}.png'
  287. shell: 'pdftoppm -singlefile -r {wildcards.res} {input:q} | convert - {output:q}'
  288. rule png_crop:
  289. '''Crop away empty margins from a PNG.'''
  290. input: pdf = 'graphics/{basename}.png'
  291. output: pdf = 'graphics/{basename,.*(?<!-CROP)}-CROP.png'
  292. shell: 'convert {input:q} -trim {output:q}'
  293. rule jpg_crop:
  294. '''Crop away empty margins from a JPG.'''
  295. input: pdf = 'graphics/{basename}.jpg'
  296. output: pdf = 'graphics/{basename,.*(?<!-CROP)}-CROP.jpg'
  297. shell: 'convert {input:q} -trim {output:q}'
  298. rule svg_to_pdf:
  299. input: 'graphics/{filename}.svg'
  300. output: 'graphics/{filename}-SVG.pdf'
  301. run:
  302. infile = os.path.join(os.path.abspath("."), input[0])
  303. outfile = os.path.join(os.path.abspath("."), output[0])
  304. shell('''inkscape {infile:q} --export-pdf={outfile:q} --export-dpi=300''')
  305. rule svg_raster:
  306. input: 'graphics/{filename}.svg'
  307. output: 'graphics/{filename}-SVG.png'
  308. run:
  309. infile = os.path.join(os.path.abspath("."), input[0])
  310. outfile = os.path.join(os.path.abspath("."), output[0])
  311. shell('''inkscape {infile:q} --export-png={outfile:q} --export-dpi=300''')
  312. rule png_rotate:
  313. input: 'graphics/{filename}.png'
  314. output: 'graphics/{filename}-ROT{angle,[1-9][0-9]*}.png'
  315. run:
  316. if re.search('-ROT[1-9][0-9]*$', wildcards.filename):
  317. raise ValueError("Cannot double-rotate")
  318. shell('convert {input:q} -rotate {wildcards.angle:q} {output:q}')
  319. # rule xcf_to_png:
  320. # input: 'graphics/{filename}.xcf'
  321. # output: 'graphics/{filename}.png'
  322. # shell: 'convert {input:q} -flatten {output:q}'
  323. rule R_to_html:
  324. '''Render an R script as syntax-hilighted HTML.'''
  325. input: '{dirname}/{basename}.R'
  326. output: '{dirname}/{basename,[^/]+}.R.html'
  327. shell: 'pygmentize -f html -O full -l R -o {output:q} {input:q}'
  328. checkpoint build_beamer_latex:
  329. input:
  330. extra_preamble='extra-preamble.latex',
  331. mkdn_file='{basename}.mkdn',
  332. # images=lambda wildcards: get_mkdn_included_images('{basename}.mkdn'.format(**wildcards)),
  333. # pdfs=lambda wildcards: get_mkdn_included_pdfs('{basename}.mkdn'.format(**wildcards)),
  334. output:
  335. latex=temp('{basename,presentation.*}.tex'),
  336. # TODO: should work with shadow minimal but doesn't
  337. run:
  338. beamer_latex = pypandoc.convert_file(
  339. input.mkdn_file, 'beamer', format='md',
  340. extra_args = [
  341. '-H', input.extra_preamble,
  342. '--pdf-engine=xelatex',
  343. ])
  344. # Center all columns vertically
  345. beamer_latex = beamer_latex.replace(r'\begin{columns}[T]', r'\begin{columns}[c]')
  346. with open(output.latex, 'w') as latex_output:
  347. latex_output.write(beamer_latex)
  348. rule build_beamer_pdf:
  349. input:
  350. latex='{basename}.tex',
  351. gfx=lambda wildcards: get_latex_included_gfx('{basename}.tex'.format(**wildcards)),
  352. output:
  353. pdf='{basename,presentation.*}.pdf'
  354. shadow: 'minimal'
  355. run:
  356. shell('''xelatex {input.latex:q} </dev/null''')
  357. print_pdfinfo(output.pdf)
  358. rule build_all_presentations:
  359. input:
  360. 'presentation.pdf',
  361. 'presentation.pptx',
  362. rule make_transplant_organs_graph:
  363. input:
  364. Rscript='graphics/presentation/transplants-organ.R',
  365. data='graphics/presentation/transplants-organ.xlsx',
  366. output:
  367. pdf='graphics/presentation/transplants-organ.pdf'
  368. shell: '''
  369. Rscript 'graphics/presentation/transplants-organ.R'
  370. '''