Open Source Diary C.VI.1
Sometimes when I want to relax I just start hacking on whatever I feel like hacking on most at that moment, but then allow myself to get sidetracked to fix whatever small annoyances I come across.
When working for a client you can only afford so many of these diversions, cause at the end of the day you need to something you can ship. And so you suck it up, and live with all the little sharp edges in the tools and libraries you use. In my own time on my own projects there’s no such external pressure. Of course I don’t want to only yak shave on my own projects either, it’s nice to have something to show for, but I can allow myself greater leeway to spend time polishing my tools, so when I do sit down and try to get the project over the line, it’s a much more enjoyable process.
This is also why I build and use many of my own tools. It allows me to create a happy space where things just work the way I like them to. And if they don’t I have greater power to do something about it.
bin/launchpad --no-namespace-maps
One such long time annoyance that I finally sat down to fix is
*print-namespace-maps*
. This var was introduced in clojure 1.9. If it’s set to
true
it changes the behaviour of clojure.pprint, so that namespaced maps are
printed with a prefix designating the namespace. However since that prefix moves
the whole map over, nested maps are increasingly pushed to the right, leading to
wrapped lines and messed up formatting.
;; (set! *print-namespace-maps* false)
{:foo/bar "t'was brillig and the slythie toves",
:foo/baq "did gyre and gimble in the wabe",
:foo/baz
{:xxx/yyy "do not go gentle into that good night",
:xxx/zzz "rage, rage against the dying of the light"}}
;; (set! *print-namespace-maps* true)
#:foo{:bar "t'was brillig and the slythie toves",
:baq "did gyre and gimble in the wabe",
:baz
#:xxx{:yyy "do not go gentle into that good night",
:zzz "rage, rage against the dying of the light"}}
I really just don’t think this is an improvement, so I want it to be always off, and neither Clojure nor nREPL seem to provide a convenient setting to control that. You can only set it once your REPL has started.
Launchpad seemed like an obvious place to address this. The whole idea with launchpad is that it’s a one-stop-shop Clojure environment launcher, which you can configure to your liking, either globally or on the project level.
So launchpad now has a new flag, --[no-]namespace-maps
. This will add an nREPL
middleware that rebinds the offending var. As with any Launchpad flags you can
provide a default value on multiple levels. Do you want to set it for everyone
on your team? Then do so directly in bin/launchpad
#!/usr/bin/env bb
(require
'[lambdaisland.launchpad :as launchpad])
(launchpad/main {:namespace-maps false})
To make this a default whenever you start your REPL on a given project, set it
in deps.local.edn
.
{:launchpad/options {:namespace-maps false}}
And do you want it to be always off, you can set this globally in
~/.clojure/deps.edn
.
lambdaisland/cli improvements
Note that these settings/flags sensibly override one another. CLI flag overrides local overrides global overrides per-project.
This made me think that this should really be the case for all of launchpad’s
boolean flags. For instance, if you have {:launchpad/options {:go true}}
, so
that (user/go)
is called automatically at startup, then bin/launchpad
--no-go
should turn that off again.
This in turn led to some improvements in
lambdaisland/cli. In particular in
Launchpad we have the --emacs
option, which is a shorthand for --cider-nrepl --refactor-nrepl --cider-connect
. In a case like this it would be sensible to allow later flags to override earlier ones, so that --emacs --no-refactor-nrepl
injects the CIDER nREPL middleware, and instructs emacs to connect to your process, but without injecting the refactor-nrepl middleware.
In lambdaisland/cli, you can optionally define a :handler
for each command
line flags, to override the default behavior. In the case of no-argument flags,
this is a single arity function, it just receives the options map, and for
instance assoc
‘es something onto it. If the flag takes an argument, then
:handler
should take two arguments.
The change I made is that handlers for --[no-]...
style flags also get a
second argument, to differentiate between true and false.
(def flags
["--[no-]emacs" {:doc "Shorthand for --cider-nrepl --refactor-nrepl --cider-connect"
:handler (fn [ctx v]
(assoc ctx
:cider-nrepl v
:refactor-nrepl v
:cider-connect v))}])
I also made sure that handlers and middleware for flags and subcommands are
processed in a sensible order. Middleware for commands runs after (“inside”)
middleware for flags, and middleware for subcommands runs after (“inside”)
middleware for parent commands. That way parents and flags can provide context
(through opts
or through dynamic vars) to child commands.
Makina update
Makina is coming along nicely, see my
last post for
background. There’s a CLJC purely functional lambdaisland.makina.system
namespace (mechanism), and a much more opinionated and integrated CLJ-only
namespace lambdaisland.makina.app
. I also added a tiny example app in the repo
to show what idiomatic use of Makina (+ lambdaisland/config + lambdaisland/cli)
would look like.
At this point the next step is to write a README. Now that I’m starting to settle on a design writing a README is a great way to check myself and see if the design is sensible. A good design can be explained clearly and concisely.
But in the meanwhile I’ll sketch a little bit of what I’m working towards. At first things will look a lot like integrant, you start with a config map, with keys identifying your various components, values being configuration for said components, and with refs between them to designate dependencies.
{:my.app/db {:jdbc-url "..."}
:my.app/http-server {:db #makina/ref :my.app/db}}
Note that we have data printers and readers for these tagged literals, you should be able to use them in most contexts without issue, and they’ll round trip from the printer to the reader.
This we’re calling a “config” or “system config”. Each inner map is a “component config”.
No surprises so far. The first thing Makina will do with this is expand it to look like this.
{:my.app/db {:makina/type :my.app/db
:makina/id :my.app/db
:makina/state :stopped
:makina/config {:jdbc-url "..."}
:makina/value {:makina/type :my.app/db
:makina/id :my.app/db
:jdbc-url "..."}}
:my.app/http-server {:makina/type :my.app/http-server
:makina/id :my.app/http-server
:makina/state :stopped
:makina/config {:db #makina/ref :my.app/db}
:makina/value {:db #makina/ref :my.app/db
:makina/type :my.app/http-server
:makina/id :my.app/http-server}}}
This is a “system” or “system map”. The :makina/id
is the key used in the
system config, the :makina/type
is too, but can also be supplied explicitly.
{:my.app/db {:makina/type :my.app/jdbc-database
:jdbc-url "..."}
:my.app/http-server {:db #makina/ref :my.app/db}}
It’s this type that’s used to find start/stop handlers (and possibly others down the line, including suspend/resume, and user-defined ones). In the purely functional API you do that by passing in a map with handlers.
(lambdaisland.makina.system/start
system-config
{:my.app/db (fn [{:keys [jdbc-url]}] ,,,)
:my.app/http-server {:start (fn [{:keys [db port]}] ,,,)
:stop #(.stop %)}})
These can either be a map with per-signal handlers, or just a start handler as a
function. On both level it’s also possible to declare a :default
.
(lambdaisland.makina.system/start
system-config
{:default ,,, ;; component that doesn't have an explicit handler
:my.app/foo {:default ,,, ;; Used for any signal
}})
Makina adds a bunch of extra keys onto your component config before calling the handler (if it’s a map at least, if not we leave it alone), so you can easily do your own dispatch on id, type, signal.
The lambdaisland.makina.app
API is used like this:
(ns my.app
(:require
[my.app.config :as config] ;; lambdaisland.config wrapper
[lambdaisland.makina.app :as app]))
(defonce app
(app/create
{:prefix "my.app"
:data-readers {'config config/get} ;; in system.edn use `#config :http/port` etc
:handlers {,,,}))
;; app is an atom with keys
;; (:makina/system :makina/extra-handlers :makina/config-source :makina/data-readers)
(def load! (partial app/load! system))
(def start! (partial app/start! system))
(def stop! (partial app/stop! system))
(def value (partial app/value system))
(def state (partial app/state system))
(def component (partial app/component system))
(def refresh (partial app/refresh `system)) ;; symbol containing the app atom
(def refresh-all (partial app/refresh-all `system))
This will load the system config from the classpath (i.e. resources/
), under
my.app/system.edn
. If you are following lambdaisland.config
conventions this
file will be sitting next to config.edn
, dev.edn
, etc. Note that instead of
:prefix
you can also pass a :config-source
, a relative path resolved on the
classpath.
You can pass handlers here explicitly, but if you don’t Makina will try to
resolve them. If the type is a keyword or symbol, it’ll look for a var with a
corresponding name, or for a var in a namespace with that name named component.
So given :my.app/db
it could be
(ns my.app)
(def db {:start ,,, :stop})
;; OR
(defn db [opts] ,,,)
;;;;;;;;;;;;;;;;;;;;;;
(ns my.app.db)
(def component {:start ,,, :stop})
;; OR
(defn component [opts] ,,,)
refresh
/refresh-all
are wrappers for
clojure.tools.namespace.repl/refresh(-all)
. This is a BYO dependency, you need
to add it to your project deps, it gets loaded dynamically when needed. This
prevents is gumming up production builds, while being available in dev with
minimal boilerplate.
A final thing I’ll point out is that if a component errors, startups stops at
that point. The component in question will be in an :error
state, with the
exception under :makina/error
(it also gets rethrown by start!
). At this
point you can use your REPL to fix things, and rerun (start!)
, starting the
remaining components.
lambdaisland/open-source tooling
We have a lot of projects under the lambdaisland banner at this point, around two dozen I believe. You can find an overview here although it’s a bit out of date.
To manage this we’ve settled years ago on our own
tooling, standardized across
projects. The goal was to have minimal boilerplate in the individual projects,
to really avoid having things like release scripts that get copied between
projects, and then get out of date and out of sync. Instead each project has one
dependency in bb.edn
, and one bb script: bin/proj
;; bb.edn
{:deps
{lambdaisland/open-source {:git/url "https://github.com/lambdaisland/open-source"
:git/sha "6cb675d2adae284021f8cd94dcfbd078986b39bd"}}}
#!/usr/bin/env bb
;; bin/proj
(ns proj (:require [lioss.main :as lioss]))
(lioss/main
{:license :mpl
:inception-year 2021
:description "Quickly define print handlers for tagged literals across print/pprint implementations."
:group-id "lambdaisland"
:aliases-as-optional-deps [:dev]})
This does a bunch of things
$ bin/proj --help
NAME
bin/proj
SYNOPSIS
bin/proj [release | pom | relocation-pom | install | print-versions | gh_actions_changelog_output | inspect | gen-readme | update-readme | bump-version | launchpad | ingest-docs] [<args>...]
SUBCOMMANDS
release Release a new version to clojars
pom Generate pom files
relocation-pom Generate pom files to relocate artifacts to a new groupId
install Build and install jar(s) locally
print-versions Print deps.edn / lein coordinates
gh_actions_changelog_output Print the last stanza of the changelog in a format that GH actions understands
inspect Show expanded opts and exit
gen-readme Generate README based on a template and fill in project variables
update-readme Update sections in README.md
bump-version Bump minor version
launchpad Launch a REPL with Launchpad
ingest-docs Run cljdoc in Docker to ingest the current version of this project.
The most important command here is bin/proj release
, this
- checks if the repo is clean
- runs the tests
- bumps the version
- adds the version/date/sha to the CHANGELOG
- updates any version identifiers in the README’s install section
- generates pom.xml
- commits
- creates a git tag
- pushes
- builds the jar and deploys to clojars
- adds a comment to any merged PRs that went out in this release, saying which version they were released in
- create a new github “Release” entry, which includes the changelog
- triggers a cljdoc build
It’s really a pretty neat system which conveniently lets us do lots of small releases.
A few other things it does is handle projects with submodules (we used this for
Chui), and for projects where the
group-id changed (we moved a few from lambdaisland
to com.lambdaisland
), it
builds two jars, one for each group, with one being empty but with a dependency
on the other one.
lambdaisland/cli is largely based on the command line argument handling that we originally built into this lioss tooling. This weekend I finally got around to migrating lioss.main itself over. It was quite painless, and now we get even better help text handling, and a slightly cleaner code base.