From bb7f1228fc27539ee02c494adb1deb1986e5d325 Mon Sep 17 00:00:00 2001 From: Magnus Hagdorn Date: Wed, 7 Jun 2023 11:42:11 +0200 Subject: [PATCH 1/8] prepare to create package --- {bin => learner_certificates}/certificates.py | 0 .../templates/dc-attendance.svg | 0 .../templates/lc-attendance.svg | 0 .../templates/swc-attendance.svg | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename {bin => learner_certificates}/certificates.py (100%) rename dc-attendance.svg => learner_certificates/templates/dc-attendance.svg (100%) rename lc-attendance.svg => learner_certificates/templates/lc-attendance.svg (100%) rename swc-attendance.svg => learner_certificates/templates/swc-attendance.svg (100%) diff --git a/bin/certificates.py b/learner_certificates/certificates.py similarity index 100% rename from bin/certificates.py rename to learner_certificates/certificates.py diff --git a/dc-attendance.svg b/learner_certificates/templates/dc-attendance.svg similarity index 100% rename from dc-attendance.svg rename to learner_certificates/templates/dc-attendance.svg diff --git a/lc-attendance.svg b/learner_certificates/templates/lc-attendance.svg similarity index 100% rename from lc-attendance.svg rename to learner_certificates/templates/lc-attendance.svg diff --git a/swc-attendance.svg b/learner_certificates/templates/swc-attendance.svg similarity index 100% rename from swc-attendance.svg rename to learner_certificates/templates/swc-attendance.svg From 164c9a70291857d307c05b2022ecd9ac63a8e0f0 Mon Sep 17 00:00:00 2001 From: Magnus Hagdorn Date: Wed, 7 Jun 2023 16:36:00 +0200 Subject: [PATCH 2/8] refactor script as module * use jinja2 package directly to process templates * use pandas to read CSV file --- README.md | 13 +- learner_certificates/certificates.py | 218 ++++++++++++--------------- requirements.txt | 4 +- 3 files changed, 110 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 678f0a6..3716eb6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,22 @@ # Certificates for The Carpentries +## Using the python program -There are two ways to build certificates from this repo, one depends on the python package cairosvg which in turn depends on cairo development libraries being installed. To use this method, use `bin/certificates.py` to build certificates. +The python program makes use of the `cairosvg` package and supports reading a csv file containing participants using the `pandas` package. You can either install the python package or run it directly from this repo. + +``` +python3 -m learner_certificates.certificates -h +``` + +## Using jinja2 directly The second, pure python method uses the python packages jinja2, jinja2-cli and svglib to build the certificates. To build certificates this way, you can run: ``` -jinja2 swc-attendance.svg -D name="Firstname Lastname" -D date="Nov. 6, 2017" -D instructor="Some Instructor Name" > lastname_firstname.svg +jinja2 learner_certificates/templates/swc-attendance.svg \ + -D name="Firstname Lastname" -D date="Nov. 6, 2017" \ + -D instructor="Some Instructor Name" > lastname_firstname.svg svg2pdf lastname_firstname.svg ``` diff --git a/learner_certificates/certificates.py b/learner_certificates/certificates.py index f333386..b44bbf2 100755 --- a/learner_certificates/certificates.py +++ b/learner_certificates/certificates.py @@ -10,50 +10,47 @@ On a Mac, a typical command line is -python bin/certificates.py \ +python -m learner_certificates.certificates \ -b swc-instructor - -r $HOME/sc/certification/ \ + -o $HOME/sc/certification/ \ -u turing_alan - date='January 24, 1924' \ - instructor='Ada Lovelace' \ - name='Alan Turing' + -d 'January 24, 1924' \ + -i 'Ada Lovelace' \ + -n 'Alan Turing' where: -b: BADGE_TYPE - -r: ROOT_DIRECTORY + -o: the OUTPUT_DIRECTORY -u: USER_ID - name=value: fill-ins for the template + -d: the date + -i: the name of the instructor + -n: the name of the participant -The script then looks for $(ROOT_DIRECTORY)/$(BADGE_TYPE).svg as a template -file, and creates $(ROOT_DIRECTORY)/$(BADGE_TYPE)/$(USER_ID).pdf as output. +The script then creates $(OUTPUT_DIRECTORY)/$(BADGE_TYPE)/$(USER_ID).pdf as +output. This script will also take a CSV file as input. The file must contain rows of: - badge,trainer,user_id,new_instructor,email,date + name[,badge][,instructor][,user_id][,date] such as: + badge,instructor,user_id,name,date + swc-instructor,Grace Hopper,turing_alan,Alan Turing,2016-01-27 - swc-instructor,Grace Hopper,turing_alan,Alan Turing,alan@turing.org,2016-01-27 - -In this case, the command line invocation is: - -python bin/certificates.py \ - -r $HOME/sc/certification/ \ - -c input.csv +Any missing columns are filled in from the command line optiomns. The order of +the columns does not matter. ''' import sys -import os -import re -import csv +import pandas import tempfile -import subprocess -import unicodedata -from optparse import OptionParser -import time +import string +import argparse +from pathlib import Path from datetime import date import cairosvg +from jinja2 import Environment, PackageLoader, select_autoescape DATE_FORMAT = '%B %-d, %Y' @@ -61,127 +58,106 @@ def main(): args = parse_args() + + env = Environment( + loader=PackageLoader("learner_certificates"), + autoescape=select_autoescape()) + if args.csv_file: - process_csv(args) + check(args.csv_file.exists(), f"no such file {args.csv_file}") + process_csv(args, env) else: - process_single(args) + process_single(args, env) + + +def construct_user_name(name): + '''construct a username from the name by only allowing + alphanumeric characters and replacing spaces with _''' + valid_characters = string.ascii_letters + string.digits + '_' + user_id = ''.join( + ch for ch in name.replace(' ', '_') if ch in valid_characters) + user_id = user_id.lower() + return user_id def parse_args(): '''Get command-line arguments.''' - parser = OptionParser() - # -i argument retained for backward incompatibility, - # not used anymore - parser.add_option('-i', '--ink', - default='/Applications/Inkscape.app/Contents/Resources/bin/inkscape', - dest='inkscape', - help='[deprecated] Path to Inkscape') - parser.add_option('-b', '--badge', - default=None, dest='badge_type', help='Type of badge') - parser.add_option('-r', '--root', - default=os.getcwd(), dest='root_dir', help='Root directory (current by default)') - parser.add_option('-u', '--userid', - default=None, dest='user_id', help='User ID') - parser.add_option('-c', '--csv', - default=None, dest='csv_file', help='CSV file') - - args, extras = parser.parse_args() - check(args.root_dir is not None, 'Must specify root directory') - msg = 'Must specify either CSV file or both root directory and user ID' - if args.csv_file is not None: - check((args.badge_type is None) and (args.user_id is None), msg) - elif args.badge_type and args.user_id: - check(args.csv_file is None, msg) - else: - check(False, msg) - - args.params = extract_parameters(extras) - if 'date' not in args.params: - args.params['date'] = date.strftime(date.today(), DATE_FORMAT) + parser = argparse.ArgumentParser() + parser.add_argument( + '-b', '--badge', dest='badge_type', help='Type of badge') + parser.add_argument( + '-o', '--output-dir', default=Path.cwd(), type=Path, + help='Output directory (current by default)') + parser.add_argument( + '-d', '--date', default=date.isoformat(date.today()), + help='Date of certificate, defaults to today') + parser.add_argument( + '-i', '--instructor', help='Name of instructor') + grp = parser.add_mutually_exclusive_group(required=True) + grp.add_argument( + '-c', '--csv', type=Path, dest='csv_file', help='CSV file') + grp.add_argument( + '-n', '--name', help='Name of the participant') + parser.add_argument( + '-u', '--userid', dest='user_id', + help='User ID, default construct from name') + args = parser.parse_args() return args -def extract_parameters(args): - '''Extract key-value pairs from command line (checking for uniqueness).''' - - result = {} - for a in args: - fields = a.split('=') - assert len(fields) == 2, 'Badly formatted key-value pair "{0}"'.format(a) - key, value = fields - assert key not in result, 'Duplicate key "{0}"'.format(key) - result[key] = value - return result - - -def process_csv(args): +def process_csv(args, env): '''Process a CSV file.''' - with open(args.csv_file, 'r') as raw: - reader = csv.reader(raw) - for row in reader: - check(len(row) == 6, 'Badly-formatted row in CSV: {0}'.format(row)) - badge_type, args.params['instructor'], user_id, args.params['name'], email, args.params['date'] = row - if '-' in args.params['date']: - d = time.strptime(args.params['date'], '%Y-%m-%d') - d = date(*d[:3]) - args.params['date'] = date.strftime(d, DATE_FORMAT) - template_path = construct_template_path(args.root_dir, badge_type) - output_path = construct_output_path(args.root_dir, badge_type, user_id) - create_certificate(template_path, output_path, args.params) - -def process_single(args): + data = pandas.read_csv(args.csv_file) + if 'instructor' not in data.columns: + check(args.instructor is not None, "need to specify instructor") + data['instructor'] = args.instructor + if 'date' not in data.columns: + data['date'] = args.date + if 'user_id' not in data.columns: + data['user_id'] = data['name'].apply(construct_user_name) + if 'badge' not in data.columns: + check(args.badge_type is not None, "need to specify badge type") + data['badge'] = args.badge_type + for _, row in data.iterrows(): + create_certificate( + row['badge'], row['instructor'], row['user_id'], row['name'], + row['date'], args.output_dir, env) + + +def process_single(args, env): '''Process a single entry.''' - template_path = construct_template_path(args.root_dir, args.badge_type) - output_path = construct_output_path(args.root_dir, args.badge_type, args.user_id) - create_certificate(template_path, output_path, args.params) + check(args.instructor is not None, "need to specify instructor") + check(args.badge_type is not None, "need to specify badge type") + if args.user_id is None: + user_id = construct_user_name(args.name) + else: + user_id = args.user_id -def construct_template_path(root_dir, badge_type): - '''Create path for template file.''' - - return os.path.join(root_dir, badge_type + '.svg') - - -def construct_output_path(root_dir, badge_type, user_id): - '''Create path for generated PDF certificate.''' - - badge_path = os.path.join(root_dir, badge_type) - path_exists = os.path.exists(badge_path) - - if not path_exists: - os.mkdir(badge_path) - - return os.path.join(badge_path, user_id + '.pdf') + create_certificate(args.badge_type, args.instructor, user_id, + args.name, args.date, args.output_dir, env) -def create_certificate(template_path, output_path, params): +def create_certificate( + badge_type, instructor, user_id, name, datestr, output, env): '''Create a single certificate.''' - with open(template_path, 'r') as reader: - template = reader.read() - check_template(template, params) + template = env.get_template(badge_type + ".svg") + badge_path = output / Path(badge_type) - for key, value in params.items(): - pattern = '{{' + key + '}}' - template = template.replace(pattern, value) + if not badge_path.exists(): + badge_path.mkdir() + outputpdf = badge_path / Path(user_id).with_suffix('.pdf') tmp = tempfile.NamedTemporaryFile(suffix='.svg', delete=False) - tmp.write(bytes(template, 'utf-8')) - - cairosvg.svg2pdf(url=tmp.name, write_to=output_path, dpi=90) - - -def check_template(template, params): - '''Check that all values required by template are present.''' - - expected = re.findall(r'\{\{([^}]*)\}\}', template) - missing = set(expected) - set(params.keys()) - check(not missing, - 'Missing parameters required by template: {0}'.format(' '.join(missing))) + tmp.write(bytes(template.render( + name=name, instructor=instructor, + date=date.fromisoformat(datestr).strftime(DATE_FORMAT)), 'utf-8')) + cairosvg.svg2pdf(url=tmp.name, write_to=str(outputpdf), dpi=90) def check(condition, message): diff --git a/requirements.txt b/requirements.txt index 3ef89d8..41d1222 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ jinja2 -jinja2-cli -svglib +cairosvg +pandas From 4bb554193bd09cda7133c9e739bc1fbaefccc4be Mon Sep 17 00:00:00 2001 From: Magnus Hagdorn Date: Wed, 7 Jun 2023 16:38:58 +0200 Subject: [PATCH 3/8] remove shell script --- bin/makecert.sh | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100755 bin/makecert.sh diff --git a/bin/makecert.sh b/bin/makecert.sh deleted file mode 100755 index a441567..0000000 --- a/bin/makecert.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh - -if [[ $# -eq 0 ]]; -then - echo "makecert.sh " -fi - -# Check for jinja2 -command -v jinja2 >/dev/null 2>&1 || { echo >&2 "This scripts requires jinja2-cli but it's not installed. Install with `pip install jinja2-cli`. Aborting."; exit 1; } -# Check for svg2pdf -command -v svg2pdf>/dev/null 2>&1 || { echo >&2 "This script requires svg2pdf, a part of svglib. Install with `pip install svgilib`. Aborting."; exit 1; } - -source_svg=${1} -context=${2} -certout_pdf=${3} -certout_svg=${certout_pdf/.pdf/.svg} - -jinja2 ${source_svg} ${context} > ${certout_svg} # fill template - -svg2pdf ${certout_svg} # convert to PDF - - From 9570303878a9226f536cc7929d8a2d931b35595d Mon Sep 17 00:00:00 2001 From: Magnus Hagdorn Date: Wed, 7 Jun 2023 16:39:11 +0200 Subject: [PATCH 4/8] ignore some files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b5e2e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*~ +__pycache__ From b76566978f1f80a8b1344d8e01d9b5bb487b8012 Mon Sep 17 00:00:00 2001 From: Magnus Hagdorn Date: Wed, 7 Jun 2023 17:03:41 +0200 Subject: [PATCH 5/8] add a version --- learner_certificates/__init__.py | 1 + learner_certificates/__version__.py | 1 + 2 files changed, 2 insertions(+) create mode 100644 learner_certificates/__init__.py create mode 100644 learner_certificates/__version__.py diff --git a/learner_certificates/__init__.py b/learner_certificates/__init__.py new file mode 100644 index 0000000..7686a1d --- /dev/null +++ b/learner_certificates/__init__.py @@ -0,0 +1 @@ +from .__version__ import __version__ # noqa F401 diff --git a/learner_certificates/__version__.py b/learner_certificates/__version__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/learner_certificates/__version__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" From 5856fb38aded7deb704f96a4296ed53ec81654f3 Mon Sep 17 00:00:00 2001 From: Magnus Hagdorn Date: Wed, 7 Jun 2023 17:04:34 +0200 Subject: [PATCH 6/8] make it an installable package --- .gitignore | 2 ++ pyproject.toml | 3 +++ setup.cfg | 33 +++++++++++++++++++++++++++++++++ setup.py | 3 +++ 4 files changed, 41 insertions(+) create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 9b5e2e8..ab18773 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *~ __pycache__ +build/ +learner_certificates.egg-info/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fed528d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..888ea5c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,33 @@ +[metadata] +name = learner-certificates +version = attr: learner_certificates.__version__ +author = Magnus Hagdorn +author_email = magnus.hagdorn@charite.de +url = https://github.com/ScientificComputingCharite/learner-certificates +description = generating Carpentries certificates +long_description = file: README.md +long_description_content_type = text/markdown + +classifiers = + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3 + Topic :: Education + Intended Audience :: Education + +[options] +packages = + learner_certificates +zip_safe = True +include_package_data = True +install_requires = + pandas + jinja2 + +lint = flake8 >= 3.5.0 + +[options.entry_points] +console_scripts = + generate_carpentries_certificate = learner_certificates.certificates:main + +[options.package_data] +learner_certificates = templates/*.svg diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b908cbe --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +import setuptools + +setuptools.setup() From 6dd0d669d92f59aa655af1418e35c4da7f7823c0 Mon Sep 17 00:00:00 2001 From: Magnus Hagdorn Date: Wed, 14 Jun 2023 11:35:19 +0200 Subject: [PATCH 7/8] use dictionary to pass template parameters --- learner_certificates/certificates.py | 35 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/learner_certificates/certificates.py b/learner_certificates/certificates.py index b44bbf2..450340c 100755 --- a/learner_certificates/certificates.py +++ b/learner_certificates/certificates.py @@ -85,7 +85,7 @@ def parse_args(): parser = argparse.ArgumentParser() parser.add_argument( - '-b', '--badge', dest='badge_type', help='Type of badge') + '-b', '--badge', help='Type of badge') parser.add_argument( '-o', '--output-dir', default=Path.cwd(), type=Path, help='Output directory (current by default)') @@ -119,44 +119,45 @@ def process_csv(args, env): if 'user_id' not in data.columns: data['user_id'] = data['name'].apply(construct_user_name) if 'badge' not in data.columns: - check(args.badge_type is not None, "need to specify badge type") - data['badge'] = args.badge_type + check(args.badge is not None, "need to specify badge type") + data['badge'] = args.badge for _, row in data.iterrows(): - create_certificate( - row['badge'], row['instructor'], row['user_id'], row['name'], - row['date'], args.output_dir, env) + create_certificate(args.output_dir, env, row) def process_single(args, env): '''Process a single entry.''' check(args.instructor is not None, "need to specify instructor") - check(args.badge_type is not None, "need to specify badge type") + check(args.badge is not None, "need to specify badge type") if args.user_id is None: user_id = construct_user_name(args.name) else: user_id = args.user_id - create_certificate(args.badge_type, args.instructor, user_id, - args.name, args.date, args.output_dir, env) + params = {} + for k in ['badge', 'instructor', 'name', 'date']: + params[k] = getattr(args, k) + params['user_id'] = user_id + create_certificate(args.output_dir, env, params) -def create_certificate( - badge_type, instructor, user_id, name, datestr, output, env): + +def create_certificate(output, env, params): '''Create a single certificate.''' - template = env.get_template(badge_type + ".svg") - badge_path = output / Path(badge_type) + params['date'] = date.fromisoformat(params['date']).strftime(DATE_FORMAT) + + template = env.get_template(params['badge'] + ".svg") + badge_path = output / Path(params['badge']) if not badge_path.exists(): badge_path.mkdir() - outputpdf = badge_path / Path(user_id).with_suffix('.pdf') + outputpdf = badge_path / Path(params['user_id']).with_suffix('.pdf') tmp = tempfile.NamedTemporaryFile(suffix='.svg', delete=False) - tmp.write(bytes(template.render( - name=name, instructor=instructor, - date=date.fromisoformat(datestr).strftime(DATE_FORMAT)), 'utf-8')) + tmp.write(bytes(template.render(**params), 'utf-8')) cairosvg.svg2pdf(url=tmp.name, write_to=str(outputpdf), dpi=90) From b3d5651822b0bd7e242d8a2f481d94dff290de37 Mon Sep 17 00:00:00 2001 From: Magnus Hagdorn Date: Wed, 14 Jun 2023 11:39:30 +0200 Subject: [PATCH 8/8] remove temporary svg file --- learner_certificates/certificates.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/learner_certificates/certificates.py b/learner_certificates/certificates.py index 450340c..a33d79a 100755 --- a/learner_certificates/certificates.py +++ b/learner_certificates/certificates.py @@ -160,6 +160,8 @@ def create_certificate(output, env, params): tmp.write(bytes(template.render(**params), 'utf-8')) cairosvg.svg2pdf(url=tmp.name, write_to=str(outputpdf), dpi=90) + Path(tmp.name).unlink() + def check(condition, message): '''Fail if condition not met.'''