#!/usr/bin/env python # -*- coding: utf-8 -*- """ Author: Bryon M. Elliott One annoying aspect of job opportunity hunting is that different organizations want your materials in different formats. Keeping up-to-date versions of each format horribly violates the DRY principle, and in practice it's actually quite difficult. Enough of that! I've finally written something that I needed way back at the beginning of my career. This script generates my resume, references, and skill list as PDF, HTML, text, and Word documents, and even uploads some of them to my web site so they can be viewed via URL. USES ==== dateutil: http://labix.org/python-dateutil python-docx: https://python-docx.readthedocs.org/ reportlab: http://www.reportlab.com/opensource/ TO-DO ===== * Put a "date generated" field in the footer. * Update to the latest ReportLab. "The very recent versions of ReportLab have ListFlowable and ListItem objects (check Chapter 9 of the current user guide)." * Create a job list document, including all data about each job, including related skills. * Improve the skill list. Use xlwt to generate a spreadsheet that's colour-coded (red/none/green) based on skill priority. """ # Make all string constants unicode. from __future__ import unicode_literals # Use print function instead of statement. from __future__ import print_function # Always use true division. from __future__ import division # Enable DeprecationWarning messages. import warnings warnings.simplefilter('default') import copy import datetime from functools import partial from itertools import chain import os from pprint import pformat, pprint # NOQA import textwrap from dateutil.parser import parse from dateutil.relativedelta import relativedelta import docx from docx.shared import Inches from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_RIGHT # NOQA from reportlab.lib.pagesizes import letter, landscape, portrait # NOQA from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import inch from reportlab.pdfgen import canvas from reportlab.platypus import Flowable, Image, KeepTogether # NOQA from reportlab.platypus.doctemplate import (BaseDocTemplate, Frame, IndexingFlowable, NextPageTemplate, PageTemplate, Paragraph) from reportlab.platypus.tables import Table, ParagraphStyle, TableStyle ################################################################################ # Module variables. (Mostly for convenience and/or code clarity.) ################################################################################ _today = datetime.date.today() _today_fmt = _today.strftime("%Y-%m-%d") _p = Paragraph ################################################################################ # Helper function(s). ################################################################################ def linkify(raw, urls): """ Convert certain text into a hotlink. """ for (fm, to) in urls.items(): to = to or fm to = '%s' % (to, fm) cooked = raw.replace(fm, to) raw = cooked return raw # def print_skills_initializer(job_list): # """ # Print the Python code to initialize the Skills list as they exist in the # passed job list. # # # Used to generate the initial code in employment_data.py, but no longer needed. # """ # # Harvest together all the skills. # skills = chain(*(j.skills for j in job_list)) # name_n_type = frozenset((s.name,s.type) for s in skills) # # Print the initializer. # print("skills = [") # for (skill_name, skill_type) in sorted(name_n_type, key=lambda nnt: nnt[0].lower()): # print('\t\t\tSkill("%s", "%s", 5,' % (skill_name, skill_type)) # print('\t\t\t\t\turl="",),') # print("\t\t\t]") ################################################################################ # Helper class(es). ################################################################################ class Agency(object): """ Encapsulation of a so-called "head-hunter" company. I intend to add more functionality later, but to begin with, I just need to capture the name. """ def __init__(self, company): # Capture the parameter(s). self.company = company class BasePDF(object): """ Common methods for all PDF documents. """ def __init__(self, data, output_path): # Capture the parameter(s). self.data = data self.output_path = output_path # First, create the overall document object. self.doc = BMFDocTemplate(data, self.output_path) def adjust_col_widths(self, content, col_headers=None): """ Adjust column widths to take column headers into account and to fit within the available content width. """ frame = self.doc.pageTemplates[-1].frames[-1] # Get the maximum and minimum horizontal space necessary for each of the columns. (min_widths, max_widths) = self.get_min_max_col_widths(content, col_headers) # Add some inter-column padding. max_widths = map(lambda x: x + 7, max_widths) min_widths = map(lambda x: x + 3, min_widths) # Fit within the content, either expanding or shrinking appropriately. num_cols = len(max_widths) total_width = sum(max_widths) if total_width == frame.width: # Rare, but it must still be handled. widths = max_widths elif total_width < frame.width: # Expand table: Split the extra space equally among the columns. extra = float(frame.width - total_width) / num_cols widths = map(lambda x: x + extra, max_widths) else: # Shrink: Remove the extra space proportionally fm all columns, # making sure that no column becomes narrower than its minimum. cols_at_min = [] widths = max_widths[:] while True: too_much = sum(widths) - frame.width if (too_much <= 0.0) or (len(cols_at_min) >= num_cols): break malleable_width = sum(w for i, w in enumerate(widths) if i not in cols_at_min) for (idx, width) in enumerate(widths): if idx in cols_at_min: continue widths[idx] = width - (too_much * (width / malleable_width)) if widths[idx] < min_widths[idx]: widths[idx] = min_widths[idx] cols_at_min.append(idx) return widths @staticmethod def available_fonts(canvas): """ Used when designing the output. Puts a sample of all available fonts on the background of each page. """ text = "One bright day in the middle of the night..." x = 1.8 * inch y = 2.7 * inch for font in canvas.getAvailableFonts(): canvas.setFont(font, 10) canvas.drawString(x, y, text) canvas.setFont("Helvetica", 10) canvas.drawRightString(x - 10, y, font + ":") y = y - 13 @staticmethod def draw_footer(canvas, doc): """ Low-level function to draw the footer on the page. """ canvas.saveState() # Page x of y. canvas.setStrokeColor(colors.black) canvas.setFillColor(colors.black) canvas.setFont("Helvetica", 8) if doc.pass_num > 1: canvas.line(doc.leftMargin, doc.bottomMargin, (doc.pagesize[0] - doc.rightMargin), doc.bottomMargin) canvas.drawCentredString( (doc.pagesize[0] / 2), (doc.bottomMargin / 2), "Page %d of %d" % (doc.page, doc.total_pages) ) canvas.restoreState() def draw_header(self, canvas, doc): """ Low-level function to draw the header on the page. """ canvas.saveState() canvas.setStrokeColor(colors.black) canvas.setFillColor(colors.black) canvas.line(doc.leftMargin, (doc.pagesize[1] - doc.topMargin), (doc.pagesize[0] - doc.rightMargin), (doc.pagesize[1] - doc.topMargin)) canvas.setFont("Times-Bold", 9) canvas.drawString( doc.leftMargin, (doc.pagesize[1] - (doc.topMargin * .85)), self.data.name ) canvas.setFont("Times-Roman", 9) canvas.drawRightString( (doc.pagesize[0] - doc.rightMargin), (doc.pagesize[1] - (doc.topMargin * .85)), self.data.title ) canvas.restoreState() def flowable_width(self, flowable): """ Returns the width of a flowable *IF* it doesn't wrap. """ # Initializations and convenience variables. canv = canvas.Canvas("dummy.pdf", pagesize=self.doc.pagesize) fontname = flowable.style.fontName fontsize = flowable.style.fontSize # Return the widest width. pieces = flowable.text.split("
") return max([canv.stringWidth(p, fontname, fontsize) for p in pieces]) def get_min_max_col_widths(self, row_data, col_headers=None): """ Return lists of minimum required column widths, and maximum desired column widths. """ # Initilization(s). num_cols = len(row_data[0]) max_widths = [0] * num_cols min_widths = [0] * num_cols # Include the column headings in the calculations, if provided. if col_headers: row_data = chain([col_headers], row_data) # Find min/max widths for each column. for (row_idx, row) in enumerate(row_data): for (col_idx, cell) in enumerate(row): lines = cell if isinstance(cell, (tuple, list)) else [cell] max_widths[col_idx] = max( max_widths[col_idx], max(self.flowable_width(L) for L in lines) ) wrapped_lines = [_p("
".join(L.text.split()), L.style) for L in lines] min_widths[col_idx] = max( min_widths[col_idx], max(self.flowable_width(L) for L in wrapped_lines) ) return (min_widths, max_widths) def page_setup(self, canvas, doc): """ Low-level function to set up each page before Platypus populates it. """ # Header. self.draw_header(canvas, doc) # Footer. BasePDF.draw_footer(canvas, doc) class BMFDocTemplate(BaseDocTemplate): """ Specialized behaviour for the Bryon & Mike PDFs. """ def __init__(self, data, *args, **kwargs): # Capture the parameter(s). self.data = data # Set the parameters the way *we* want. kwargs["pagesize"] = letter kwargs["showBoundary"] = 0 kwargs["leftMargin"] = (.5 * inch) kwargs["rightMargin"] = (.5 * inch) kwargs["topMargin"] = (.5 * inch) kwargs["bottomMargin"] = (.5 * inch) kwargs["allowSplitting"] = 1 kwargs["title"] = "%s Resume (%s)" % (self.data.name, _today_fmt) kwargs["author"] = self.data.name kwargs["subject"] = "%s's Resume as of %s" % (self.data.name, _today_fmt) kwargs["creator"] = self.data.name kwargs["pageCompression"] = 1 kwargs["_pageBreakQuick"] = 1 # Let the ancestor(s) do their magic. BaseDocTemplate.__init__(self, *args, **kwargs) # Initiate instance variables. self.pass_num = 0 self.section_index = 0 self.total_pages = 0 self.__logo_cache = {} # Register an onProgress notification handler. self.setProgressCallBack(partial(BMFDocTemplate.handle_onprogress, self)) def afterFlowable(self, flowable): """ Add the flowable to the document's "outline". """ if (not flowable) or (not hasattr(flowable, "style")): return if flowable.style.name == "Section_Style": self.section_index += 1 key = "section_%d" % self.section_index self.canv.bookmarkPage(key, fit="XYZ", left=self.frame._x, top=(self.frame._y + flowable.height) ) self.canv.addOutlineEntry(flowable.getPlainText(), key, 0, False) if flowable.style.name == "Company_Style": self.section_index += 1 key = "subsection_%d" % self.section_index self.canv.bookmarkPage(key, fit="XYZ", left=self.frame._x, top=(self.frame._y + flowable.height) ) self.canv.addOutlineEntry(flowable.getPlainText(), key, 1, False) def handle_onprogress(self, typ, value): """ Handle multiBuild's onProgress notifications. """ if typ == "PASS": self.notify(typ, value) self.pass_num = value elif typ == "PAGE": if value > self.total_pages: self.total_pages = value class Data(object): """ Encapsulation of one person's data. It's just an unenhanced object class so that employment_data.py can dynamically set its properties. """ pass class ForceBuildPasses(IndexingFlowable): """ Invisible flowable whose only purpose is to cause multiBuild to make at least a certain number of passes. Requires that you use multiBuild() instead of build(). """ def __init__(self, passes, *args, **kwargs): # Capture the parameter(s). self._passes = passes # Initialize the instance variables. self._build_pass = 0 # Let the ancestor(s) do their magic. IndexingFlowable.__init__(self, *args, **kwargs) def isSatisfied(self): return (self._build_pass >= self._passes) def notify(self, kind, stuff): """ This will be called by the framework wherever 'stuff' happens. 'kind' will be a value that can be used to decide whether to pay attention or not. """ if kind == "PASS": self._build_pass = stuff def draw(self): pass def wrap(self, availWidth, availHeight): return (0, 0) class Job(object): """ Encapsulation of a job. """ def __init__(self, company, title, location, start, end, desc, via=None, summarized=False): # Capture the parameter(s). self.company = company self.title = title self.location = location self.start = start self.end = end self.desc = desc self.via = via self.summarized = summarized # Object initialization(s). self.skills = [] if isinstance(start, basestring): self.start_d = parse(start).date().replace(day=1) else: self.start_d = start if end == "Present": self.end_d = _today elif isinstance(end, basestring): self.end_d = parse(end).date() + relativedelta(day=31) else: self.end_d = end self.duration = self.end_d - self.start_d def add_skill(self, skill, versions=None, duration=None): """ Add the passed skill to this job's list of skills. """ skill.duration += duration or self.duration skill.last_used = max(skill.last_used, self.end_d) if versions: if not isinstance(versions, (tuple, list)): versions = [versions] for version in versions: if (version not in skill.versions): skill.versions.append(version) self.skills.append(skill) @property def end_year(self): if self.end == "Present": return self.end elif isinstance(self.end, datetime.date): return str(self.end.year) else: return self.end[:4] @property def end_mon_year(self): if self.end == "Present": return self.end elif isinstance(self.end, datetime.date): return self.end.strftime("%b %Y") else: return datetime.date(int(self.end[:4]), int(self.end[5:7]), 1).strftime("%b %Y") @property def start_mon_year(self): if isinstance(self.start, datetime.date): return self.start.strftime("%b %Y") else: return datetime.date(int(self.start[:4]), int(self.start[5:7]), 1).strftime("%b %Y") @property def start_year(self): if isinstance(self.start, datetime.date): return str(self.start.year) else: return self.start[:4] class Reference(object): """ Encapsulation of a personal or professional reference. """ def __init__(self, name, relationship, email, phone): # Capture the parameter(s). self.name = name self.relationship = relationship self.email = email self.phone = phone class Skill(object): """ Encapsulation of a skill used on a job. """ def __init__(self, name, type_, priority=5, url="", versions=None): # Capture the parameter(s). """ Note: Higher priorities denote higher importance. 0 == ignore. """ self.name = name self.type = type_ self.priority = priority self.url = url self.versions = versions or [] # Object initialization(s). self.duration = datetime.timedelta() self.last_used = datetime.date.min def __repr__(self): version = " %s" % self.versions[-1] if self.versions else "" rd = relativedelta(_today, _today - self.duration) d = " (%d years, %d months, %d days)" % (rd.years, rd.months, rd.days) last_used = getattr(self, "last_used", "") last_used = " Last used: %s" % last_used.isoformat() if last_used else "" return "%s%s (%s)%s%s" % (self.name, version, self.type, d, last_used) def __unicode__(self): return self.__repr__() @property def name_ver(self): return "%s %s" % (self.name, self.versions[-1]) if self.versions else self.name ################################################################################ # Main class(es). ################################################################################ class ReferencesPDF(BasePDF): """ Encapsulation of the PDF version of the List of References. """ def __init__(self, data, output_path): # Let the ancestor(s) do their magic. super(ReferencesPDF, self).__init__(data, output_path) # ReportLab Styles. # Built-in: BodyText Bullet Code Definition Heading1 Heading2 Heading3 # Heading4, Heading5 Heading6 Italic Normal Title stylesheet = getSampleStyleSheet() self.refs_head_style = ParagraphStyle( "Skills_Head_Style", stylesheet['Normal'], fontName="Times-Bold", fontSize=stylesheet['Normal'].fontSize + 2, textColor="navy", ) self.refs_style = ParagraphStyle( "Refs_Style", stylesheet['Normal'], fontName="Times-Roman", ) self.section_style = ParagraphStyle( "Section_Style", stylesheet['Heading3'], allowWidows=False, fontName="Helvetica-BoldOblique", spaceAfter=.05 * inch, spaceBefore=0, textColor="navy", ) # Working from the bottom up, create the frames which our content will be flowed into. later_frame = Frame( self.doc.leftMargin, self.doc.bottomMargin, self.doc.width, self.doc.height, leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0.1 * inch, id="later_frame", showBoundary=False, ) # Create the page templates. later_page = PageTemplate( id="later_page", pagesize=self.doc.pagesize, frames=[later_frame], onPage=self.page_setup, ) # Finally, add the page templates to the document. self.doc.addPageTemplates((later_page,)) def generate(self): """ Create the PDF file. """ # Initialization(s). story = [] # Force two passes so that the "Page x of y" stuff works. story.append(ForceBuildPasses(2)) # Add the References story.append(_p("References", self.section_style)) story.append(self.get_refs_table()) print("Building %s" % self.output_path) self.doc.multiBuild(story) def get_refs_table(self): """ Return the table that goes at the top of the first page of the resume. """ # Add the table headers. table_data = [ [ _p("Name", self.refs_head_style), _p("Relationship", self.refs_head_style), _p("Contact Information", self.refs_head_style), ], ] # Add each reference. for ref in self.data.references: table_data.append([ _p(ref.name, self.refs_style), _p(ref.relationship, self.refs_style), [_p(ref.email, self.refs_style), _p(ref.phone, self.refs_style)], ]) # Create the table. table_style = TableStyle([ ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('GRID', (0, 0), (-1, -1), 1.0, colors.black), ('VALIGN', (0, 0), (0, -1), 'BOTTOM'), ('VALIGN', (0, 1), (-1, -1), 'TOP'), ]) column_widths = self.adjust_col_widths(table_data) table = Table(table_data, colWidths=column_widths, rowHeights=None, style=table_style, splitByRow=0, repeatRows=0, repeatCols=0, ) return table class ReferencesWord(object): """ Encapsulation of the Word (.docx) version of the resume. """ def __init__(self, data, output_path): # Capture the parameter(s). self.data = data self.output_path = output_path # Object initialization(s). self.doc = None def add_refs_table(self): """ Add the table of references. """ # Initialization(s). col_width = Inches(2.52) # Break skills into types. by_type = {} for skill in self.data.skills.itervalues(): if skill.priority > 1: # Skip the 0 and 1 priority skills, categorize the rest. by_type.setdefault(skill.type, []).append(skill) # Create the table. table = self.doc.add_table(rows=1, cols=3, style="TableRefs") table.autofit = False # Add the table headers. cell = table.cell(0, 0) cell.width = col_width cell.text = "Name" cell.paragraphs[0].style = "Refs_Head_Style" cell = table.cell(0, 1) cell.width = col_width cell.text = "Relationship" cell.paragraphs[0].style = "Refs_Head_Style" cell = table.cell(0, 2) cell.width = col_width cell.text = "Contact Information" cell.paragraphs[0].style = "Refs_Head_Style" # Add each reference. for ref in self.data.references: row = table.add_row() row.cells[0].text = ref.name row.cells[0].paragraphs[0].style = "Refs_Style" row.cells[1].text = ref.relationship row.cells[0].paragraphs[0].style = "Refs_Style" row.cells[2].text = "\n".join(filter(None, [ref.email, ref.phone])) row.cells[0].paragraphs[0].style = "Refs_Style" def generate(self): """ Create the Word .docx file. """ # Create the new document. self.doc = docx.Document("references_template.docx") section = self.doc.sections[0] section.left_margin = Inches(.5) section.right_margin = Inches(.5) section.top_margin = Inches(.5) section.bottom_margin = Inches(.5) #section.header_distance = Inches(.5) #section.footer_distance = Inches(.5) # Add content. self.doc.add_paragraph("References", style='Section_Style') self.add_refs_table() # Save it. print("Building %s" % self.output_path) self.doc.save(self.output_path) class ResumeHTML(object): """ Encapsulation of the HTML version of the resume. """ def __init__(self, data, output_path, full): # Capture the parameter(s). self.data = data self.output_path = output_path self.full_version = full def generate(self): """ Create the HTML file. """ # Add the initial tags. story = [ '', '', '', '\t', '\t' % os.path.basename(self.output_path), '\t' % self.data.name, '\t' % (_today.year, self.data.name), '\t%s - %s' % (self.data.name, self.data.title), '\t', ] story.append(self.get_css()) story += ['', ''] # Introduce myself. story.append('\t

%s

' % self.data.name) story.append('\t

%s

' % self.data.title) story.append('\t

%s

' % "
".join((self.data.addr1, self.data.addr2, self.data.email, self.data.phone, ) ) ) story.append('\t

Objective

') story.append('\t

%s

' % self.data.objective) story.append('\t

Summary of Qualifications

') summary = self.data.summary_long if self.full_version else self.data.summary for paragraph in summary.split("\n"): story.append('\t

%s

' % linkify(paragraph.strip(), self.data.urls)) # Skills Summary. story.append('\t

Technical Skills

') story += self.get_skills() # Professional Experience. story.append('\t

Professional Experience

') story += self.get_jobs() # Earlier Professional Experience. if self.data.earlier_prof_exp and not self.full_version: story.append('\t

Earlier Professional Experience

') for paragraph in self.data.earlier_prof_exp.split("\n"): story.append('\t

%s

' % linkify(paragraph.strip(), self.data.urls) ) # Static bottom-stuff story.append('\t

Education

') for paragraph in self.data.education.split("\n"): story.append('\t

%s

' % paragraph.strip()) if self.data.comm_service: story.append('\t

Community Service

\n\t

%s

' % self.data.comm_service) story += ['', ''] print("Building %s" % self.output_path) html_file = open(self.output_path, "w") html_file.write("\r\n".join(story)) html_file.close() def get_css(self): """ Return the style metadata. """ return textwrap.dedent("""\ """) def get_jobs(self): """ Return the list of job experiences. """ # Initialization(s). novella = [] # Add each job. for job in sorted(self.data.jobs, reverse=True, key=lambda j: (j.end_year, j.start_year)): if (not self.full_version) and job.summarized: # This isn't the full version of the resume, and this job has been summarized # into the "Earlier Professional Experiences" section, so don't include it here. continue company = job.company if job.via: company += ' (via %s)' % job.via.company novella.append('\t
') novella.append('\t\t

%s

' % job.title) novella.append('\t\t

%s

' % "%s - %s" % (job.start_mon_year, job.end_mon_year) ) novella.append('\t\t

%s

' % linkify(company, self.data.urls)) novella.append('\t\t

%s

' % job.location) desc = job.desc for emph in self.data.emphasize: desc = desc.replace(emph, "%s" % emph) for line in desc.split("\n"): if line.startswith("* "): novella.append('\t\t' % line[2:]) else: novella.append('\t\t

%s

' % line) novella.append('\t
') return novella def get_skills(self): """ Return the summary of skills. """ # Initialization(s). key_fn = lambda s: (s.priority, s.last_used, s.duration, s.name_ver.lower()) novella = [ '\t', '\t\t', ] # Break skills into types. by_type = {} for skill in self.data.skills.itervalues(): if skill.priority > 1: # Skip the 0 and 1 priority skills, categorize the rest. by_type.setdefault(skill.type, []).append(skill) # Populate the table. llf = sorted(by_type["language"] + by_type["library"], reverse=True, key=key_fn) db = sorted(by_type["database"], reverse=True, key=key_fn) std = sorted(by_type["markup"] + by_type["technology"], reverse=True, key=key_fn) os = sorted(by_type["os"], reverse=True, key=key_fn) oth = sorted(by_type["tool"] + by_type["source control"], reverse=True, key=key_fn) novella.append('\t\t\t') novella.append('\t\t\t\t') novella.append('\t\t\t\t' % ", ".join([s.name_ver for s in llf])) novella.append('\t\t\t') novella.append('\t\t\t') novella.append('\t\t\t\t') novella.append('\t\t\t\t' % ", ".join([s.name_ver for s in db])) novella.append('\t\t\t') novella.append('\t\t\t') novella.append('\t\t\t\t') novella.append('\t\t\t\t' % ", ".join([s.name_ver for s in std])) novella.append('\t\t\t') novella.append('\t\t\t') novella.append('\t\t\t\t') novella.append('\t\t\t\t' % ", ".join([s.name_ver for s in os])) novella.append('\t\t\t') novella.append('\t\t\t') novella.append('\t\t\t\t') novella.append('\t\t\t\t' % ", ".join([s.name_ver for s in oth])) novella.append('\t\t\t') novella.append('\t\t') novella.append("\t
Languages, Libraries & Frameworks:%s
Databases:%s
Standards:%s
Operating Systems:%s
Other:%s
") return novella class ResumePDF(BasePDF): """ Encapsulation of the PDF version of the resume. """ def __init__(self, data, output_path, full): # Let the ancestor(s) do their magic. super(ResumePDF, self).__init__(data, output_path) # Capture the parameter(s). self.full_version = full # ReportLab Styles. # Built-in: BodyText Bullet Code Definition Heading1 Heading2 Heading3 # Heading4, Heading5 Heading6 Italic Normal Title stylesheet = getSampleStyleSheet() self.company_style = ParagraphStyle( "Company_Style", stylesheet['Normal'], fontName="Times-Italic", spaceAfter=0, spaceBefore=0, ) self.contact_style = ParagraphStyle( "Contact_Info_Style", stylesheet['Normal'], alignment=TA_RIGHT, allowWidows=False, fontSize=8, leading=stylesheet['Normal'].leading - 2, spaceAfter=0, spaceBefore=0, ) self.date_style = ParagraphStyle( "Date_Range_Style", stylesheet['Normal'], alignment=TA_RIGHT, fontName="Times-BoldItalic", spaceAfter=0, spaceBefore=0, ) self.job_desc_style = ParagraphStyle( "Job_Desc_Style", stylesheet['Normal'], bulletColor="navy", bulletFontName="Symbol", bulletIndent=0, bulletFontSize=8, fontName="Times-Roman", fontSize=8, leading=stylesheet['Normal'].leading - 2, spaceAfter=2, spaceBefore=0, ) self.job_title_style = ParagraphStyle( "Job_Title_Style", stylesheet['Heading4'], allowWidows=False, fontName="Times-BoldItalic", fontSize=12, spaceAfter=0, spaceBefore=0, ) self.location_style = ParagraphStyle( "Location_Style", stylesheet['Normal'], alignment=TA_RIGHT, fontName="Times-Roman", spaceAfter=0, spaceBefore=0, ) self.name_style = ParagraphStyle( "Name_Style", stylesheet['Heading1'], allowWidows=False, borderPadding=0, borderWidth=0, fontName="Helvetica-Bold", fontSize=20, spaceAfter=0, spaceBefore=0, textColor="navy", ) self.normal_style = ParagraphStyle( "Normal_Style", stylesheet['Normal'], fontName="Times-Roman", spaceAfter=.1 * inch, ) self.section_style = ParagraphStyle( "Section_Style", stylesheet['Heading3'], allowWidows=False, fontName="Helvetica-BoldOblique", spaceAfter=.05 * inch, spaceBefore=.15 * inch, textColor="navy", ) self.skill_head_style = ParagraphStyle( "Skills_Head_Style", stylesheet['Normal'], alignment=TA_RIGHT, fontName="Times-Bold", ) self.skills_style = ParagraphStyle( "Skills_Style", stylesheet['Normal'], fontName="Times-Roman", fontSize=9, ) self.title_style = ParagraphStyle( "Title_Style", stylesheet['Heading2'], allowWidows=False, fontName="Helvetica-Oblique", fontSize=15, spaceAfter=0, spaceBefore=.03 * inch, textColor="navy", ) # Working from the bottom up, create the frames which our content will be flowed into. first_frame = Frame(self.doc.leftMargin, self.doc.bottomMargin, self.doc.width, self.doc.height + (self.doc.topMargin * .25), leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0, id="first_frame", showBoundary=False, ) later_frame = Frame(self.doc.leftMargin, self.doc.bottomMargin, self.doc.width, self.doc.height, leftPadding=0, bottomPadding=0, rightPadding=0, topPadding=0, id="later_frame", showBoundary=False, ) # Create the page templates. first_page = PageTemplate(id="first_page", pagesize=self.doc.pagesize, frames=[first_frame], onPage=BasePDF.draw_footer, ) later_page = PageTemplate(id="later_page", pagesize=self.doc.pagesize, frames=[later_frame], onPage=self.page_setup, ) # Finally, add the page templates to the document. self.doc.addPageTemplates((first_page, later_page,)) def generate(self): """ Create the PDF file. """ # Add the report title to the PDF. story = [self.get_top_table()] # Force two passes so that the "Page x of y" stuff works. story.append(ForceBuildPasses(2)) # Now that we've got something on the first page, make sure the # remaining pages use the non-first page template. story.append(NextPageTemplate('later_page')) # Static top-stuff. story += [ _p("Objective", self.section_style), _p(self.data.objective, self.normal_style), _p("Summary of Qualifications", self.section_style), ] summary = self.data.summary_long if self.full_version else self.data.summary for paragraph in summary.split("\n"): story.append(_p(linkify(paragraph.strip(), self.data.urls), self.normal_style)) # Skills Summary story.append(_p("Technical Skills", self.section_style)) story.append(self.get_skills_table()) # Professional Experience. story.append(_p("Professional Experience", self.section_style)) story += self.get_jobs() # Earlier Professional Experience. if self.data.earlier_prof_exp and not self.full_version: story.append(_p("Earlier Professional Experience", self.section_style)) for paragraph in self.data.earlier_prof_exp.split("\n"): story.append(_p(linkify(paragraph.strip(), self.data.urls), self.normal_style)) # Static bottom-stuff kt = [_p("Education", self.section_style)] for paragraph in self.data.education.split("\n"): kt.append(_p(paragraph.strip(), self.normal_style)) story.append(KeepTogether(kt)) if self.data.comm_service: story.append(KeepTogether([ _p("Community Service", self.section_style), _p(self.data.comm_service, self.normal_style), ])) print("Building %s" % self.output_path) self.doc.multiBuild(story) def get_jobs(self): """ Return the list of job experiences. """ # Initialization(s). novella = [] table_style = TableStyle([ ('BOTTOMPADDING', (0, 0), (-1, -1), 0), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 0), ('BOTTOMPADDING', (-1, -1), (-1, -1), 0.02 * inch), ('TOPPADDING', (0, 0), (0, 0), .04 * inch), ('VALIGN', (0, 0), (-1, 0), 'BOTTOM'), ('VALIGN', (0, -1), (-1, -1), 'TOP'), ]) # Add each job. for job in sorted(self.data.jobs, reverse=True, key=lambda j: (j.end_year, j.start_year)): if (not self.full_version) and job.summarized: # This isn't the full version of the resume, and this job has been summarized # into the "Earlier Professional Experiences" section, so don't include it here. continue date_range = "%s - %s" % (job.start_mon_year, job.end_mon_year) company = job.company if job.via: company += ' (via %s)' % job.via.company table_data = [ [ _p(job.title, self.job_title_style), _p(date_range, self.date_style), ], [ _p(linkify(company, self.data.urls), self.company_style), _p(job.location, self.location_style), ], ] frame = self.doc.pageTemplates[-1].frames[-1] column_widths = [frame.width * .6, frame.width * .4] table = Table(table_data, colWidths=column_widths, rowHeights=None, style=table_style, splitByRow=0, repeatRows=0, repeatCols=0, ) desc = job.desc for emph in self.data.emphasize: desc = desc.replace(emph, "%s" % emph) desc_lines = [] for line in desc.split("\n"): if line.startswith("* "): desc_lines.append(_p(text=line[2:], bulletText="•", style=self.job_desc_style)) else: desc_lines.append(_p(line, self.job_desc_style)) novella.append(KeepTogether([table] + desc_lines)) return novella def get_skills_table(self): """ Return the table of skills. """ # Initialization(s). key_fn = lambda s: (s.priority, s.last_used, s.duration, s.name_ver.lower()) # Break skills into types. by_type = {} for skill in self.data.skills.itervalues(): if skill.priority > 1: # Skip the 0 and 1 priority skills, categorize the rest. by_type.setdefault(skill.type, []).append(skill) # Populate the table. llf = sorted(by_type["language"] + by_type["library"], reverse=True, key=key_fn) llf = ", ".join([s.name_ver for s in llf]) db = sorted(by_type["database"], reverse=True, key=key_fn) db = ", ".join([s.name_ver for s in db]) std = sorted(by_type["markup"] + by_type["technology"], reverse=True, key=key_fn) std = ", ".join([s.name_ver for s in std]) os = sorted(by_type["os"], reverse=True, key=key_fn) os = ", ".join([s.name_ver for s in os]) oth = sorted(by_type["tool"] + by_type["source control"], reverse=True, key=key_fn) oth = ", ".join([s.name_ver for s in oth]) table_data = [ [ _p("Languages, Libraries & Frameworks:", self.skill_head_style), _p(llf, self.skills_style), ], [_p("Databases:", self.skill_head_style), _p(db, self.skills_style)], [_p("Standards:", self.skill_head_style), _p(std, self.skills_style)], [_p("Operating Systems:", self.skill_head_style), _p(os, self.skills_style)], [_p("Other:", self.skill_head_style), _p(oth, self.skills_style)], ] # Create the table. table_style = TableStyle([ ('GRID', (0, 0), (-1, -1), 0.25, colors.black), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ]) frame = self.doc.pageTemplates[-1].frames[-1] column_widths = [frame.width * .2, frame.width * .8] table = Table(table_data, colWidths=column_widths, rowHeights=None, style=table_style, splitByRow=0, repeatRows=0, repeatCols=0, ) return table def get_top_table(self): """ Return the table that goes at the top of the first page of the resume. """ table_data = [[ [ _p(self.data.name, self.name_style), _p(self.data.title, self.title_style), ], [ _p(self.data.addr1, self.contact_style), _p(self.data.addr2, self.contact_style), _p(self.data.email, self.contact_style), _p(self.data.phone, self.contact_style), ], ]] table_style = TableStyle([ ('LINEBELOW', (0, 0), (-1, 0), 1.0, colors.black), ('LEFTPADDING', (0, 0), (-1, -1), 0), ('RIGHTPADDING', (0, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (0, 0), 0), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ]) frame = self.doc.pageTemplates[-1].frames[-1] column_widths = [frame.width / 2, frame.width / 2] table = Table(table_data, colWidths=column_widths, rowHeights=None, style=table_style, splitByRow=0, repeatRows=0, repeatCols=0, ) return table class ResumeTXT(object): """ Encapsulation of the text version of the resume. """ width = 100 def __init__(self, data, output_path, full): # Capture the parameter(s). self.data = data self.output_path = output_path self.full_version = full def generate(self): """ Create the text file. """ # Add the stuff at the top of the resume. story = self.get_top_stuff() # Static top-stuff. story += ["OBJECTIVE", "=========", self.data.objective, ""] story += ["SUMMARY OF QUALIFICATIONS", "========================="] summary = self.data.summary_long if self.full_version else self.data.summary story.append(summary.strip().replace("\n", "\r\n\r\n")) story.append("") # Skills Summary story += ["TECHNICAL SKILLS", "================"] story += self.get_skills() # Professional Experience. story += ["PROFESSIONAL EXPERIENCE", "======================="] story += self.get_jobs() # Earlier Professional Experience. if self.data.earlier_prof_exp and not self.full_version: story += ["EARLIER PROFESSIONAL EXPERIENCE", "==============================="] story.append(self.data.earlier_prof_exp.strip().replace("\n", "\r\n\r\n")) story.append("") # Static bottom-stuff story += ["EDUCATION", "=========", ] story.append(self.data.education.strip().replace("\n", "\r\n\r\n")) story.append("") if self.data.comm_service: story += ["COMMUNITY SERVICE", "=================", self.data.comm_service, ""] story.append("") print("Building %s" % self.output_path) text_file = open(self.output_path, "w") text_file.write("\r\n".join(story)) text_file.close() def get_jobs(self): """ Return the list of job experiences. """ # Initialization(s). novella = [] # Add each job. for job in sorted(self.data.jobs, reverse=True, key=lambda j: (j.end_year, j.start_year)): if (not self.full_version) and job.summarized: # This isn't the full version of the resume, and this job has been summarized # into the "Earlier Professional Experiences" section, so don't include it here. continue date_range = "%s - %s" % (job.start_mon_year, job.end_mon_year) company = job.company + " (via %s)" % job.via.company if job.via else job.company gap = self.width - len(job.title) - len(date_range) novella.append(job.title + " " * gap + date_range) gap = self.width - len(job.title) - len(date_range) novella.append("-" * len(job.title) + " " * gap + "-" * len(date_range)) gap = self.width - len(company) - len(job.location) novella.append(company + " " * gap + job.location) novella.append(job.desc.strip().replace("\n", "\r\n\r\n")) novella.append("") return novella def get_skills(self): """ Return the skills summary section. """ # Initialization(s). key_fn = lambda s: (s.priority, s.last_used, s.duration, s.name_ver.lower()) # Break skills into types. by_type = {} for skill in self.data.skills.itervalues(): if skill.priority > 1: # Skip the 0 and 1 priority skills, categorize the rest. by_type.setdefault(skill.type, []).append(skill) # Populate the table. llf = sorted(by_type["language"] + by_type["library"], reverse=True, key=key_fn) db = sorted(by_type["database"], reverse=True, key=key_fn) std = sorted(by_type["markup"] + by_type["technology"], reverse=True, key=key_fn) os = sorted(by_type["os"], reverse=True, key=key_fn) oth = sorted(by_type["tool"] + by_type["source control"], reverse=True, key=key_fn) novella = ["Languages, Libraries & Frameworks", "---------------------------------"] novella.append(", ".join([s.name_ver for s in llf])) novella += ["", "Databases", "---------"] novella.append(", ".join([s.name_ver for s in db])) novella += ["", "Standards", "---------"] novella.append(", ".join([s.name_ver for s in std])) novella += ["", "Operating Systems", "-----------------"] novella.append(", ".join([s.name_ver for s in os])) novella += ["", "Other", "-----"] novella.append(", ".join([s.name_ver for s in oth])) novella.append("") return novella def get_top_stuff(self): """ Return the table that goes at the top of the first page of the resume. """ gap = self.width - len(self.data.name) - len(self.data.addr1) novella = [self.data.name.upper() + " " * gap + self.data.addr1] gap = self.width - len(self.data.title) - len(self.data.addr2) novella.append(self.data.title + " " * gap + self.data.addr2) gap = self.width - len(self.data.email) novella.append(" " * gap + self.data.email) gap = self.width - len(self.data.phone) novella.append(" " * gap + self.data.phone) novella.append("=" * self.width) return novella class ResumeWord(object): """ Encapsulation of the Word (.docx) version of the resume. """ def __init__(self, data, output_path, full): # Capture the parameter(s). self.data = data self.output_path = output_path self.full_version = full # Object initialization(s). self.doc = None def add_jobs(self): """ Add the job experience. """ # Add each job. for job in sorted(self.data.jobs, reverse=True, key=lambda j: (j.end_year, j.start_year)): if (not self.full_version) and job.summarized: # This isn't the full version of the resume, and this job has been summarized # into the "Earlier Professional Experiences" section, so don't include it here. continue date_range = "%s - %s" % (job.start_mon_year, job.end_mon_year) table = self.doc.add_table(rows=2, cols=2, style="TableJobHeader") table.autofit = False cell = table.cell(0, 0) cell.width = Inches(3.78) cell.text = job.title cell.paragraphs[0].style = "Job_Title_Style" cell = table.cell(0, 1) cell.width = Inches(3.78) cell.text = date_range cell.paragraphs[0].style = "Date_Range_Style" cell = table.cell(1, 0) cell.width = Inches(3.78) cell.text = job.company cell.paragraphs[0].style = "Company_Style" if job.via: cell.paragraphs[0].add_run(" (via %s)" % job.via.company, "Company_Via_Style") cell = table.cell(1, 1) cell.width = Inches(3.78) cell.text = job.location cell.paragraphs[0].style = "Location_Style" desc = job.desc for line in desc.split("\n"): if not line.strip(): continue if line.startswith("* "): self.doc.add_paragraph(line[2:], style='Job_Desc_List_Style') else: self.doc.add_paragraph(line, style='Job_Desc_Style') def add_skills_table(self): """ Add the table of skills. """ # Initialization(s). key_fn = lambda s: (s.priority, s.last_used, s.duration, s.name_ver.lower()) # Break skills into types. by_type = {} for skill in self.data.skills.itervalues(): if skill.priority > 1: # Skip the 0 and 1 priority skills, categorize the rest. by_type.setdefault(skill.type, []).append(skill) # Create the table. table = self.doc.add_table(rows=5, cols=2, style="TableSkills") table.autofit = False # Populate the table. cell = table.cell(0, 0) cell.width = Inches(3.5) # 8.9 cm cell.text = "Languages, Libraries & Frameworks:" cell.paragraphs[0].style = "Skills_Head_Style" cell = table.cell(0, 1) cell.width = Inches(4.06) # 10.30 cm llf = sorted(by_type["language"] + by_type["library"], reverse=True, key=key_fn) cell.text = ", ".join([s.name_ver for s in llf]) cell.paragraphs[0].style = "Skills_Style" cell = table.cell(1, 0) cell.text = "Databases:" cell.paragraphs[0].style = "Skills_Head_Style" cell = table.cell(1, 1) db = sorted(by_type["database"], reverse=True, key=key_fn) cell.text = ", ".join([s.name_ver for s in db]) cell.paragraphs[0].style = "Skills_Style" cell = table.cell(2, 0) cell.text = "Standards:" cell.paragraphs[0].style = "Skills_Head_Style" cell = table.cell(2, 1) std = sorted(by_type["markup"] + by_type["technology"], reverse=True, key=key_fn) cell.text = ", ".join([s.name_ver for s in std]) cell.paragraphs[0].style = "Skills_Style" cell = table.cell(3, 0) cell.text = "Operating Systems:" cell.paragraphs[0].style = "Skills_Head_Style" cell = table.cell(3, 1) os = sorted(by_type["os"], reverse=True, key=key_fn) cell.text = ", ".join([s.name_ver for s in os]) cell.paragraphs[0].style = "Skills_Style" cell = table.cell(4, 0) cell.text = "Other:" cell.paragraphs[0].style = "Skills_Head_Style" cell = table.cell(4, 1) oth = sorted(by_type["tool"] + by_type["source control"], reverse=True, key=key_fn) cell.text = ", ".join([s.name_ver for s in oth]) cell.paragraphs[0].style = "Skills_Style" def add_top_stuff(self): """ Add the table at the top of the first page of the resume. """ table = self.doc.add_table(rows=1, cols=2, style="TableResumeHeader") table.autofit = False table.columns[0].cells[0].width = Inches(3.78) table.columns[1].cells[0].width = Inches(3.78) cell = table.cell(0, 0) cell.text = self.data.name cell.paragraphs[0].style = "Name_Style" cell.add_paragraph(self.data.title, style='Title_Style') cell = table.cell(0, 1) cell.text = self.data.addr1 cell.paragraphs[0].style = "Contact_Info_Style" cell.add_paragraph(self.data.addr2, style='Contact_Info_Style') cell.add_paragraph(self.data.email, style='Contact_Info_Style') cell.add_paragraph(self.data.phone, style='Contact_Info_Style') def generate(self): """ Create the Word .docx file. """ # Create the new document. self.doc = docx.Document("resume_template.docx") section = self.doc.sections[0] section.left_margin = Inches(.5) section.right_margin = Inches(.5) section.top_margin = Inches(.5) section.bottom_margin = Inches(.5) #section.header_distance = Inches(.5) #section.footer_distance = Inches(.5) # Add content. self.add_top_stuff() self.doc.add_paragraph("Objective", style='Section_Style') self.doc.add_paragraph(self.data.objective, style='Normal_Style') self.doc.add_paragraph("Summary of Qualifications", style='Section_Style') summary = self.data.summary_long if self.full_version else self.data.summary for paragraph in summary.split("\n"): self.doc.add_paragraph(paragraph, style='Normal_Style') self.doc.add_paragraph("Technical Skills", style='Section_Style') self.add_skills_table() self.doc.add_paragraph("Professional Experience", style='Section_Style') self.add_jobs() if self.data.earlier_prof_exp and not self.full_version: self.doc.add_paragraph("Earlier Professional Experience", style='Section_Style') for paragraph in self.data.earlier_prof_exp.split("\n"): self.doc.add_paragraph(paragraph, style='Normal_Style') self.doc.add_paragraph("Education", style='Section_Style') for paragraph in self.data.education.split("\n"): self.doc.add_paragraph(paragraph, style='Normal_Style') if self.data.comm_service: self.doc.add_paragraph("Community Service", style='Section_Style') self.doc.add_paragraph(self.data.comm_service, style='Normal_Style') # Save it. print("Building %s" % self.output_path) self.doc.save(self.output_path) ################################################################################ # Main function(s). ################################################################################ def generate_materials(data): """ Generate all job search materials. """ # Summarized resumes. ResumeHTML(data, data.resume_html_path, full=False).generate() ResumePDF(data, data.resume_pdf_path, full=False).generate() ResumeTXT(data, data.resume_txt_path, full=False).generate() ResumeWord(data, data.resume_word_path, full=False).generate() # Full resumes. if data.summary != data.summary_long: ResumeHTML(data, data.full_resume_html_path, full=True).generate() ResumePDF(data, data.full_resume_pdf_path, full=True).generate() ResumeTXT(data, data.full_resume_txt_path, full=True).generate() ResumeWord(data, data.full_resume_word_path, full=True).generate() # References. ReferencesPDF(data, data.refs_pdf_path).generate() ReferencesWord(data, data.refs_word_path).generate() # Skill lists. generate_txt_skill_list(data, data.skills_txt_path) def generate_txt_skill_list(data, output_path): """ Create the text version of the skill list. """ key_fn = lambda s: (s.priority, s.last_used, s.duration, s.name_ver.lower()) lines = ['Skill\t"Priority (10=top, 5=normal, 0/1=exclude)"\tType\tLast Used' '\tCumulative Duration\tVersions'] for skill in sorted(data.skills.values(), key=key_fn, reverse=True): #if not skill.priority: # continue rd = relativedelta(_today, _today - skill.duration) duration = "%d years, %d months, %d days" % (rd.years, rd.months, rd.days) versions = copy.copy(skill.versions) versions.reverse() lines.append("%s\t%d\t%s\t%s\t%s\t%s" % ( skill.name, skill.priority, skill.type, skill.last_used.isoformat(), duration, ", ".join(versions).strip(), )) print("Building %s" % output_path) text_file = open(output_path, "w") text_file.write("\r\n".join(lines)) text_file.close() def scatter_materials(data): """ Distribute stuff. """ # Avoiding circular imports. from employment_data import server_output_dir cmd = 'scp "%s" "%sgenerate_resume.txt"' % (__file__, server_output_dir) #print(cmd) os.system(cmd) cmd = 'scp "%s" "%s%s_%s_Resume.pdf"' \ % (data.resume_pdf_path, server_output_dir, data.first_name, data.last_name) os.system(cmd) cmd = 'scp "%s" "%s%s_%s_Resume.html"' \ % (data.resume_html_path, server_output_dir, data.first_name, data.last_name) os.system(cmd) if data.summary != data.summary_long: cmd = 'scp "%s" "%s%s_%s_Full_Resume.pdf"' \ % (data.full_resume_pdf_path, server_output_dir, data.first_name, data.last_name) os.system(cmd) cmd = 'scp "%s" "%s%s_%s_Full_Resume.html"' \ % (data.full_resume_html_path, server_output_dir, data.first_name, data.last_name) os.system(cmd) ################################################################################ # Main script. ################################################################################ if __name__ == '__main__': # Avoiding circular imports. # from employment_data import bryon, mike from employment_data import bryon # Do each of us. # for data in (bryon, mike): for data in (bryon,): # if data == mike: # # Skip until Mike's data is filled in. # continue # Set destination files. _firstlast = "%s%s" % (data.first_name, data.last_name) data.full_resume_html_path = "%s%sFullResume_%s.html" % \ (data.output_dir, _firstlast, _today_fmt) data.full_resume_pdf_path = "%s%sFullResume_%s.pdf" % \ (data.output_dir, _firstlast, _today_fmt) data.full_resume_txt_path = "%s%sFullResume_%s.txt" % \ (data.output_dir, _firstlast, _today_fmt) data.full_resume_word_path = "%s%sFullResume_%s.docx" % \ (data.output_dir, _firstlast, _today_fmt) data.refs_pdf_path = "%s%sReferences_%s.pdf" % (data.output_dir, _firstlast, _today_fmt) data.refs_word_path = "%s%sReferences_%s.docx" % (data.output_dir, _firstlast, _today_fmt) data.resume_html_path = "%s%sResume_%s.html" % (data.output_dir, _firstlast, _today_fmt) data.resume_pdf_path = "%s%sResume_%s.pdf" % (data.output_dir, _firstlast, _today_fmt) data.resume_txt_path = "%s%sResume_%s.txt" % (data.output_dir, _firstlast, _today_fmt) data.resume_word_path = "%s%sResume_%s.docx" % (data.output_dir, _firstlast, _today_fmt) data.skills_txt_path = "%s%sSkills_%s.csv" % (data.output_dir, _firstlast, _today_fmt) # Generate all job search materials. generate_materials(data) # Distribute particular search materials. scatter_materials(data)