#!/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 = [
'',
'',
'
%s
' % self.data.title) story.append('\t%s
' % "%s
' % self.data.objective) story.append('\t%s
' % linkify(paragraph.strip(), self.data.urls)) # Skills Summary. story.append('\t%s
' % linkify(paragraph.strip(), self.data.urls) ) # Static bottom-stuff story.append('\t%s
' % paragraph.strip()) if self.data.comm_service: story.append('\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%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%s
' % line) novella.append('\t| Languages, Libraries & Frameworks: | ') novella.append('\t\t\t\t%s | ' % ", ".join([s.name_ver for s in llf])) novella.append('\t\t\t
|---|---|
| Databases: | ') novella.append('\t\t\t\t%s | ' % ", ".join([s.name_ver for s in db])) novella.append('\t\t\t
| Standards: | ') novella.append('\t\t\t\t%s | ' % ", ".join([s.name_ver for s in std])) novella.append('\t\t\t
| Operating Systems: | ') novella.append('\t\t\t\t%s | ' % ", ".join([s.name_ver for s in os])) novella.append('\t\t\t
| Other: | ') novella.append('\t\t\t\t%s | ' % ", ".join([s.name_ver for s in oth])) novella.append('\t\t\t