Rewriting a script for the Homebrew package manager taught me how the Go programming language’s design choices align with platform-ready tools.
The problem with brew upgrade
By default, the brew upgrade command updates every formula (terminal utility or library). It also updates every cask (GUI application) it manages. All are upgraded to the latest version — major, minor, and patch. That’s convenient when you want the newest features, but disruptive when you only want quiet patch-level fixes.
Last week I solved this in Perl with brew-patch-upgrade.pl, a script that parsed brew upgrade’s JSON output, compared semantic versions, and upgraded only when the patch number changed. It worked, but it also reminded me how much Perl leans on implicit structures and runtime flexibility.
This week I ported the script to Go, the lingua franca of DevOps. The goal wasn’t feature parity — it was to see how Go’s design choices map onto platform engineering concerns.
Why port to Go?
Portfolio practice: I’m building a body of work that demonstrates platform engineering skills.
Operational focus: Go is widely used for tooling in infrastructure and cloud environments.
Learning by contrast: Rewriting a working Perl script in Go forces me to confront differences in error handling, type safety, and distribution.
The Go version is noisier, but it forces explicit decisions. That’s a feature in production tooling: no silent failures.
Dependency management
Perl: cpanfile + CPAN modules. Distribution means “install Perl (if it’s not already), install modules, run script.” Tools like carton and the cpan or cpanm commands help automate this. Additionally, one can use further tooling like fatpack and pp to build more self-contained packages. But those are neither common nor (except for cpan) distributed with Perl.
Go: go.mod + go build. Distribution is a single (platform-specific) binary.
For operational tools, that’s a massive simplification. No runtime interpreter, no dependency dance.
type Formula struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version"`
InstalledVersions []string `json:"installed_versions"`
}
The compiler enforces assumptions that Perl left implicit. That friction is valuable — it surfaces errors early.
Binary distribution
This is where Go shines. Instead of telling colleagues “install Perl v5.34 and CPAN modules,” I can hand them a binary. No need to worry about scripting runtime environments — just grab the right file for your system.
Available on the release page. Download, run, done.
Semantic versioning logic
In Perl, I manually compared arrays of version numbers. In Go, I imported golang.org/x/mod/semver:
import (
golang.org/x/mod/semver
)
...
if semver.MajorMinor(toSemver(formula.InstalledVersions[0])) !=
semver.MajorMinor(toSemver(formula.CurrentVersion)) {
log.Printf("%s is not a patch upgrade", formula.Name)
results.skipped++
continue
}
Cleaner, more legible, and less error-prone. The library encodes the convention, so I don’t have to.
Deliberate simplification
I didn’t port every feature. Logging adapters, signal handlers, and edge-case diagnostics remained in Perl. The Go version focuses on the core logic: parse JSON, compare versions, run upgrades. That restraint was intentional — I wanted to learn Go’s idioms, not replicate every Perl flourish.
Platform engineering insights
Three lessons stood out:
Binary distribution matters. Operational tools should be installable with a single copy step. Go makes that trivial.
Semantic versioning is an operational practice. It’s not just a convention for library authors — it’s a contract that tooling can enforce.
Go’s design aligns with platform needs. Explicit errors, type safety, and static binaries all reduce surprises in production.
Bringing it home
This isn’t a “Perl vs. Go” story. It’s a story about deliberate simplification, taking a working Perl script and recasting it in Go. The aim is to see how the language’s choices shape a solution to the same problem.
The result is homebrew-semver-guardv0.1.0, a small but sturdy tool. It’s not feature-finished, but it’s production-ready in the ways that matter.
Next up: I’m considering more Go tools, maybe even Kubernetes for services on my home server. This port was practice, an artifact demonstrating platform engineering in action.
At first, I just wanted Homebrew to behave a little more politely. Formulae should upgrade only on patch changes, without being dragged through minor and major bumps. That itch became a small(ish) Perl script, brew-patch-upgrade.pl.
Along the way, I discovered another patch was needed. My own logging adapter, Log::Any::Adapter::MacOS::OSLog, wasn’t building and installing its bundle correctly. Before the script can shine in the Mac Console app, I had to fix the adapter itself.
What follows is how those two threads came together. One is a tool that keeps Homebrew upgrades patch-perfect. The other is a logging adapter that finally behaves as a first-class Perl module.
What is Homebrew?
Not to be confused with perlbrew, Homebrew is a free and open-source package manager for macOS (and Linux). It makes it easy to install and update software from the command line. You don’t have to hunt down installers on websites. Instead, you can type commands like brew install wget. Homebrew will then fetch, build, and link the tool into place.
Everything is organized under /opt/homebrew (on Apple Silicon-based Macs) or /usr/local (on Intel). Homebrew can even manage both command‑line utilities (“formulae”) and desktop apps (“casks”).
In short: it’s the missing package manager Apple never shipped, and it’s become an essential part of many developers’ workflows.
Homebrew makes it easy to stay up to date — sometimes too easy. By default, brew upgrade jumps to the latest version of everything, even across minor and major releases.
That’s fine when you want the newest features. Yet, it can be disruptive if all you really need are the quiet, patch-level fixes.
Scripted patch-only upgrades
My brew‑patch‑upgrade.pl takes a narrower view: it parses brew outdated command, compares semantic versions, and upgrades only when the patch number changes.
That means:
3.2.1 → 3.2.4 will be upgraded.
3.2.1 → 3.3.0 will be skipped.
3.2.1 → 4.0.0 will be skipped.
Normally, the script’s output is comparable to brew upgrade, streaming the familiar Homebrew output to your terminal for any patch-level updates while noting any skipped versions:
% brew-patch-upgrade.pl
Skipping harfbuzz, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
main::process_formula(HASH(0x7c075dc90)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
Skipping woodpecker-cli, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
main::process_formula(HASH(0x7c0c1a180)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
==> Upgrading 1 outdated package:
zstd 1.5.6 -> 1.5.7
==> Fetching downloads for: zstd
==> Fetching zstd
==> Downloading https://ghcr.io/v2/homebrew/core/zstd/blobs/sha256:ddb0c145060bc2366ce5d58d95aa205bb15cb4c66948f20bb85e23fdb5eba7e9
Already downloaded: /Users/mjg/Library/Caches/Homebrew/downloads/eb865576547e163ef6908f1e5762f4dc4d7fb548940f7b896ae12c0d5b202362--zstd--1.5.7.arm64_tahoe.bottle.1.tar.gz
==> Upgrading zstd
1.5.6 -> 1.5.7
==> Pouring zstd--1.5.7.arm64_tahoe.bottle.1.tar.gz
🍺 /opt/homebrew/Cellar/zstd/1.5.7: 32 files, 2.2MB
==> Running `brew cleanup zstd`...
Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`.
Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).
Removing: /opt/homebrew/Cellar/zstd/1.5.6... (32 files, 2.1MB)
Removing: /Users/mjg/Library/Caches/Homebrew/zstd_bottle_manifest--1.5.6... (11.9KB)
Removing: /Users/mjg/Library/Caches/Homebrew/zstd--1.5.6... (753.9KB)
==> No outdated dependents to upgrade!
But since I intended this to run unattended on a schedule, you can also tell it to log to a different destination using the environment variable LOG_ANY_DEFAULT_ADAPTER:
% LOG_ANY_DEFAULT_ADAPTER=Stderr brew-patch-upgrade.pl
using log adapter Log::Any::Adapter::Stderr
outdated formulae: {casks => [],formulae => [{current_version => "12.0.0",installed_versions => ["11.5.1"],name => "harfbuzz",pinned => bless( do{\(my $o = 0)}, 'JSON::PP::Boolean' ),pinned_version => undef},{current_version => "3.10.0",installed_versions => ["3.9.0"],name => "woodpecker-cli",pinned => $VAR1->{formulae}[0]{pinned},pinned_version => undef},{current_version => "1.5.7",installed_versions => ["1.5.6","1.5.7"],name => "zstd",pinned => $VAR1->{formulae}[0]{pinned},pinned_version => undef}]}
Skipping harfbuzz, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
main::process_formula(HASH(0xa1a01f510)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
Skipping woodpecker-cli, needs manual review before upgrade at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 178.
main::process_formula(HASH(0xa1acbcf30)) called at /Users/mjg/.local/bin/brew-patch-upgrade.pl line 121
Starting brew upgrade for zstd...
==> Upgrading 1 outdated package:
zstd 1.5.6 -> 1.5.7
Finished brew upgrade for zstd
Summary: upgraded 1, skipped 2, failed 0
That’s useful for debugging. But, the real payoff comes when you send logs to a more sophisticated adapter. A good example is my Log::Any::Adapter::MacOS::OSLog package. It tags different levels of log messages in macOS’ unified logging system. These messages can then be filtered and searched in the Console utility app.
And produces results that can be viewed in Console:
Or using the macOS log show command:
At the end, the script logs a summary of upgraded, skipped, and failed formulae. On failure, it exits non-zero to make it easy to use in automation.
Highlights from the script source code
For those curious about the internals, here are a few highlights from the source.
Skipping major and minor version bumps
use version;
...
sub process_formula ($formula_ref) {
my $name = $formula_ref->{name};
my ( $current, $installed ) = map { defined and qv($_) } (
$formula_ref->{current_version},
$formula_ref->{installed_versions}->[0] );
unless ( $current and $installed ) {
$logger->debug(
'no current and/or installed version detected for',
$name );
return 'skipped';
}
my $result = 'failed'; # default
my @current = $current->{version}->@*;
my @installed = $installed->{version}->@*;
#<<<
unless (@current >= 3
and @installed >= 3
and $installed[0] == $current[0]
and $installed[1] == $current[1]
and $installed[2] < $current[2] )
#>>>
{
carp "Skipping $name, needs manual review before upgrade";
return 'skipped';
}
...
At the start, process_formula checks whether a formula from brew outdated has a valid semantic version. Only then does it compare patch numbers.
To that end, it uses the core Perl version module’s qv function. This parses the current and latest installed version, avoiding a complicated regular expression. The resulting version objects can be treated as hash references with a version key. This key is itself a reference to an array containing the major, minor, and patch version numbers. If the major or minor version numbers differ between installed and current, the script calls for a manual review. Then it returns a “skipped” status.
Setting up the loggers
use Log::Any qw($logger);
use Log::Any::Adapter;
...
use constant {
MAC_LOG_ADAPTER_CLASS => 'Log::Any::Adapter::MacOS::OSLog',
MAC_LOG_SUBSYSTEM => 'com.phoenixtrap.brew-patch-upgrade',
};
my %brew_log;
if ( $ENV{LOG_ANY_DEFAULT_ADAPTER} ) {
my $adapter = Log::Any::Adapter->get(__PACKAGE__);
Log::Any::Adapter->set( q(+) . ref $adapter,
subsystem => MAC_LOG_SUBSYSTEM )
if $adapter->isa(MAC_LOG_ADAPTER_CLASS);
$logger->debug( 'using log adapter', ref $adapter );
$SIG{__WARN__} = sub ($message) { $logger->alert($message) };
$SIG{__DIE__} = sub ($message) { $logger->fatal($message) };
foreach my $stdio (qw(stdout stderr)) {
Log::Any::Adapter->set(
{ category => "brew.$stdio" },
q(+) . ref $adapter,
( subsystem => MAC_LOG_SUBSYSTEM,
os_category => "brew-$stdio",
)x!!$adapter->isa(MAC_LOG_ADAPTER_CLASS),
);
$brew_log{$stdio}
= Log::Any->get_logger( category => "brew.$stdio" );
}
}
If LOG_ANY_DEFAULT_ADAPTER is set, the script creates three loggers: one for the program itself, one for brew.stdout, and one for brew.stderr. All three have logic to check if the default logger class is Log::Any::Adapter::MacOS::OSLog. If it is, an appropriate OS-level logging category is set. A unique subsystem name for the script, “com.phoenixtrap.brew-patch-upgrade”, is also assigned.
Unfortunately, while working on this macOS-specific logging code, I made a discovery. I realized that I needed to fix my log adapter class with a patch of its own.
OSLog revisited
I was proud of my earlier work on the MacOS::OSLog log adapter — a clever wrapper around macOS’ logging functions using Perl foreign function interface (FFI). I even took a victory lap, converting the CPAN distribution to use Dist::Zilla for pluggable installer creation and documentation.
But outside the packaged maclog script, the module didn’t work. Oops.
Once I tried using the MacOS::OSLog adapter in brew-patch-upgrade.pl, the cracks became obvious. The bundle wasn’t built or installed correctly. My _find_my_bundle hack was brittle — not a foundation to build on.
The solution was to stop faking it and let the FFI::Platypus toolchain do its job. Using Dist::Zilla::Plugin::MakeMaker::Awesomein my dist.ini file, I rewrote the build process to use FFI::Build::MM in the generated Makefile.PL installer. This meant that the bundle would compile and install in the right place instead of relying on my _find_my_bundle hack:
The location of the C source code as well as compilation flags moved to a separate ffi/OSLog.fbx file, which FFI::Build::MM would infer the name of based on the arguments passed to its mm_args method.
I also updated the C source code with an #include <ffi_platypus_bundle.h> line, giving the resulting code bundle a proper entry point that FFI::Platypus could recognize.
With those fixes, the adapter now works as a native-grade component. No hacks. No guessing. Just clean, structured logs flowing into the unified logging system.
Each run of brew-patch-upgrade.pl now shows a clear subsystem, with categories for STDOUT, STDERR, and the script itself. This makes it easy to filter and review. And because the script exits with a summary line and proper exit code, it slots neatly into automation as well.
In other words, the logging side of brew-patch-upgrade.pl is now as patch-perfect as the upgrade logic it supports.
Looking back, I realized that this project wasn’t just about taming Homebrew’s upgrades or fixing a stubborn Perl module. It was about creating a stronger connection between the tools I rely on and their expected behavior. I want them to be predictable, legible, and resilient. Both packages work together, keeping my upgrades quiet and intentional while surfacing them in the Console app.
Both the script and log adapter are on Codeberg, with the adapter also on CPAN. If you’d like to try them out, file issues, or suggest improvements, the repos are waiting.
Social media is how many people experience the Internet today. There’s a good chance you found this post through one of them. But the way we connect online doesn’t have to be dictated by a handful of platforms. Beyond the single-app, single-site giants, there’s the Fediverse: a constellation of independent social networks that talk to each other.
Think of it like email: no single company in charge, no central service to rule them all. It’s powered by a mix of software for text, photos, podcasts, events, and more.
Mastodon is one of the most popular of these–a “microblogging” platform like X (née Twitter), but open-source and federated. You can join one of thousands of servers run by others… or, if you’re me, you host your own on a tiny Mac mini sitting on top of a file cabinet.
But why?
I’d been happily tooting away on a couple of well-run Mastodon servers for years. But I kept running into little things I wanted to tweak: themes, moderation settings, even the domain of my handle. None of them were deal-breakers, but they added up. Eventually, I realized that the only way to get exactly what I wanted was to set it up myself.
The hardware
When I say tiny, I mean tiny: a low-spec 2023M2 Mac mini, 8GB memory, 512GB solid-state drive. My wife and I had set it up in our home office to drive a TV displaying our shared schedule. I had also installed a Calibre server for our e‑book library.
So the mini isn’t even close to breaking a sweat yet. And I was encouraged to read that others were successfully running single-user Mastodon servers on a Raspberry Pi.
The mini was more than capable. The next step was figuring out how to run Mastodon without becoming a maintenance headache.
The containers
Mastodon’s official installation instructions involve setting up a variety of services on a Linux VPS (virtual private server). But there’s an easier, literally more self-contained way: Docker containers, orchestrated through Docker Compose and running via Docker Desktop.
its ancillary streaming service and Sidekiq background event queue
Everything is containerized. Still, there must be a way to keep the host safe from the open Internet.
Avoiding overexposure
Directly exposing the Mac mini to the full malice of the Internet filled me with dread. And besides, my home connection lacks a guaranteed fixed address to which I’d attach a domain name. My solution? Cloudflare Tunnel, a service run by my domain registrar and name service.
All I need to do is add another Docker container service. Cloudflare manages the web traffic to and from Mastodon. The other services and the host Mac then stay safe from harm.
With the tunnel in place, I can focus on keeping the setup lean and easy to update.
Aiming for maintainability
Despite all of these moving parts, I still aim to keep the Mac philosophy of simplicity. To change only what I need, I use git to clone Mastodon’s GitHub repository. Then, I check out the latest release tag. Finally, I’ve got this docker-compose.override.yml file with just my modifications for the various container services:
Full code listings are expandable on the website; email readers may need to click through to view them.
Let’s start from the bottom secrets section.
Secrets management
Rather than Mastodon’s typical approach of exposing passwords, tokens, and other sensitive information as environment variables, I use a more secure approach. I mount each one as a separate text file. Then relevant services can read these files directly or pass them as variables on startup.
To make sure I don’t accidentally commit private information to git, /local/secrets/ is in my repository’s .git/info/exclude file.
Shared configuration with a YAML extension
Back to the top, and I have a x-mastodon-localYAML extension for shared configuration among several of Mastodon’s services:
This lists most of the aforementioned secrets, and mounts a wrapper script to set them as environment variables.
The wrapper script
Speaking of which:
with-secrets.sh
#!/bin/sh
set -eu
# Map of secret file name to environment variable name
for secret in \
"postgres_password:DB_PASS" \
"smtp_password:SMTP_PASSWORD" \
"redis_password:REDIS_PASSWORD" \
"secret_key_base:SECRET_KEY_BASE" \
"vapid_private:VAPID_PRIVATE_KEY" \
"ar_enc_primary:ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY" \
"ar_enc_deterministic:ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY" \
"ar_enc_salt:ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"
do
# Split the pair into file name and env var name
# ${var%%:*} -- strip everything from the first ":" onward
# (keeps left side)
name="${secret%%:*}"
# ${var##*:} -- strip everything up to and including the last ":"
# (keeps right side)
var="${secret##*:}"
file="/run/secrets/$name"
# If the secret file exists, read its contents into the env var
if [ -f "$file" ]
then
eval "export $var=\"$(cat "$file")\""
fi
done
# Hand off to the original command
exec "$@"
Full code listings are expandable on the website; email readers may need to click through to view them.
Since Mastodon wants everything, secrets included, as environment variables, this script:
loops through a map of their corresponding secrets files
exports each of their contents to the environment
and then runs whatever command was passed along for the ride.
Customized containerized services
Remember that docker-compose.override.yml gets merged with the upstream docker-compose.yml file, so I only need to write necessary changes and additions. Let’s take them one at a time.
This service is completely new, with nothing to override. I specify everything from the Docker Hub-based image to the startup command. I also specify the external network used to talk to Cloudflare’s servers.
Of special note is the TUNNEL_TOKEN_FILE environment variable. This feature was added a mere six months ago. It enables loading the Cloudflare-provided authentication token directly from my mounted file in /run/secrets. This avoids stuffing its contents into an environment variable itself. I also add a depends_on item for the Mastodon web service. This ensures that Mastodon is running before I open the tunnel.
And since the upstream git repository ignores files matching .env*.local, I set POSTGRES_DB and POSTGRES_USER environment variables in an .env.db.local file and harden the healthcheck.test to both check that PostgreSQL is accepting connections and that a simple query is successful. That check is run every thirty seconds and retried up to five times if unsuccessful. It times out after a mere five seconds, more than enough time because everything’s running on the same host.
A brief stop with Redis
redis:
restart: unless-stopped
The only change to upstream’s Redis configuration is restarting the service if it wasn’t manually stopped. (Every other service in my override file also uses this.) Then I can bring any of them down for maintenance or troubleshooting, and not worry about docker compose over-enthusiastically starting them up again.
Full code listings are expandable on the website; email readers may need to click through to view them.
Each of these services brings in the mastodon-localYAML extension defined earlier. This approach avoids repeating the same secrets and volumes each time. They also repeat upstream’s command entries, but wrapped in the with-secrets.sh script described above.
Each service also has a depends_on stanza that relies on the PostgreSQL and Redis service reporting good health. The rest is just networking tweaks. limiting external exposure to the web service only.
Again, the point here is maintainability and only overriding what’s not already covered by upstream’s docker-compose.yml file.
Bringing it all together
The containers are humming along. Secrets are tucked safely away. Cloudflare quietly handles the outside world. My little Mac mini now runs a fully-fledged Mastodon instance, still without breaking a sweat. It’s not just a proof-of-concept–it’s my daily driver for posting, following, and exploring the Fediverse.
Performance has been pleasantly uneventful: CPU and memory usage stay low, even during busy federated timelines. The tunnel has been rock-solid, and the override-only approach means I can pull upstream updates without dreading a merge marathon.
Lessons learned
Secrets-as-files keep sensitive data out of the environment and out of version control–worth the extra setup.
Docker override files are a sanity-saver; upstream changes flow in without trampling my tweaks.
Health checks aren’t just for show–they’ve already caught a misbehaving Sidekiq service before it caused downtime.
Cloudflare Tunnel removes the need for a static IP and keeps the host off the public Internet entirely.
If you’re thinking of trying this
Start small. You don’t need a rack of servers–a modest machine and a bit of container discipline can get you surprisingly far. Keep your changes minimal, document them as you go, and let upstream do the heavy lifting.
Running Mastodon this way has been a reminder that self-hosting doesn’t have to mean endless tinkering. With the right boundaries–both in network exposure and in configuration scope–it can be calm and predictable. It offers the satisfaction of knowing it’s entirely yours.
I’ve since enabled ElasticSearch for full-text search by copying over upstream’s commented-out es example service to my docker-compose.override.yml and lightly configuring it. It was a fairly simple addition that hasn’t affected performance on the Mac mini.
My next steps will involve lightweight monitoring and backup strategies. These steps will guarantee this little server can keep quietly doing its job for years to come.
Every extended Labor Day weekend, 80,000 fans of pop culture descend on Atlanta for Dragon Con. It’s a sprawling choose-your-own adventure of a convention with 38 programming tracks and over 5,000 hours of events. It spans five downtown host hotels, and there is no way to see it all.
Sadly, this year’s con is almost over. Still, I thought I’d share a little script I wrote to help me make sense of it all.
The official mobile app is fine for searching and bookmarking events, speakers, and exhibitors. Nonetheless, it’s not suitable for scanning the whole landscape at once. I wanted a single, scrollable view of every event, before I even packed my cosplay.
Even in the app’s tablet version, the Dragon Con Events area is a scroll-fest.
The web version of the app gave me exactly what I needed: predictable per-day URLs and semantically marked-up HTML. That meant I can skip the API hunt, skip the manual scrolling, and go straight to scraping.
Inspecting the HTML reveals per-day event URLs and per-event <div> blocks.
From Chaos to Clarity in 40 lines
We’re about to turn a messy, multi-day, multi-hotel schedule into one clean, scroll-once list. This is the forty-five-line Perl map that gets us there, aided by the Mojolicious web toolkit.
Laying the Groundwork: Tools for the Job
#!/usr/bin/env perl
use v5.40;
use Carp;
use English;
use Mojo::UserAgent;
use Mojo::URL;
use Mojo::DOM;
use Mojo::Collection q(c);
use Time::Piece;
use HTML::HTML5::Entities;
use Memoize;
binmode STDOUT, ':encoding(UTF-8)'
or croak "Couldn't encode STDOUT: $OS_ERROR";
my $ua = Mojo::UserAgent->new();
my $site = Mojo::URL->new('https://app.core-apps.com');
my $path = '/dragoncon25/events/view_by_day';
What’s happening: Load the modules that will do the heavy lifting–HTTP fetches, DOM parsing, date handling, Unicode cleanup. Lock STDOUT to UTF‑8 so characters like curly quotes and em-dashes don’t break the output. Point the script at the base schedule URL.
Remembering the Days Without Re-Parsing
my $date_from_dom = memoize( sub ($dom) {
return content_at( $dom, 'div.section_header[class~="alt"]' );
} );
What’s happening: Create a memoized helper that plucks the date from a day’s HTML and caches it. That way, if we need it again, we skip the DOM re-parse and keep the pipeline fast.
content_at is a helper function I define later.
Starting Where the App Starts
my $today_dom = Mojo::DOM->new( $ua->get("$site$path")->result->text );
What’s happening: Fetch the “today” view–the same default the app shows. This is so we have a known starting point for building the full timeline.
Collecting the Whole Timeline
my $day_doms = c(
$today_dom,
$today_dom->find(qq(div.filter_box-days > a[href^="$path?day="]))
->map( \&dom_from_anchor )
->to_array->@*,
)->sort( sub { day_epoch($a) <=> day_epoch($b) } );
What’s happening: Grab every day link from the filter bar, fetch each day’s HTML, and sort them chronologically. Now we’ve got the entire con’s schedule in memory, ready to process.
dom_from_anchor and day_epoch are two more helper functions explained further down.
Turning HTML into a Human-Readable Schedule
$day_doms->each( sub { # process each day's events
my $date = $date_from_dom->($_);
$_->find('a.bookmark[data-type="events"] + a.object_link')
->each( sub { # output start time + title
my $time = content_at( $_, 'div.line[class~="two"]' );
my $title = content_at( $_, 'div.line[class~="one"]' );
my ($start) = split /\s*\p{Dash_Punctuation}/, $time;
say "$date $start: ", decode_entities($title);
} );
} );
What’s happening: For each day, find every event link and pull out the start time and title. Split the time cleanly on any dash and decode HTML entities so the output reads like a real schedule.
The Little Routines That Make It All Work
sub dom_from_anchor ($dom) { # fetch DOM for a day link
return Mojo::DOM->new(
$ua->get( Mojo::URL->new( $dom->attr('href') )->to_abs($site) )
->result->text );
}
sub day_epoch ($dom) { # parse date into epoch
return Time::Piece->strptime( $date_from_dom->($dom), '%A, %b %e' )
->epoch;
}
# extract and trim text from selector
sub content_at ( $dom, @args ) { return trim $dom->at(@args)->content }
What’s happening:
dom_from_anchor: fetch and parses a linked days’ HTML.
day_epoch: turn a date string into a sort-able epoch.
content_at: extract and trim text from a DOM fragment, given a CSS selector.
These helpers keep the main flow readable and re-usable.
The Schedule, Unlocked
Run the script and you get a clean, UTF-8-safe list of every event, in chronological order, across all days. No swiping around, no tapping, no “what did I miss?” anxiety. (Ha, who am I kidding? There’s too much going on at Dragon Con to not end up missing something.)
An example run of the script in my terminal. Each line is “Day, Date Time: Event Title”, sorted chronologically across the whole con.
And here’s just a small slice of the 2,500+ lines it produces:
Sunday, Aug 31 11:30 AM: Unmasking Sherlock: Beyond the Many Faces Sunday, Aug 31 11:30 AM: Weaponization of the FCC and Other Agencies to Chill Speech Sunday, Aug 31 11:30 AM: Where Physics Gets Weird . . . Sunday, Aug 31 11:50 AM: Photo Session: Amelia Tyler Sunday, Aug 31 11:50 AM: Photo Session: Cissy Jones Sunday, Aug 31 11:50 AM: Photo Session: Emma Gregory . . . Sunday, Aug 31 12:00 PM: Dragon Con Mashups Sunday, Aug 31 12:00 PM: James J. Butcher and R.R. Virdi signing at The Missing Volume booth# 1300 Sunday, Aug 31 12:00 PM: JoeDan Worley and Eric Dontigney signing at the Shadow Alley Press Booth# 2 . . . Sunday, Aug 31 12:00 PM: Photo Session: Robert Duncan McNeill Sunday, Aug 31 12:00 PM: Photo Session: Robert Picardo Sunday, Aug 31 12:00 PM: Photo Session: Tamara Taylor
Key Techniques
Here’s the fun part–the techniques that make this tidy, scroll-once list possible.
CSS selectors for precision
I used a.bookmark[data-type="events" + a.object_link] to grab only the event title links, and div.line[class~="two" /div.line[class~="one"] for time and title, respectively. This avoids scraping unrelated elements.
Memoization for efficiency
memoize caches the date string for each day’s DOM so I didn’t end up re-parsing the HTML fragment multiple times.
Unicode-safe splitting
\p{Dash_Punctuation} matches any dash type (em, en, hyphen-minus, etc.), so I could split times reliably without worrying about which dash the site uses.
Functional chaining
Mojo::Collection’s map, sort, and each methods let me express the scrape→transform→output pipeline in a linear, readable way.
Entity decoding at output
HTML::HTML5::Entities’ decode_entities is applied right before printing, so HTML entities like & or " are human-readable in the final output.
A Pattern You Can Take Anywhere
The same approach that tamed Dragon Con’s chaos works anywhere you’ve got:
Predictable URLs–so you can iterate without guesswork
Consistent HTML structure–so your selectors stay stable
A need to see everything at once–so you can make decisions without paging or filtering
From fan conventions to conference schedules, from local sports fixtures to film festival line‑ups–the same pattern applies. Sometimes the right tool isn’t a sprawling framework or heavyweight API client. It’s a forty‑odd‑line Perl script that does one thing with ruthless clarity.
Because once you’ve tamed a schedule like this, the only lines you’ll stand in are the ones that feel like part of the show.
Brett’s Utils::H2O::More module amends the lightweight class builder Utils::H2O. It adds many extra methods, including command line argument processing via the Perl-packaged Getopt::Long module. It also promises to build its accessors with less ceremony and code than Moo.
So let’s dive in!
A simple script
A script can use Util::H2O::More’s Getopt2h2o function to process command line options. It returns an object with accessors for each parameter.
#!/usr/bin/env perl
use v5.38;
use Util::H2O::More v0.4.2 qw(Getopt2h2o);
# name is a string, water is an array of strings
my $o = Getopt2h2o \@ARGV, {}, qw(
name=s
water=s@
);
die "Missing --name\n" unless $o->name;
printf "Good %s, %s!\n", time_of_day(), $o->name;
if ( defined $o->water and $o->water->@* ) {
say 'What kind of water would you like?';
say "- $_" for $o->water->@*;
}
sub time_of_day {
my %hours = (
5 => 'morning',
12 => 'afternoon',
17 => 'evening',
21 => 'night',
);
for ( sort { $b <=> $a } keys %hours ) {
return $hours{$_} if (localtime)[2] >= $_;
}
return 'night';
}
This is short and readable. I like how Getopt::Long’s quirky parameter parsing syntax is repurposed to create accessors, even for multi-valued options.
You can call this script like so:
./h2options.pl --name Aquarius --water hot --water cold
We had to check that --name was set, as Util::H2O::More doesn’t support using an = modifier to specify required options. This is something Getopt::Long allows.
And as Util::H2O’s documentation suggests: “You should probably switch to something like Moo instead [for advanced features].”
But enough about limitations–what if you wanted to use this as a Perl module for testing?
Testing the waters
One of the strengths of a modulino is the ability to unit test its logic without invoking it from the shell. A typical test script looks like this:
#!/usr/bin/env perl
use v5.38;
use Test2::V0;
use modulinh2o;
plan(6);
can_ok(
'modulinh2o',
[ 'time_of_day', 'name', 'water' ],
'class has methods',
);
my $water = modulinh2o->new( name => 'Aquarius' );
isa_ok( $water, ['modulinh2o'],
'object is expected class' );
can_ok(
$water,
[ 'time_of_day', 'name', 'water' ],
'object has methods',
);
is( $water->name, 'Aquarius', 'name set' );
is( $water->name('Bob'), 'Bob', 'name change' );
is( $water->time_of_day,
in_set( qw(
morning
afternoon
evening
night
) ),
'time of day function',
);
It isn’t difficult to adapt a modulino from our earlier simple script:
#!/usr/bin/env perl
use v5.38;
package modulinh2o;
use Getopt::Long qw();
use Util::H2O::More v0.4.2 qw(h2o opt2h2o);
my @opt_spec = qw(
name=s
water=s@
);
my $o = h2o -classify => __PACKAGE__, {},
opt2h2o(@opt_spec);
sub time_of_day {
my %hours = (
5 => 'morning',
12 => 'afternoon',
17 => 'evening',
21 => 'night',
);
for ( sort { $b <=> $a } keys %hours ) {
return $hours{$_} if (localtime)[2] >= $_;
}
return 'night';
}
# constructor that parses arguments w/ basic validation
sub new_with_options ($class) {
Getopt::Long::GetOptionsFromArray(
\@ARGV, $o, @opt_spec
) or die "bad options\n";
die "Missing --name\n" unless $o->name;
return $o;
}
sub run ($self) {
printf "Good %s, %s!\n",
time_of_day(), $self->name;
if ( defined $self->water and $self->water->@* ) {
say 'What kind of water would you like?';
say "- $_" for $self->water->@*;
}
return;
}
package main;
main() unless caller;
sub main { modulinh2o->new_with_options->run() }
And run:
./modulinh2o.pm --name Aquarius \
--water sparkling --water still
This is much shorter than the Moo-based modulino from three weeks ago, but it also doesn’t do as much. There’s no support for using comma separators to pass multiple values to a single argument. Worse, there’s no automatic help text if one passes the wrong options.
Both are fixable, as we’ll see in a moment. Still, you end up having to write the POD yourself, printed out with various invocations of Pod::Usage’s pod2usage() function.
An ounce of script is worth a gallon of documentation
Here’s a full example that adds both --help and --man command line options, as typically provided by traditional Getopt::Long-based scripts:
#!/usr/bin/env perl
use v5.38;
package modulinh2o2;
use Getopt::Long qw();
use Util::H2O::More v0.4.2 qw(h2o opt2h2o);
use Pod::Usage;
my @opt_spec = qw(
name=s
water=s@
help
man
);
my $o = h2o -classify => __PACKAGE__, {},
opt2h2o(@opt_spec);
sub time_of_day {
my %hours = (
5 => 'morning',
12 => 'afternoon',
17 => 'evening',
21 => 'night',
);
for ( sort { $b <=> $a } keys %hours ) {
return $hours{$_} if (localtime)[2] >= $_;
}
return 'night';
}
# different parameter mixes for pod2usage()
my %pod2usage_opt = (
cmdline => {
-exitval => 2,
-verbose => 99,
-sections => 'USAGE/Command line',
-message => 'Use --help to list options',
},
opts => {
-exitval => 0,
-verbose => 99,
-sections => ['USAGE/Command line', 'OPTIONS'],
},
man => {
-exitval => 0,
-verbose => 2,
},
);
sub new_with_options ($class) {
Getopt::Long::GetOptionsFromArray(
\@ARGV, $o, @opt_spec
) or pod2usage( %pod2usage_opt{cmdline} );
pod2usage( $pod2usage_opt{opts} ) if $o->help;
pod2usage( $pod2usage_opt{man} ) if $o->man;
pod2usage( %pod2usage_opt{cmdline},
-message => 'Missing --name',
) unless $o->name;
# default values for the water parameter
$o->water(
( defined $o->water and $o->water->@* )
? [ split /,/, join q{,}, $o->water->@* ]
: [ qw(
still
sparkling
tap
) ] );
return $o;
}
sub run ($self) {
printf "Good %s, %s!\n",
time_of_day(), $self->name;
say 'What kind of water would you like?';
say "- $_" for $self->water->@*;
return;
}
package main;
main() unless caller;
sub main { modulinh2o2->new_with_options->run() }
# the rest below is documentation
__END__
=head1 NAME
modulinh2o2 - demo of a modulino using Util::H2O::More
=head1 USAGE
=head2 Command line
modulinh2o2.pm [options]
=head2 Perl
use modulinh2o2;
my $water = modulinh2o2->new(
name => 'Aquarius',
water => [ qw(
sparkling
still
tap
) ],
);
$water->run;
=head1 OPTIONS
=over
=item B<--name>
Your name here! (required)
=item B<--water>
Type of water to serve. May be specified multiple times, either by repeating the option or separated by commas.
Takes an arrayref when used as a method or construction parameter.
Default values:
=over
=item still
=item sparkling
=item tap
=back
=item B<--help>
Displays a brief help message.
=item B<--man>
Display full documentation as a manual page.
=back
=head1 DESCRIPTION
A sample L<Util::H2O::More> modulino that prints your name, what part of the day it is, and a menu of water choices.
=head1 METHODS
=head2 new
Constructor that takes the above L</OPTIONS> but without the preceding C<-->.
=head2 new_with_options
Alternate constructor that receives its parameters from C<@ARGV>.
=head2 run
Prints out a greeting along with a selection of refreshing drinks.
=head2 ACCESSOR OPTIONS
All of the L</OPTIONS> above are also available as method accessors but without the preceding C<-->.
=head1 FUNCTIONS
=head2 time_of_day
Returns the period of the day based on local time. Possible values are:
=over
=item morning
=item afternoon
=item evening
=item night
=back
=head1 AUTHOR
Mark Gardner <[email protected]>
=head1 LICENSE AND COPYRIGHT
This software is copyright (c) 2025 by Mark Gardner.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.
Now we can run either:
./modulinh2o2.pm --help
to get a quick help message, or:
./modulinh2o2.pm --man
to show the full manual page.
Yes, over half of the line count is documentation. Even granting this the code amount is now comparable to the Moo-based version. And you don’t get the advantage of MooX::Options’ declarative syntax.
To summarize, here’s a table charting the evolution of our modulinh2o:
Stage
Pros
Cons
Complexity
Simple script: Getopt2h2o only
* Minimal code footprint * Instant accessors from CLI arguments * Multi-valued options via @ syntax
* No required-argument enforcement * No auto-help * Manual validation
Low: about 20 lines, one file
Basic modulino: h2o + opt2h2o
* Testable as a module * Reusable new_with_options constructor * Keeps brevity vs. Moo
* Still no comma-separated multi-values * No auto-help on bad arguments
Medium: adds package structure, test harness
Full modulino w/help & man: Pod::Usage integrated
* Support for --help and --man * Comma-separated multi-values handled * Defaults for missing --water * Clearer on-boarding for new users
* More boilerplate * Over half the file is POD * Still less declarative than MooX::Options
High: more moving parts, but user-friendly
Util::H2O::More modulino evolution and feature trade-offs
Seen together, these stages trace a course from a bare-bones script to a well-provisioned modulino. Each step adds features at the cost of a little more complexity.
In the end, Util::H2O::More delivers a lean, testable modulino with far less boilerplate than Moo–but also fewer built‑in niceties. If you value speed from idea to working script and can live without declarative option handling, it’s a compelling choice. For more structured needs, MooX::Options still has the edge. Either way, the path from script to production-ready modulino is shorter than you think.
The best tool is the one that flows from idea to “it works” in a single pour.