Source code for leopard

#!/bin/env python3
"""Module for laboratory reporting.

Containers data and interpretations together and then
streamlines it to different output formats.
"""
import matplotlib.pyplot as plt, re
from matplotlib.figure import Figure
import pandas as pd, re, inspect, os
from itertools import count
from unittest.mock import Mock

# Leopard utilities
from leopard.utils import makeFigFromFile, pdSeriesToFrame, renewliner
from leopard.utils import LeopardDict as OrderedDict
#from collections import OrderedDict

# Settings
from .config import config
reportsDir = config['leopard']['reportdir']
csvsep = config['leopard']['csvsep']
csvdec = config['leopard']['csvdec']

[docs]class Section(object): """ Report sections. Defines methods dealing with the structure of sections, and section specific output. tablehead => set to integer to only show DataFrame.head(tablehead) rows in this section For adding subsections, a method is provided. """ def __init__(self,title,text, figures=None,tables=None,subsections=None,code=None, tablehead=None,tablecolumns=None,clearpage=False): """ Args: title (str): Section title. text (str): Section paragraph text. figures (OrderedDict): Section figures. tables (OrderedDict): Section tables. subsections (list): Section subsections list. code (str): code that generated section data/figures. tablehead (int): Number of lines to print of tables in space restricted output formats. tablecolumns (list): Column names of tables included in space restricted output formats. clearpage (bool): Start a new page in certain output formats. """ self.title = title.strip() self.p = text.strip() self.figs = OrderedDict(figures) if figures else OrderedDict() self.tabs = OrderedDict(tables) if tables else OrderedDict() self.subs = subsections if subsections else [] self.code = code self.settings = {'tablehead':tablehead, 'tablecolumns':tablecolumns, 'clearpage':clearpage, 'doubleslashnewline':False} # Initially set section upstream references to self self._parentSection = self self._reportSection = self def __repr__(self): return "<{} @ {}{}>".format( 'Report' if 'sections' in dir(self) else 'Section', self.title[:50], '' if len(self.title)<=50 else '...' ) def __getitem__(self,key): try: return self.subs[key] except TypeError: try: return self._subs[key] except (AttributeError,KeyError) as e: self._subs = {s.title:s for s in self.subs} return self._subs[key]
[docs] def append(self,*args,toSection=None,**kwargs): """Append a new section If toSection is None, section is appended to the main section/subs list. Else if toSection is int or (int,int,...), it gets added to the subs (subsection) list of the specified section. *args* and *kwargs* are processed by Section class initiation """ if not toSection and toSection is not 0: s = Section(*args,**kwargs) self.subs.append(s) self.lastSection = s s._parentSection = self s._reportSection = self._reportSection else: if type(toSection) is int: toSection = (toSection,) s = self.subs[toSection[0]].append(*args,toSection=toSection[1:],**kwargs) return s
[docs] @staticmethod def sectionWalker(section,callback,*args,walkTrace=tuple(),**kwargs): """ callback needs to be a function that handles different Section elements appropriately walkTrace needs to be a tuple, indicate the route to the section, e.g. (1,2,0) """ callback(section,*args,walkTrace=walkTrace,case='sectionmain',**kwargs) c = count(1) for f in section.figs.items(): callback(section,*args,walkTrace=walkTrace,case='figure',element=f,**kwargs) c = count(1) for t in section.tabs.items(): callback(section,*args,walkTrace=walkTrace,case='table',element=t,**kwargs) c = count(1) for s in section.subs: Section.sectionWalker(s,callback,*args,walkTrace=walkTrace+(next(c),),**kwargs)
[docs] def walkerWrapper(callback): """ Wraps a callback function in a wrapper that will be applied to all [sub]sections. Returns: function """ def wrapper(*args,**kwargs): #args[0] => has to be the current walked section return Section.sectionWalker(args[0],callback,*args[1:],**kwargs) return wrapper
@walkerWrapper def list(self,walkTrace=tuple(),case=None,element=None): """List section titles. """ if case == 'sectionmain': print(walkTrace,self.title) @walkerWrapper def listFigures(self,walkTrace=tuple(),case=None,element=None): """List section figures. """ if case == 'sectionmain': print(walkTrace,self.title) if case == 'figure': caption,fig = element try: print(walkTrace,fig._leopardref,caption) except AttributeError: fig._leopardref = next(self._reportSection._fignr) print(walkTrace,fig._leopardref,caption) @walkerWrapper def listTables(self,walkTrace=tuple(),case=None,element=None): """List section tables. """ if case == 'sectionmain': print(walkTrace,self.title) if case == 'table': caption,tab = element try: print(walkTrace,tab._leopardref,caption) except AttributeError: tab._leopardref = next(self._reportSection._tabnr) print(walkTrace,tab._leopardref,caption)
[docs] def sectionOutZip(self,zipcontainer,zipdir='',figtype='png'): """Prepares section for zip output """ from io import StringIO, BytesIO text = self.p if not self.settings['doubleslashnewline'] else self.p.replace('//','\n') zipcontainer.writestr( zipdir+'section.txt', '# {}\n{}'.format(self.title,text).encode() ) c = count(1) for ftitle,f in self.figs.items(): figfile = zipdir+'fig{}_{}.{}'.format(next(c),ftitle.replace(' ','_'),figtype) b = BytesIO() f.savefig(b,format=figtype,transparent=True) b.seek(0) zipcontainer.writestr(figfile,b.getvalue()) c = count(1) for ttitle,t in self.tabs.items(): b = StringIO() t.to_csv(b,sep=csvsep,decimal=csvdec) b.seek(0) zipcontainer.writestr( zipdir+'table{}_{}.csv'.format(next(c),ttitle.replace(' ','_')), b.read().encode() ) c = count(1) for s in self.subs: s.sectionOutZip(zipcontainer,'{}s{}_{}/'.format(zipdir,next(c),s.title.replace(' ','_')),figtype=figtype)
@walkerWrapper def sectionsPDF(self,walkTrace=tuple(),case=None,element=None,doc=None): """Prepares section for PDF output. """ import pylatex as pl if case == 'sectionmain': if self.settings['clearpage']: doc.append(pl.utils.NoEscape(r'\clearpage')) with doc.create(pl.Section(self.title) if len(walkTrace) == 1 else pl.Subsection(self.title) if len(walkTrace) == 2 else pl.Subsubsection(self.title)): text = (self.p.replace('\n',' ').replace('//','\n') if self.settings['doubleslashnewline'] else renewliner(self.p)) if r'\ref' not in text: doc.append(text) else: figrefs = re.compile(r'\\ref\{figref\d+\}') #latexcode = re.compile(r'&@\\.+') lastpos = 0 for fr in figrefs.finditer(text): doc.append(text[lastpos:fr.start()]) doc.append(pl.utils.NoEscape(text[fr.start():fr.end()])) lastpos = fr.end() doc.append(text[lastpos:]) if case == 'figure': width = r'1\textwidth' figtitle,fig = element #if fig._suptitle: fig.suptitle('Figure {}: {}'.format(fig.number,fig._suptitle.get_text())) #figtitle = fig._suptitle.get_text() if fig._suptitle else '' #fig.suptitle('') with doc.create(pl.Figure(position='htbp')) as plot: plt.figure(fig.number) plot.add_plot(width=pl.NoEscape(width)) plot.add_caption(figtitle) plot.append(pl.utils.NoEscape(r'\label{figref'+str(fig.number)+r'}')) #fig.suptitle(figtitle if figtitle else None) if case == 'table': caption,t = element t = pdSeriesToFrame(t) if type(t) == pd.Series else t if self.settings['tablehead']: t = t.head(self.settings['tablehead']) if self.settings['tablecolumns']: t = t[self.settings['tablecolumns']] with doc.create(pl.Table(position='ht')) as tablenv: tablenv.add_caption(caption) with doc.create(pl.Tabular('r|'+'l'*len(t.columns))) as table: table.add_hline() table.add_row(('',)+tuple(t.columns)) for row in t.to_records(): table.add_row(row) table.add_hline(1) #table.add_empty_row() @walkerWrapper def sectionsWord(self,walkTrace=tuple(),case=None,element=None,doc=None): """Prepares section for word output. """ from docx.shared import Inches from io import BytesIO #p.add_run('italic.').italic = True if case == 'sectionmain': if self.settings['clearpage']: doc.add_page_break() doc.add_heading(self.title, level = len(walkTrace)) for p in renewliner(self.p).split('\n'): doc.add_paragraph(p) if case == 'figure': bf=BytesIO() figtitle,fig = element width = fig.get_size_inches()[0] width = Inches(width if width < 6 else 6) fig.savefig(bf) doc.add_picture(bf, width=width) doc.add_heading('Figure {}: {}'.format( fig._leopardref, figtitle),level=6) if case == 'table': caption,t = element tableref = t._leopardref t = pdSeriesToFrame(t) if type(t) == pd.Series else t if self.settings['tablehead']: t = t.head(self.settings['tablehead']) if self.settings['tablecolumns']: t = t[self.settings['tablecolumns']] doc.add_heading('Table {}: {}'.format( tableref, caption),level=6) table = doc.add_table(t.shape[0]+1,t.shape[1]+1) for tcell,col in zip(table.rows[0].cells[1:],t.columns): tcell.text = str(col) for trow,rrow in zip(table.rows[1:],t.to_records()): for tcell,rcell in zip(trow.cells,rrow): tcell.text = str(rcell)
[docs] @staticmethod def sectionFromFunction(function,*args,**kwargs): """ This staticmethod executes the function that is passed with the provided args and kwargs. The first line of the function docstring is used as the section title, the comments within the function body are parsed and added as the section text. The function should return an ordered dict of figures and tables, that are then attached to the section. Args: function (function): The function that generates the section content. Returns: Section >>> # Section title of example function ... def exampleFunction(a,b=None): ... 'Mock figures and tables included' ... figures = (('fig1',Mock()),('fig2',Mock())) ... tables = (('tab1',Mock()),('tab2',Mock())) ... return figures, tables >>> Section.sectionFromFunction(exampleFunction,Mock(),b=Mock()) <Section @ Section title of example function> """ figures, tables = function(*args,**kwargs) title = inspect.getcomments(function)[1:].strip() text = inspect.getdoc(function) code = inspect.getsource(function) return Section(title=title,text=text,figures=figures,tables=tables,code=code)
[docs]class Report(Section): """ Contains as main attribute a list of sections. Defines methods of outputting the sections. outfile should not include a final extension, as that is determined by the different output methods. """ def __init__(self,title,intro='',conclusion='',outname='',outfile=None,author=None,addTime=True,makeDir=False): """Create a report Note: If you are using a nested outname and the reports directory does not yet exist, makeDir can be set to True. Args: title (str): Report title. intro (str): Introduction. conclustion (str): Report concluding statements. outname (str): The base file name for the report, can include directories relative to reportDir. outfile (str, optional): A full filename (without extension) can also be provided to save report outside of reportDir. """ import time super().__init__(title=title,text=intro) self.sections = self.subs # Report sections can be accessed by both sections and subs attribute self.conclusion = conclusion.strip() self.outdir = os.path.dirname(outfile) if outfile else os.path.join(reportsDir,outname) self.outfile = outfile if outfile else os.path.join( self.outdir, '{}{}'.format(time.strftime('%Y_%m_%d'), '_'+outname if outname else '') ) self.author = author self.addTime = addTime #Fig and table ref management hidden variables self._fignr = count(1) self._tabnr = count(1)
[docs] def list(self): """ Get an overview of the report content list """ for i in range(len(self.sections)): self.sections[i].list(walkTrace=(i+1,))
[docs] def outputZip(self,figtype='png'): """ Outputs the report in a zip container. Figs and tabs as pngs and excells. Args: figtype (str): Figure type of images in the zip folder. """ from zipfile import ZipFile with ZipFile(self.outfile+'.zip', 'w') as zipcontainer: zipcontainer.writestr( 'summary.txt', '# {}\n\n{}\n{}'.format( self.title, self.p, ('\n## Conclusion\n' if self.conclusion else '')+self.conclusion ).encode() ) c = count(1) for section in self.sections: section.sectionOutZip(zipcontainer,'s{}_{}/'.format(next(c),section.title.replace(' ','_')), figtype=figtype)
[docs] def outputPDF(self,**kwargs): """Makes a pdf report with pylatex *kwargs* are send to doc.generate_pdf -> see pylatex.Document.generate_pdf for help """ import pylatex as pl geometry_options = {"tmargin": "2cm", "lmargin": "2cm"} doc = pl.Document(geometry_options=geometry_options) #Following option avoids float error when to many unplaced figs or tabs # (to force placing floats also \clearpage can be used after a section for example) doc.append(pl.utils.NoEscape(r'\extrafloats{100}')) doc.append(pl.utils.NoEscape(r'\title{'+self.title+'}')) if self.addTime: from time import localtime, strftime doc.append(pl.utils.NoEscape(r'\date{'+strftime("%Y-%m-%d %H:%M:%S", localtime())+r'}')) else: doc.append(pl.utils.NoEscape(r'\date{\today}')) if self.author: doc.append(pl.utils.NoEscape(r'\author{'+self.author+'}')) doc.append(pl.utils.NoEscape(r'\maketitle')) # Append introduction if self.p: with doc.create(pl.Section('Introduction')): doc.append(renewliner(self.p)) # Sections c = count(1) for section in self.sections: section.sectionsPDF(walkTrace=(next(c),),doc=doc) # Append conclusion if self.conclusion: with doc.create(pl.Section('Conclusion')): doc.append(renewliner(self.conclusion)) # Generate pdf doc.generate_pdf(self.outfile,**kwargs)
[docs] def outputWord(self): """Output report to word docx """ import docx from docx.enum.text import WD_ALIGN_PARAGRAPH doc = docx.Document() doc.styles['Normal'].paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY doc.add_heading(self.title, level=0) if self.addTime: from time import localtime, strftime doc.add_heading(strftime("%Y-%m-%d %H:%M:%S", localtime()), level=1) # Append introduction if self.p: doc.add_heading('Introduction',level=1) for p in renewliner(self.p).split('\n'): doc.add_paragraph(p) # Sections c = count(1) #Prepare fig and table numbers self.listFigures(tuple()) self.listTables(tuple()) for section in self.sections: section.sectionsWord((next(c),),doc=doc) # Append conclusion if self.conclusion: doc.add_heading('Conclusion', level=1) for p in renewliner(self.conclusion).split('\n'): doc.add_paragraph(p) # Generate Word document doc.save(self.outfile+'.docx')
[docs] @staticmethod def getReportTable(reportzipfile,tablefilename,inReportsDir=True,verbose=False): """Get a pandas table from a previous report Args: reportzipfile (str): Zip folder location, '.zip' extension is optional. tablefilename (str or list int): Table location within the zip folder. Can be provided as the filename within the zip folder, or a list of integers indicating its exact position (1-indexed). If you provide an empty string or list, all available table filenames in the zip folder will be printed. inReportsDir (bool): Search reportzipfile relative to reportsDir. Returns: pd.DataFrame """ import zipfile, io, re # zipfilename preparation if not reportzipfile.endswith('.zip'): reportzipfile+='.zip' if inReportsDir: reportzipfile = os.path.join(reportsDir,reportzipfile) with zipfile.ZipFile(reportzipfile) as z: # print all table filenames if tablefilename is not provided if not tablefilename: for f in z.filelist: if 'table' in f.filename: print(f.filename) return # tablefilename preparation if int list if isinstance(tablefilename,list): tablelocation = tablefilename tablefilename = None location = re.compile(r'(s|table)(\d+)_') for f in z.filelist: if 'table' not in f.filename or f.filename.count('/') != (len(tablelocation)-1): continue if [int(location.match(s).groups()[1]) for s in f.filename.split('/')] == tablelocation: tablefilename = f.filename if verbose: print('Loading',tablefilename) break if tablefilename is None: raise FileNotFoundError('Table location not found in zip folder.') # read table with z.open(tablefilename) as f: ft = io.TextIOWrapper(f) return pd.read_csv(ft,index_col=0,sep=csvsep,decimal=csvdec)