Another approach to building a date conversion tool

Following up on an idea mentioned here, this post describes how to build, deploy, and run a date conversion tool built with Emacs, Lisp, and Docker.

Build from Emacs

As you may recall, I discussed the idea of developing a date conversion tool with Emacs, Lisp, and deploying the resulting software with Docker. However, I dismissed it because I wanted my tool to (1) have no third party dependencies – in that scenario, Emacs – (2) support cross-compilation out of the box, and (3) be deployed as a self-contained binary executable, i.e. to not depend on a runtime such as the Java VM or a docker engine on the target system.

Nevertheless, once voiced, I couldn’t quite leave this intruiging idea (it involves Emacs and Lisp after all) unimplemented – in fact, who could?

Emacs provides the calendar functions1 that we need for our tool. Usually, these functions are called only indirectly from Emacs’ builtin calendar.2 Since we want to perform date conversions directly from the command line (and not from inside Emacs), we write a Lisp script that:

The script displayed below (and named calcal.el) does exactly that:

;; -*- mode: Lisp; lexical-binding: t; indent-tabs-mode: nil; -*-
(require 'calendar)
(require 'parse-time)

;; Extract month, day and year from a date parse result list (as produced by
;; parse-time-string). Returns nil if any date element is nil.
(defun extract-mdy (parse-list)
  "Extract date components from parse results of parse-time-string"
  (list ;; order of date components is month-day-year
   (nth 4 parse-list)
   (nth 3 parse-list)
   (nth 5 parse-list)))

;; Parse a ISO 8601-formatted string. Return a list with month, day and year
;; components on success, and nil otherwise
(defun parse-date (date-string)
  "Parse an ISO 8601-formatted date string. 
On success, returns a list of the form (year month day), or nil otherwise"
  (let ((parsed-date (extract-mdy (parse-time-string date-string))))
    (if (and
         (not (member nil parsed-date)) ;; no date element should be nil
         (> (nth 0 parsed-date)) ;; month should be a positive integer
         (> (nth 1 parsed-date))) ;; day should be a positive integer
        parsed-date
        nil)))

;; Print date conversion results to screen
(defun print-dates (string-list)
  "Print dates computed by calendar.el's calendar-other-dates"
  (message "\n%s\n" 
           "The dates below were computed with Emacs' calendar functions.")
  (mapc 'message string-list))

;; main conversion function
(defun main (args)
  ;; If no argument is provided, main converts the current date
  ;; If arguments were passed, main parses the first argument and uses the
  ;; resulting date for date conversion. If the first argument cannot be
  ;; parsed to a date, main returns an error message.
  (if (> (length args) 0)
      (let ((parsed-date (parse-date (elt args 0))))
        (if parsed-date ;; if parsed-date is not nil
            (print-dates (calendar-other-dates parsed-date))
            (message "Invalid date. Specify date as yyyy-mm-dd.")))
      (print-dates (calendar-other-dates (calendar-current-date)))))

;; run main function
(main argv)

To display the calendar dates corresponding to the 29th of August 2022 (Gregorian),3 we would simply run emacs --script path/to/calcal.el 2022-08-29, producing the following output:

The dates below were computed with Emacs' calendar functions.

Day 241 of 2022; 124 days remaining in the year
ISO date: Day 1 of week 35 of 2022
Julian date: August 16, 2022
Astronomical (Julian) day number (at noon UTC): 2459821.0
Fixed (RD) date: 738396
Hebrew date (before sunset): Elul 2, 5782
Persian date: Sahrivar 7, 1401
Islamic date (before sunset): Safar 1, 1444
Bahá’í date: Asmá’ 10, 179
Chinese date: Cycle 78, year 39 (Ren-Yin), month 8 (Ji-You), day 3 (Jia-Yin)
Coptic date: Misra 23, 1738
Ethiopic date: Nahas 23, 2014
French Revolutionary date: Duodi 12 Fructidor an 230 de la Révolution, jour du Fenouil
Mayan date: Long count = 13.0.9.14.18; tzolkin = 6 Etznab; haab = 11 Mol

Deployment

Assuming an unix-like operating system on the target computer with Emacs installed, one way of deploying the date converter consists of obtaining the Lisp script and either:

  1. call the script directly with emacs --script path/to/calcal.el, or,
  2. install the script, and later on call it like a regular command line utility. This requires abstracting away calcal.el’s location and the direct call to Emacs. To do so,
    • add #!/usr/bin/env emacs --script to the top of the Lisp script
    • make calcal.el executable, e.g. with chmod +x
    • place calcal.el on your $PATH (or modify $PATH accordingly)

Although neither option is overly complicated to implement, in both cases the user has to deal with matters well beyond calling a command line tool: make sure Emacs is installed, memorize file paths; edit files, modify the $PATH environment variable, change file permissions.4 Clearly, it would be much easier to deploy an utility whose components are packaged in a single unit – or, using the appropriate term, container – and which runs independently of the underlying operating system.

As chance would have it, this is essentially how Docker works.5 Containers package software components like application code, libraries, and dependencies6 into one unit of software, which can then be executed on any platform supported by the respective container engine.

Below listing defines the container of our date conversion tool. As discussed earlier, it consists of Emacs (emacs-nox) and the Lisp script calcal.el.

# This is calcal-emacs' Dockerfile
FROM alpine:latest
RUN apk add emacs-nox
COPY calcal.el /calcal.el
ENTRYPOINT ["emacs", "--script", "calcal.el"]

One might wonder why we include an Alpine Linux distribution in the container as well. To answer this question, let us look a bit more closely at how OS-level virtualization/containerization techniques like Docker work.

Containers share the resources provided by the operating system (more precisely, the kernel). For resource allocation and isolation, Docker uses two features of the Linux kernel, cgroups and kernel namespaces (this is why, on Windows and MacOS, Docker runs a virtual machine with Linux, which in turn powers the containers).

In the case of the approaches discussed above, we implicitly rely on the operating system’s user space to provide the means to run the script, e.g. a shell. In the Docker case, the engine only provides kernel resources such as CPU and memory to our container, which is not sufficient to run calcal.el (no shell, no libraries on which the shell and Emacs depend, etc.).7 Therefore, we add an Alpine Linux user space, supplying the required dependencies.8

After building our calcal-emacs container,9 we may run it with sudo docker run calcal-emacs 2022-08-29,10 printing to stdout the same output we obtained from invoking calcal.el with Emacs:

The dates below were computed with Emacs' calendar functions.

Day 241 of 2022; 124 days remaining in the year
ISO date: Day 1 of week 35 of 2022
Julian date: August 16, 2022
Astronomical (Julian) day number (at noon UTC): 2459821.0
Fixed (RD) date: 738396
Hebrew date (before sunset): Elul 2, 5782
Persian date: Sahrivar 7, 1401
Islamic date (before sunset): Safar 1, 1444
Bahá’í date: Asmá’ 10, 179
Chinese date: Cycle 78, year 39 (Ren-Yin), month 8 (Ji-You), day 3 (Jia-Yin)
Coptic date: Misra 23, 1738
Ethiopic date: Nahas 23, 2014
French Revolutionary date: Duodi 12 Fructidor an 230 de la Révolution, jour du Fenouil
Mayan date: Long count = 13.0.9.14.18; tzolkin = 6 Etznab; haab = 11 Mol

We may feel quite content, having successfully built and run a containerized date conversion tool. Yet we shouldn’t forget about actually deploying the container to target machines. There are two distribution options:

  1. Use a container registry such as DockerHub, Quay, or Google Container Registry, or run your own registry server (for more details, see the docker documentation)
  2. Save your container to a tar archive, and distribute the archive

To keep matters simple (which – I believe – the first option does not), we shall distribute calcal-emacs as a tarball. Docker provides the save command to write a container to an archive, and load to restore a container from such an archive.11 To create a (gzip-compressed) container tarball, call sudo docker save calcal-emacs:latest | gzip > calcal-emacs.tar.gz. For distribution, simply copy the archive to the target machine, and restore the container with sudo load < calcal-emacs.tar.gz.

Concluding remarks

In what I intended to be a short follow-up post, I described how to build, deploy and run a containerized date converter built from Emacs and custom Lisp functions.

Note: For this post’s accompanying code, see https://git.staudtlex.de/blog/date-conversion-with-emacs.


  1. For more details regarding the function definitions, see the Emacs source code (e.g. on GitHub), and these two papers: Dershowitz, Nachum, and Edward Reingold. 1990. “Calendrical Calculations”, Software – Practice and Experience, 20 (9), 899-928; Reingold, Edward, Nachum Dershowitz, and Stewart Clamen. 1993. “Calendrical Calculations, II: Three Historical Calendars”, Software – Practice and Experience, 23 (4), 383-404. For more context about calendars from a computational point of view, see: Reingold, Edward, and Nachum Dershowitz. 2018. Calendrical Calculations: The Ultimate Edition. Cambridge: Cambridge University Press↩︎

  2. See the documentation about the Emacs calendar/diary↩︎

  3. Of course, this requires Emacs to be located somewhere on $PATH – and hence installed on the target computer. ↩︎

  4. Note that the second option as described above only works on unix-like systems. ↩︎

  5. Needless to say, there is a bit more to Docker (and other OS-level virtualization tools) than “runs containers”. See for instance the Wikipedia entries about OS-level virtualization and Docker, as well as IBM’s overview about containers and virtualization. For web archived versions of the IBM articles, see here (containers) and here (virtualization).

    The idea of bundling application dependencies in a single package is being actively explored as well in software distribution frameworks such as Flatpak and Snap↩︎

  6. In our case, calcal.el and Emacs. ↩︎

  7. For a more detailed discussion, see for instance Red Hat’s blog series about the difference between user and kernel space, part 1, 2, and 3. For the corresponding web archived articles, see here, here, and here↩︎

  8. In our case, adding an entire Alpine Linux user space is the easiest way to get the dependencies into our container. Alternatively, we could identify every single dependency needed for running scripts or Emacs, and include only these. An exercise which I leave to the interested reader. ↩︎

  9. To build the container, run sudo docker build -t calcal-emacs:latest . in the working directory that contains the Lisp script and Dockerfile. ↩︎

  10. For those who prefer to avoid using sudo, podman may be an interesting drop-in replacement for Docker, an alternative that does not require elevated privileges to build and run containers:

    # build the calcal-emacs container
    podman build -t calcal-emacs:latest .
    # run the container
    podman run calcal-emacs 2022-08-29
    

    For more information about podman, see podman.io↩︎

  11. The Docker documentation about save and load can be found here and here↩︎