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.
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.
Last week I wrote about developing a Perl module enabling me to output log entries to macOS’ unified logging system. This weekend’s adventure involved porting that module’s manual processes. These processes included dependency management, documentation syncing, version bumping, and release. The goal was to make everything more automated and repeatable.
And maybe even… monstrous.
I was already familiar with the Dist::Zilla (DZil to its friends) suite of tools and plugins. I also knew that some Perl developers view it as a huge barrier to entry. This perception affects their willingness to contribute to others’ projects.
So even though Log-Any-Adapter-MacOS-OSLog was a small module of interest to a limited coding audience (macOS Perl users), I thought it wise to have both a main branch and separate build/main branch for those programmers who wanted to work as though things had barely changed:
Entire source code with full POD-formatted documentation;
A Makefile.PL script to generate a portable building, testing, and installing Makefile;
Plus, every Perl distribution should supply the typical README, MANIFEST, LICENSE, and CONTRIBUTING documentation. This is essential if it’s meant for public consumption.
A small distribution would also give me a model I can scale up for larger projects. At the very least, it was another learning opportunity for me.
Boy, was it.
Why Dist::Zilla?
Because I know it can automate away the boilerplate code and repetition of information that’s unfortunately necessary in a modern Perl module distribution:
the version numbers in every module and script
the README that often includes the same introductory text as the main module’s documentation
the naming, order, and content of POD sections (via DZil’s sister suite, Pod::Weaver), some of which repeat distribution metadata like author, version, support, copyright, license, and so on
Because just as in the story of Goldilocks and the three bears, everything else I looked at seemed wanting in some way:
Module::Build/Module::Build::Tiny: Although minimal, pure-Perl, and easy to install, Module::Build proper still requires shipping extra boilerplate and duplicate metadata. ::Tiny shaves that down further but drops whole swaths of functionality. As an example, you can’t specify at setup time that users need a specific operating system. It’s a deal-breaker for a module that requires macOS 10.12 Sierra or newer.
Minilla: Opinionated convention over configuration, but I don’t share its opinions. Overriding directory layout, test structure, and README/license handling would mean fighting Minilla’s defaults, DZil-config style, without DZil’s plugin ecosystem.
ShipIt: Simple, one-file configuration. But too simple: it’s mostly release automation and not authoring automation. Everything I wanted to tool away is still manual.
No or minimal toolchain: That was how I started this module, with ExtUtils::MakeMaker. Totally manual, with every contributor, including yours truly, needing to remember all the moving parts by hand.
Off to see the lizard!
My DZil dist.ini configuration file started off simply enough:
name = Log-Any-Adapter-MacOS-OSLog
author = Mark Gardner <[email protected]>
license = Perl_5
copyright_holder = Mark Gardner
version = 0.0.5 ; bump as appropriate
[MetaResources]
repository.type = git
bugtracker.web = https://codeberg.org/mjgardner/perl-Log-Any-Adapter-MacOS-OSLog/issues
[Repository]
web = https://codeberg.org/mjgardner/perl-Log-Any-Adapter-MacOS-OSLog
That [MetaResources] is the first evidence of using plugins. It provides a nice tidy way to specify the metadata. Automated tools can use this information to index, examine, package, or install Perl distributions.
So far so good. But then the monster attacked.
[@Filter]
-bundle = @Basic
-remove = GatherDir
-remove = MakeMaker
-remove = Readme
; License plugin still active here
[GatherDir]
include_dotfiles = 1
exclude_filename = .gitignore
exclude_filename = .build
[ReadmeAnyFromPod]
type = markdown
filename = README.md
location = build
[CopyFilesFromBuild]
copy = README.md
; various MakeMaker::*, Git::*, Pod::Weaver, etc. plugins
I was getting errors from the dzil --build command as it attempted to add the same file multiple times. These were generated files committed to the main version control branch and a build/main branch for non-DZil contributors.
This brought me to a dead stop. I went down a rabbit-hole examining built files from rsync(1) with [Run::AfterBuild]., comparing them to the branch I had set up.
Eventually I traced the LICENSE file duplication to dueling DZil plugins. The aforementioned [GatherDir] was dutifully copying the file from my working root even as the [License] plugin was generating it. I didn’t want to lose the latter source of whatever license I happened to be using to distribute this code.
And .perlcriticrc? It turns out the [PruneCruft] plugin doesn’t listen to its friend [GatherDir] and was hoovering it away.
In the end, I had to expand my manually-configured plugin roster a little to keep them from fighting over what files came from where:
[CopyFilesFromBuild] copies it along with the README into the root for my commit,
And [GatherDir] doesn’t stomp on it like so many Tokyo city blocks.
The build/main branch for non-DZil contributors would contain exactly what they and I expected. It would be a match of the DZil working copy plus artifacts, git cloneable with no surprises.
Lessons learned, and what’s next?
I wanted to serve two styles of development and had signed myself up for a complicated authoring process. I now have more knowledge of which plugin runs in each phase of the build. This allows me to decide between generated and version-controlled files.
CPAN has a rich variety of personal Dist::Zilla::PluginBundle::s factoring individual authors’ preferences into a single place. Those authors don’t have to copy-and-paste DZil configurations around. It’s past time for me to mint one of my own bundles for more consistent and well-understood Perl distributions. Then I can start spinning up new projects without revisiting the same pain.
This weekend has brought on a mental shift. I moved from fighting DZil’s defaults to making it work my way. I also found satisfaction in a predictable, minimal-effort Perl release pipeline.
The monster was just a guy in a rubber suit all along.