Skip to main content

writing-schedule (Python)

Version PyPI version Python versions Documentation Status License: MIT

A Python port of mooerslab/writing-schedule.el. It reads the same weekly org-mode block table and produces the same three outputs, namely the dated schedule .org file, an iCalendar .ics file, and a printable time-block PDF. A person who cannot install Emacs gets the same artifacts from a single pip install, because the PDF is drawn with ReportLab rather than TeX. The .org file can be exported to HTML or other formats with pandoc.

You do not need Emacs, and you do not need writing-schedule.el, to use this package. It is a standalone command-line program and Python library whose only runtime dependency is ReportLab. The elisp package is the reference implementation, and it matters only when a developer runs the parity tests described under Testing and parity.

The org table stays the planning surface, because it is plain text that any editor can handle. The two implementations are two performances of one score: the format specification in formatspec.org is the score, the elisp package is the reference recording, and a shared fixture directory keeps the Python variant from drifting into a dialect of its own.

Installation

pip install writing-schedule        # now published to PyPI
pipx install writing-schedule        # isolated console tool
pip install -e .                     # from a checkout, for development

The only runtime dependency is ReportLab. The iCalendar writer and the parser use the standard library alone, including zoneinfo for daylight saving.

Tutorial: build a schedule from an org table

The weekly table is the one file you edit. It is an ordinary org-mode table, so any text editor can produce it, and Emacs is not required. This walkthrough builds a week from scratch and turns it into the three outputs.

Step 1, scaffold or copy a table

To start from a blank grid, ask the tool for one and then fill in the cells:

writing-schedule template 5 --out my-week.org

That writes a blank table for five projects. You can also copy the worked example below, which is examples/my-week.org in the repository.

#+TITLE: My Writing Week

| Time <l>    | M  | Tu | W  | Th | F  | Sa |
|-------------+----+----+----+----+----+----|
| Generative: |    |    |    |    |    |    |
| 04:00-05:30 | A  | B  | A  | B  | W  |    |
| 05:45-07:15 | A  | B  | A  | B  | W  | A  |
| Rewriting:  |    |    |    |    |    |    |
| 09:15-10:45 | B  | A  | B  | A  | TT | A  |
| Supporting: |    |    |    |    |    |    |
| 13:15-14:45 | EM | EM | EM | EM | EM |    |
|-------------+----+----+----+----+----+----|
| A:  DNPH1 docking    |  |  |  |  |  |
| B:  DUSP1 radiation  |  |  |  |  |  |
| W:  2026words        |  |  |  |  |  |
| TT: time tracking    |  |  |  |  |  |
| EM: email            |  |  |  |  |  |

Note that the table can have rows with overlapping time ranges. These will be ignored unless they have a code in a day column. Rows that are blank will also be ignored.

Step 2, understand the four kinds of rows

The parser reads the table by testing the first cell of each row, and horizontal rules are ignored. Four kinds of row carry meaning.

The header row is the first row that names weekdays. Its first column is a plain label, such as Time, and every column after it is a day. The accepted day spellings are m, mo, mon for Monday, then tu or tue, w, we, wed, th or thu, f, fr, fri, sa or sat, and su or sun. Matching ignores case and surrounding space. The days may be any subset and in any order, so a week of only Monday and Thursday is valid. A lone T is not accepted, because it is ambiguous between Tuesday and Thursday.

A section header row is a line whose first cell is a word or a few words with an optional trailing colon, such as Generative:. It groups the blocks beneath it, and it becomes the CATEGORY in the schedule file. Use letters and spaces only, because a name that contains a digit, such as Deep Work 1, is not recognized as a section.

A time-block row is a row whose first cell holds a time range, such as 04:00-05:30. Each day cell beneath the time holds a short code that names what you work on during that block. The time reader is forgiving, so a one-digit hour as in 4:00, a space after the colon as in 16: 30, extra spaces, and more than one hyphen are all accepted.

A legend row maps a code to a description, as in A: DNPH1 docking. The code is one uppercase letter followed by up to three more uppercase letters or digits, so A, EM, and W2 are all valid, and you are not limited to four projects or to single letters. When the first cell is only the code and a colon, the description may instead sit in the second cell. The code test is case-sensitive, which is how A: docking is read as a legend while Generative: is read as a section header.

Step 3, generate the outputs

Point the tool at your table and name any day inside the target week, because the week snaps to the Monday on or before that date.

writing-schedule generate examples/my-week.org --week 2026-01-19 --dir out
writing-schedule sheets   examples/my-week.org --week 2026-01-19 --dir out --format both

The example table holds twenty-two blocks across five codes and three sections, and the two commands write these files into out/.

File What it is
writing-2026-01-19.org The dated schedule, one TODO per block with an active timestamp, and a Summary of hours per code. Add it to your org agenda.
writing-2026-01-19.ics The calendar. Import it into Google Calendar, Outlook, or Apple Calendar.
sheets-week-2026-01-19.pdf The printable sheet, two pages per day, with the plan in the first column and blank columns to revise as the day changes.
sheets-week-2026-01-19.org The same week as an editable per-day org table, in case you prefer to adjust it in org before printing.

To rework a saved week later, writing-schedule weeks --dir out lists the archived schedule files newest first.

Step 4, redo one day when the plan changes

Events crop up, so a day rarely runs as first drawn. Edit that day's cells in the weekly table, then redo just that day with --day, which takes a date or the word today. Both sheets and generate accept it.

writing-schedule sheets   examples/my-week.org --day 2026-01-21 --dir out --format both
writing-schedule sheets   examples/my-week.org --day today       --dir out
writing-schedule generate examples/my-week.org --day 2026-01-21 --dir out

The sheets command writes sheet-<ISO>.pdf and, with --format both, sheet-<ISO>.org. The generate command writes day-<ISO>.org and day-<ISO>.ics, holding that day's events alone. The day- prefix keeps these out of the weekly archive, so a redone day never overwrites the week. The day sets its own week, so --week is not needed with --day. A day the table has no column for prints as a blank sheet.

Step 5, catch overlapping time blocks

The table places generative, editing, and support activities in separate row groups, so the same day can carry a block in one group and a clashing block in another without your noticing. Every command checks for such clashes. By default generate, export, and sheets print a warning that names the conflicting blocks and still write the output, because a worksheet with the clash marked is more useful than no output at all. Pass --strict to turn the warning into an error that writes nothing until you fix the table.

writing-schedule check    examples/my-week.org
writing-schedule generate examples/my-week.org --week 2026-01-19 --dir out --strict

The check command reports the clashes and exits 3 when it finds any, so it fits a pre-commit hook or a continuous integration step. On the printed sheet each clashing block is marked with an asterisk and a footnote, so the overlap is visible on paper. Two blocks that merely touch, for example 04:00-05:30 followed by 05:30-07:00, do not count as a clash. A block that crosses midnight is compared only against other blocks on its own day.

Command line

The subcommands mirror the shell front end of the elisp package.

writing-schedule generate  TABLE (--week 2026-01-19 | --day 2026-01-21|today) [--dir OUT] [--no-ics] [--no-todo] [--tz ZONE] [--strict]
writing-schedule export    TABLE (--week 2026-01-19 | --day 2026-01-21|today) [--dir OUT] [--tz ZONE] [--strict]
writing-schedule sheets    TABLE (--week 2026-01-19 | --day 2026-01-21|today) [--dir OUT] [--engine reportlab|latex] [--format pdf|org|both] [--per-day] [--strict]
writing-schedule check     TABLE
writing-schedule template  N [--out FILE]
writing-schedule weeks     [--dir OUT]

--week takes any date inside the target week, because the week snaps to the Monday on or before that date. --day instead names one day, as a date or the word today. On generate and export it writes day-<ISO>.org and day-<ISO>.ics for that day; on sheets it prints only that day's sheet. Give one or the other. Examples:

writing-schedule generate examples/projects-and-tasks.org --week 2026-01-19 --dir out
writing-schedule generate examples/projects-and-tasks.org --day today --dir out
writing-schedule sheets   examples/projects-and-tasks.org --week 2026-01-19 --dir out --format both
writing-schedule sheets   examples/projects-and-tasks.org --day today --dir out --format both

Library

from writing_schedule import parse_text, generate_schedule, build_ics, render_week_pdf

parsed = parse_text(open("projects-and-tasks.org").read())
monday, title, body = generate_schedule(parsed, "2026-01-19")
open("writing-2026-01-19.org", "w").write(body)
open("writing-2026-01-19.ics", "w", newline="").write(build_ics(parsed, monday, title))
render_week_pdf(parsed, monday, "sheets-week-2026-01-19.pdf")

Module layout

Module Responsibility Mirrors
parser.py Read the org table into events writing-schedule--parse
orgtable.py Split org-table text into rows org-table-to-lisp
model.py Event, ParsedTable, MapEntry the reference plists
week.py Week arithmetic and date stamping --week-monday, --iso-date
schedule.py Write the dated schedule org file writing-schedule-generate
ics.py Emit RFC 5545 events directly the ox-icalendar export
vtimezone.py Build a VTIMEZONE from zoneinfo (improvement over floating time)
sheet.py Draw the time-block PDF (ReportLab) the --timeblock-* family
sheet_latex.py Emit the LaTeX sheet (optional engine) --timeblock-document
sheet_org.py Write the editable week org file --timeblock-org-document
template.py Blank weekly-table scaffold --template-string
archive.py List archived weeks, newest first --archived-weeks
cli.py Subcommands and options writing-schedule.sh

The parser is free of any knowledge of output formats. It returns a plain data structure of events, the legend, the codes, and the day columns, so a bug in the PDF layout can never corrupt the calendar output.

The PDF decision

ReportLab is the default, because a person who cannot install Emacs is unlikely to enjoy installing a multi-gigabyte TeX distribution. Drawing the grid is a good fit for a canvas, because each planned block is a box placed at its exact time range rather than snapped to the five-row-per-hour grid the tabular model forces. The five sub-rows survive only as light guide lines. A block that reaches a page boundary is left open at that edge, so a block crossing the page break reads as one block across two pages.

The LaTeX emitter is kept behind --engine latex for users who have TeX and want output that is byte-comparable with the Emacs version.

Daylight saving

Times are written as local wall-clock values with a TZID, and the file carries a VTIMEZONE derived from zoneinfo. A 04:00 block therefore stays at 04:00 in every week, and a calendar client resolves the correct absolute instant on either side of a transition. The test suite verifies that a 04:00 block resolves to 10:00 UTC in a winter week and 09:00 UTC in a summer week.

Testing and parity

pip install -e ".[test]"
pytest

tests/test_writing_schedule.py ports the ERT suite to pytest and keeps the same test names, so the two suites read side by side. tests/test_parity.py asserts that the parser finds forty-four events with the correct codes, offsets, and times in tests/fixtures/projects-and-tasks.org, which is the hardest shared contract. tests/test_ics.py covers the calendar subset and daylight saving. tests/test_sheet.py checks the PDF page count and the block-box geometry, because byte comparison across two rendering engines is not realistic.

tests/test_frozen_fixtures.py is the tuning fork. It diffs the Python schedule .org and week .org byte-for-byte against output frozen from the elisp reference (GNU Emacs 29.3), so the two implementations stay aligned to a fixed reference rather than to each other. The calendar is checked two ways: a byte-for-byte match against a frozen Python golden, and a semantic match against the elisp ox-icalendar export, comparing the set of events by local start, local end, summary, and categories. The frozen files and their provenance live in tests/fixtures/expected/.

Using the Makefile

The repository ships a Makefile that wraps the common tasks, so you do not have to remember each command. It is self-documenting, because running make with no target, or make help, prints the list.

make help

The targets fall into three groups, namely working with the package, building and publishing a release, and refreshing the parity fixtures.

Target What it does
make install Install the package.
make dev Install the package in editable mode with the test extras.
make test Run the pytest suite.
make coverage Run the tests with a line-coverage report.
make lint Lint with ruff when it is installed, and skip with a note when it is not.
make demo Generate the schedule, calendar, and sheets from an example table into out/.
make build Build the sdist and wheel into dist/.
make check Build, then validate the distributions with twine.
make publish-test Upload the built distributions to TestPyPI.
make publish Upload the built distributions to PyPI.
make reference Refresh the frozen elisp fixtures from Emacs.
make clean Remove caches, build artifacts, and the demo output.

A first session usually runs three targets in order, one to install, one to test, and one to see real output.

make dev
make test
make demo

Several targets read their inputs from variables that you can override on the command line, so the demo can point at any table, week, and output directory:

make demo TABLE=examples/my-week.org WEEK=2026-03-02 OUT=build/demo

The reference target needs Emacs and the path to the elisp package, because it runs the reference implementation to rewrite the files in tests/fixtures/expected/:

make reference WS_EL=../writing-schedule.el

The build, check, and coverage targets install build, twine, and pytest-cov on demand, and only when those tools are missing, so a fresh checkout still works. If you edit the Makefile, keep each recipe line indented with a tab rather than spaces, because Make requires it.

Related websites

  • MooersLab/writing-schedule
  • MooersLab/writing-time-spent-heatmap
  • MooersLab/writingLogTemplateInOrg
  • MooersLab/writingLogTemplateIn
  • MooersLab/whisper-dvr
  • MooersLab/programmingLogInOrg

Sources of funding

  • NIH: R01 CA242845.
  • NIH: R01 AI088011.
  • NIH: P30 CA225520 (PI: R. Mannel).
  • NIH: P20 GM103640 and P30 GM145423 (PI: A. West).

Download files

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

Source Distribution

writing_schedule-0.3.1.tar.gz (47.7 kB view details)

Uploaded Source

Built Distribution

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

writing_schedule-0.3.1-py3-none-any.whl (39.0 kB view details)

Uploaded Python 3

File details

Details for the file writing_schedule-0.3.1.tar.gz.

File metadata

  • Download URL: writing_schedule-0.3.1.tar.gz
  • Upload date:
  • Size: 47.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for writing_schedule-0.3.1.tar.gz
Algorithm Hash digest
SHA256 c9ec7310da9812c3acbf3ca6cf0ce3722efeb3601ea9603bb4162584d9f8129e
MD5 2d779a4fa160f19cf0d6abb189833c9f
BLAKE2b-256 1ae0de7dd8791e955b593c91651427c7600e8756456fa1d5a77de265181a08d5

See more details on using hashes here.

File details

Details for the file writing_schedule-0.3.1-py3-none-any.whl.

File metadata

File hashes

Hashes for writing_schedule-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 c29d0513c3f82219ff8084adee54180119da079c13111404c0f556c784914d0b
MD5 83ab1c63ac29a0d289b18b4dd3b5077c
BLAKE2b-256 0b3cf6b17e9c2c2defd50fea49e06b3fa97a7373c15b0c87d7ae93d75495a0cc

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.3.1 This release

2 files

0.3.0

2 files

0.2.0

2 files

0.1.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