Skip to main content

pset

Build assignments from a repo of LaTeX problems, each carrying its own solution and rubric.

  • every problem is its own .tex file, with the question, the solution and the rubric together
  • an assignment is a short file that \inputs the problems it wants
  • one build gives three PDFs from that one source: the student copy, the answer key and the grading rubric
  • so problems outlive the assignment they were written for: reusable across terms, reviewable in version control, searchable before you write the next one

Installation

pip install pset

Needs pdflatex on the PATH and python 3.9 or newer.

Quick start

pset --example writes a small repo that builds: five problems, one assignment, and the shared preamble around them.

pset --example
cd pset_example/hw_example
pset hw_example
==> hw_example.tex
    hw_example.pdf  hw_example_sol.pdf  hw_example_rub.pdf

hw_example.pdf goes to the students, hw_example_sol.pdf is the answer key, hw_example_rub.pdf adds the grading rubric for the TAs. Opening the three side by side is the fastest way to read the rest of this.

The layout

pset_example/
    pset.tex              \prob, \sol, \rub, \stud (generated, don't edit)
    packages.tex          the \usepackage lines, shared by every assignment
    command.tex           your styling; inputs pset.tex
    problems/             every problem, grouped by topic
        number_rep/
            base_convert_dec_hex01.tex
            2s_comp_overflow01.tex
            mod_arithmetic01.tex
        logic/
            boolean_simplify01.tex
            cnf_dnf_warmup01.tex
    hw_example/
        hw_example.tex    metadata, then the problems it inputs

Each assignment gets its own folder one level under the root, so ../problems/... resolves the same way from all of them.

An assignment

hw_example/hw_example.tex, trimmed to three problems:

\documentclass[11pt]{article}

\newcommand{\hwtitle}{HW1: Number Representation \& Logic}
\newcommand{\hwdatedue}{Sept 22, 2025 @ 11:59pm}
\newcommand{\course}{Example Course}

\input{../packages.tex}
\input{../command.tex}

\begin{document}
    \input{../header_box.tex}
    \input{../instructions.tex}

    \prob{[24 pts: (6 each)]: Base conversions}
    \input{../problems/number_rep/base_convert_dec_hex01.tex}

    \prob{[23 pts: (14, 9)]: 2's Complement \& Overflow}
    \input{../problems/number_rep/2s_comp_overflow01.tex}

    \prob{[12 pts]: Simplifying a set expression}
    \input{../problems/logic/boolean_simplify01.tex}

\end{document}

The assignment holds the metadata, the ordering and the points; every question is in a problem file. Reordering it, or swapping in next term's variant, is a couple of lines.

\prob{...} numbers the problem and prints its title. pset -p reads the points out of the leading bracket, so [24 pts: (6 each)] is a convention worth keeping (see counting points).

A problem

problems/logic/boolean_simplify01.tex in full:

Simplify the expression below.
Your simplified expression should have the least possible number of set
operators ($\cup$, $\cap$, $^c$).
Show each step by applying and labelling the identity used.

\begin{equation*}
	(A \cap (A \cup B^c))^c
\end{equation*}

\stud{\vfill}

\sol{
	\begin{align*}
		(A \cap (A \cup B^c))^c
		 & = (A^c \cup (A \cup B^c)^c) \quad \text{(De Morgan's Law)}    \\
		 & = (A^c \cup (A^c \cap B^{cc})) \quad \text{(De Morgan's Law)} \\
		 & = (A^c \cup (A^c \cap B)) \quad \text{(Double Negation)}      \\
		 & = A^c \quad \text{(Absorption)}
	\end{align*}
}

\rub{
	\begin{itemize}
		\item 8 pts final expression of $A^c$
		\item 4 pts every step labelled with the identity used
	\end{itemize}
}

No \documentclass and no \begin{document}: the assignment supplies both. Three macros divide the content, and each PDF is this same file with a different subset of them switched on.

macro student _sol _rub what goes in it
plain text yes yes yes the question
\stud yes space for the work
\sol yes yes the answer
\rub yes how to mark it

\sol and \rub are hidden by default and revealed by their own build. \stud runs the other way: on by default, dropped from both keys. Mostly it is the blank space left for the work, \stud{\vfill} or \stud{\vspace{2in}}, which the keys do not want: leave it in and every answer sits alone on its own page, so marking one problem means paging through five sheets. Anything else addressed only to the student goes in it too, such as \stud{Show your work.} or a table to fill in.

All three wrap their argument rather than switching a mode, so it is \stud{...}, not \begin{stud}. They nest: \sol{... \rub{...}} hangs a per-part rubric off the answer it belongs to.

You never write those definitions. pset.tex at the repo root holds them and pset --init generates it, which is what setting up your own repo is about.

Building

pset hw_example          # hw_example.pdf, _sol.pdf, _rub.pdf
pset quiz1*              # every version at once
pset quiz1/              # every document in a folder

Arguments are files, globs or folders, and the .tex suffix is optional. Globs are expanded by pset rather than left to the shell, so quiz1* works the same on Windows. Two filters keep a wide pattern honest: a sibling build (quiz1a_sol.pdf) collapses onto the .tex it came from, and a fragment with no \documentclass is skipped.

All three copies build by default, minus any that would come out empty:

==> exam1a.tex
    exam1a.pdf  exam1a_sol.pdf
    no \rub in it, rub copy skipped

A document with no \rub anywhere in it gets no rubric copy, since that copy would only duplicate the answer key. The check follows \input through the whole tree, because the macros usually sit in the problem files rather than in the document. Where the tree cannot be read to the end -- a missing file, or an \input path built from a macro -- it builds the copy rather than guess. --sol and --rub force one; --no-sol and --no-rub refuse one outright.

The pdflatex transcript is hidden, leaving one line per document. A failure prints the error pdflatex reported and keeps that document's .log, but does not stop the rest of the batch: the failures are listed at the end and the exit status is non-zero. -v restores the full transcript.

Styling

pset.tex carries the mechanism, which is pset's, and leaves the look to you through three hooks. They default to printing their argument plainly, so the generated file needs no packages at all and cannot collide with your package set. \renewcommand any of them after the \input, as command.tex does:

\input{../pset.tex}

\renewcommand{\solstyle}[1]{
	\begin{tcolorbox}[colback=blue!3!white,colframe=blue!40!black,title=Solution,breakable]
		#1
	\end{tcolorbox}
}

\renewcommand{\rubstyle}[1]{
	\begin{tcolorbox}[colback=green!3!white,colframe=green!40!black,title=Rubric,breakable]
		#1
	\end{tcolorbox}
}

\renewcommand{\probstyle}[2]{\section*{Problem #1 #2}}

\probstyle takes the number and the title separately.

Two names are pset's rather than yours. \prob is the macro -p reads points out of, so it is part of the contract even though its look is yours. And the build flags are \showsol and \showrub, deliberately not \sol and \rub: a document cannot both define a macro and ask whether it is defined, so \newcommand{\sol} would make \ifdefined\sol true on every build and print the answers on the student copy.

If your repo already calls the content macros something else, name them in a pset.toml at the repo root:

[macros]
sol = 'answer'
rub = 'rubric'

Setting up your own repo

pset --init

is all an existing repo needs. It writes pset.tex and nothing else, since a repo with assignments in it already has a layout and a preamble, and neither is pset's to rewrite. Add the line it prints to each document, beside whatever shared preamble that document already inputs:

\input{../pset.tex}

The path is relative to the document being built, not to the file holding the line, so putting it in a command.tex that every document already inputs wires up the whole repo at once.

Re-running --init upgrades pset.tex in place, and it refuses to touch one it did not write. Generating the file is not only convenience: hand-rolled, those definitions are easy to get subtly wrong, and the failure mode is a student PDF with the answers in it.

pset --readme drops a short guide to the layout and the commands beside problems/, for whoever clones the repo next. --example offers one too, and remembers the answer in ~/.config/pset/pset.toml so it asks once.

Browsing the library

pset --browse

renders every problem into problems_pdf/, one PDF per topic folder of problems/, with the solutions shown (--rub adds the rubrics). Worth doing before writing a new problem, and it catches the one that has stopped compiling. A topic that fails does not stop the others; the failures are listed at the end.

The generated document inputs your repo's packages.tex and command.tex, so a problem looks the way it will on an assignment. Files matching *template*.tex are skipped, and dropping your own pset_browse.tex at the repo root changes the layout.

Which problems have I already used?

pset --usage

writes prob_file_pair.json, pairing each problem with the assignments that \input it and each assignment with its problems:

{
    "file_to_prob": {
        "hw_example/hw_example": [
            "number_rep/base_convert_dec_hex01",
            "logic/boolean_simplify01"
        ]
    },
    "prob_to_file": {
        "logic/boolean_simplify01": ["hw_example/hw_example"],
        "logic/cnf_dnf_warmup01": []
    }
}

A problem listed under two assignments is how the same question lands on both the homework and the exam; pset flags those as it writes. A problem never used keeps an empty list, so grep '\[\]' finds the unused ones. -o writes elsewhere, which is how a per-term snapshot gets kept.

--browse and --usage both act on the whole repo and find its root by walking up from the working folder, so they run from anywhere inside it. The root is the folder holding problems/.

Counting points

pip install "pset[pts]"
pset -p hw_example

sums the points per problem with sum-pts:

| part                         |    | extra   |   part total |
|:-----------------------------|---:|:--------|-------------:|
| Base conversions             | 24 |         |           24 |
| 2's Complement \& Overflow   | 23 |         |           23 |
| Modular Arithmetic           | 16 | 2.0     |           18 |
| Simplifying a set expression | 12 |         |           12 |
| CNF \& DNF warmup            | 10 |         |           10 |
| total                        | 85 | 2.0     |           87 |

The default expects a bracket at the front of a \prob{...} title, and reads both spellings:

\prob{[20 pts (8, 12)]: Bayes Net}
\prob{[16 points (4 pts each)]: Ecology System}

If that is not your convention, a [points] table in the pset.toml at your repo root overrides it for every document underneath. The keys are sum-pts' own parameters, so its documentation is the reference:

[points]
prefix = ' *\\question'
points = '(marks|pts?)'

When the patterns do not fit, pset says so and links to docs/points.md, which explains each key and the traps. Points are read from the source, so -p still reports on a document that failed to build.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pset-0.6.1.tar.gz (37.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pset-0.6.1-py3-none-any.whl (36.9 kB view details)

Uploaded Python 3

File details

Details for the file pset-0.6.1.tar.gz.

File metadata

  • Download URL: pset-0.6.1.tar.gz
  • Upload date:
  • Size: 37.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.4

File hashes

Hashes for pset-0.6.1.tar.gz
Algorithm Hash digest
SHA256 4e4642a7803edd9402c9a6d6921e428fbb7df2483ae81d85a83bdb698a89b2a5
MD5 0814a3495de6365c369cfd5870cb611a
BLAKE2b-256 a32d6cc0af7910bfc04328867cc142c1d1af74867f955657a5ff6583668963f2

See more details on using hashes here.

File details

Details for the file pset-0.6.1-py3-none-any.whl.

File metadata

  • Download URL: pset-0.6.1-py3-none-any.whl
  • Upload date:
  • Size: 36.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.4

File hashes

Hashes for pset-0.6.1-py3-none-any.whl
Algorithm Hash digest
SHA256 1bf386eb9f47acd067cb431890368df4b9e15ab1854a43da288aff31e2cd421e
MD5 3598dbd52d9bff2d4fadc699eb6a74e0
BLAKE2b-256 59eee1d5ef37a0d55294404a7542b5c577e3701bc15e3dc846c759277eb73b57

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.6.1 This release

2 files

0.6.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page