(λ (x) (create x) '(knowledge))

A potential Gem?

Thoughts on Ruby ยท June 22nd, 2024

We're back! I've had so little time to properly blog, it's really not surprising given everything that has happened this year, but that doesn't mean I don't miss it. A huge thank you is in order though, Mio's blog post on Vaporbot is a welcome addition to Lambdacreate, and it's really just kind of awesome to have a guest article to keep things fresh and engaging while I'm trying desparately to get back into the swing of things. Unfortunately, I think for the time being I'm juggling all things baby in what little free time I have, so the intermittent posting shall continue.

Anyways, the last couple of posts have been Ruby focused, it's almost thematic at this point. How could I not write more on the matter? I mean, I must absolutely LOVE this language if I'm willing to suffer through writing code on a Palm PDA right? Well, maybe, not exactly. I think I initially went into Ruby thinking I wouldn't like it, kind of convinced that it fits the same niche as Python or Lua, and I really don't have any real need for yet another scripting language.

But is that actually true?

Python is the swiss army knife of scripting languages. It does literally anything and everything. Just consider for the moment that this language powers full blow system orchestration systems like SaltStack and Ansible, to web application frameworks lke Django, to nearly everyones initial introduction to programming. It's the language we all reach for when we just want to do a thing, get it done, and get on with the next thing.

And Lua, well it's small, incredibly small. And kind of feature barren in comparison to Python. I mean we haven't shoved the kitchen sink inside the language, and the community isn't nearly as large as Python's. But despite that there's web application frameworks like Lapis, and the language exists nearly everywhere in some version. If you want speed LuaJit knocks it out the box. And the entire language can be embedded inside of other applications to enable complex run time dynamic configuration. Heck Love2D is a great and really cool example of this.

Hard to imagine that between these two things I could need yet another different scripting language, right?

Well I was wrong

It turns out that I DO need yet another language, and Ruby is a good fit for a particular problem that both Python and Lua share. And that problem is the ecosystem.

"But Will, the Python ecosystem is vast and impressive!" and you're not wrong. But it is woefully fragemented, riddled with circular dependenices, and suffers terribly from the competing standards problem. You can do literally anything with Python, but there's almost too many ways to accomplish that task. They're all very fragile unless you spend substantial time tracking down whatever is the supported method du jour. And that standard will change out from under you without warning.

"You don't know what you're talking about Will!" Maybe? Maybe not. I maintain a solid amount of Python packages for Alpine Linux. I write Python code for work. I think if anything I'm looking too far under the hood and not just running pip3 with --break-system-packages.

By comparison Lua has luarocks, which also isn't awesome, but in different ways. It overwrites distro maintained libs in the same way that that pip3 command would. But more so it's a literal wild west insofar far as what code is contributed. I maintain plenty of lua libs in Alpine as well, and have a few published ot luarocks. The ecosystem just doesn't feel robust or maintained.

So Ruby must be better right? Well sort of, the Gem ecosystem seems a little older, a little better maintained. But I think the situation is nuanced. Ruby has a major driving force behind it thanks to the popularity of Ruby on Rails, which is used to drive major projects like Gitlab. Lua doesn't really have that, and Python has too much of it! If you use Lua and want a package manager you suffer through luarocks, there is no alternatively, and the restaurant is almost entirely closed. Occassionally someone orders take out, and maybe someone pops in to wip that food up. Python on the other hand has a head chef, he publishes thoughtful documentation on the what and how, the recipe so to say. There are also several thousand other chefs ignoring it all and doing it their own way.

Ruby has one way, the Ruby way. And sometimes I just don't want to fight the horde of chefs, and I need something a little bit better maintained than the empty shop on the corner. Ruby feels like that corner Deli you go to, the owner works the counter, you order a pastrami on white bread with mayo, and they give it to you on rye bread with mustard because that's the only way you can make a pastrami.

Sometimes the box is good

The net gain in my mind, is that there is one way to think about doing things. I only need to track how to use the Gem ecosystem. I can expect to find a plethora of handy libraries in that ecoystem, some of which might be a little bit dated, but useful nonetheless! If it doesn't build there's a good chance that someone else has written a differnet library, because that ecosystem is still very much alive.

Lets look at a real example. I have a little mealie server at home I run for the wife and I. We have hundreds and hundreds of recipes and it has become a cornerstone of our budgeting and planning. Naturally that means it's somewhat important that it gets updated somewhat frequently, at least when there are compelling features or security issues.

But I run that server in an incus container, which in turn runs the upstream docker container. The little web portal will tell you when you need to update, but I'm never in the portal to do adminsitrative things, I'm in there to look up how to make spam musubi or some other tasty treat. There's also nothing that can be done from that web portal if you do check the version. If I check it and it says it's outdated, then I put down my phone, pull out a laptop to jump into the container and edit the openrc service for the container with the new version. It's a bit of a drudge frankly.

Enter some not so fancy, honestly very simple, ruby code! This took maybe 30-40m to figure out, which is a nice feeling that I think comes with any lanugage you're really familiar with, but came faster than my experience with previous languages.

The script itself is only meant to check whether or not the version reported by the mealie git repo is the version running in the container, and if it isn't, modify the openrc script with the newest version.

#!/usr/bin/ruby
require 'nokogiri'
require 'httparty'

# To Install:
# apk add ruby ruby-dev libc-dev gcc
# gem install nokogiri
# gem install httparty

$initcfg = "/etc/init.d/mealie"
$feed = "https://github.com/mealie-recipes/mealie/releases.atom"

# Given an Github Atom releases feed, and assuming title contains just versioning info (ie: v1.5.1), return the version of the last update
def findGitVer(url)
	resp = HTTParty.get(url)
	atom = Nokogiri::XML(resp.body)

	ver = atom.css("entry title").first
	return ver.text.gsub("v", "")
end

# Assuming an openrc init file, with an argument version=#.#.# (ie: version=1.4.0), return the currently configured version
def findInitVer(file)
	File.open(file) do |f|
		f.each_line do |line|
			if line.include? "version=" then
				return line.gsub("version=", "").strip
			end
		end
	end
end

# Compare the configured init version against the reported current version from git, and update the openrc init file to the latest version.
def updateInit(gitver, initver)
	if gitver > initver then
		text = File.read($initcfg)
		File.open($initcfg, "w") { |file| file.write(text.gsub("version=#{initver}", "version=#{gitver}")) }
		puts "#{$initcfg} has been updated to #{gitver}"
		system("rc-service mealie restart")
	elsif gitver == initver then
		puts "Init is configured to use #{initver} which is the same as the version reported by git #{gitver}"
	else
		puts "Something has gone wrong. Init says #{initver} and Git says #{gitver}?"
	end
end

git = findGitVer($feed)
init = findInitVer($initcfg)
updateInit(git, init)

Nothing special going on right? I could have easily done this with Python and bueautifulsoup, but if I had done it in Python I'm not entirely certain how reliable it would have been. Ruby has been fire and forget here, write it once and there's very little expectation that things break terribly. Perhaps some minor syntactic changes between versions, but it has been very minor.

Comparatively, upgrding from Python 3.11 -> 3.12 in Alpine recently uncovered a mesh of circular dependencies, precarious and disparate build processes and other strange errors.

Just [upgrading py3-iniconfig], a seemingly simple lib that gets imported by pytest, required dragging 6 r 7 other libraries through various changes. Either upgrading versions, disabling tests, or bypassing native builds in some cases. And this upgrade was a minor bump where iniconfig started using a different build system. This is an isolated problem, but unfortunately not an uncommon one. Rebuilding the couple thousand python packages in the Alpine ecosystem ahead of Alpine 3.20's release took multiple maintainers multiple weeks to sort through. And the entire process was precarious and needed thoughtful sequencing to pull off.

[https://gitlab.alpinelinux.org/alpine/aports/-/merge_requests/61309]: upgrading py3-iniconfig

Sometimes the box is bad

Maybe I'm being unfair though, Ruby isn't all good. Notice my little build notes? That's right, I'm pulling things in from Gem! Of course it's better, if I just use Python Venv's and pip I wouldn't have this problem. Or maybe Nix/Guix would be even better?

Strangely not a lot of Ruby libs appear to be packaged for Alpine, I'm not certain what the case for this is. Are they materially harder to maintain? They appear to compile in a lot of cases, but this is no different than a lot of Lua or Python libs.

Maybe as I keep digging into this ecosystem I will find that the packaging is just as bad, and the only good option is Golang's "include the world" because the world always breaks otherwise. (But I fundamentally disagree with this take as well). There's probably no solution that meets every single use case, but I firmly believe relying on the distro's maintenance and packaging is closer to right than --break-system-packages will ever be.

So right, what's not to like about Ruby? Well, it's kind of slow. Not in a way that makes it unusable, but in the sense that it uses a massive [amount of CPU to perform]. Maybe I'm once again looking too far under the hood here, but on 32bit systems Ruby is just plain slow. No issues whatsoever on aarch64/x86_64 systems. And sometimes it doesn't really matter how long something takes to complete. Like that mealie version script, I run it with an ansible playbook when I apk upgrade, who cares if it takes 5s to run?

[https://benchmarksgame-team.pages.debian.net/benchmarksgame/fastest/lua-ruby.html]: amount of CPU to perform

But then there's something like my rat info tool. This is a rewrite of a tool I wrote in Nim during the last OCC. Rat poison doesn't have a status bar like i3, but it can run arbitrary commands on a key combo. The idea here is to generate a little bit of info in the upper right hand window populated almost entirely from paths in /sys and /proc to make it as portable as possible. Now the nim version worked flawlessly, milisecond execution, really we can't be surprised that's a compiled language.

On the other hand, this bit of Ruby, while incredibly easy to write and debug, takes 3s to run on my Acer ZG5.

#!/usr/bin/ruby

def readFile(path)
  f = File.open(path, "r")
  return f.read
end

def findOneStr(file, match)
  File.readlines(file, chomp: true).each do |line|
    if line.include? match then
      return line
    end
  end
end

def getFirstLine(file)
  line = File.foreach(file).first
  return line
end
  
def batt(battery)
  sys = "/sys/class/power_supply/"

  if File.exist?(sys + battery + "/capacity") then
    perc = readFile(sys + battery + "/capacity").strip.to_i
  else
    max = readFile(sys + battery + "/charge_full").strip.to_i
    now = readFile(sys + battery + "/charge_now").strip.to_i
    perc = (now * 100) / max
  end
  return perc
end

def batteries
  sys = "/sys/class/power_supply/"
  batteries = Dir.glob(sys + "*").select {
    |path|
    path.include? "BAT"}
  total = 1
  if batteries.length < 1 then
    return nil
  else
    for battery in batteries do
                     name = battery.gsub!(sys, "")
                     total += batt(name)
                   end
      perc = (total / batteries.length)
      return perc
    end
end

def ctemp
  sys = "/sys/class/thermal/thermal_zone0/"
  if File.exist?(sys) then
    now = readFile(sys + "temp").strip.to_i
    t = now / 1000
    return t.round
  else
    return nil
  end
end

def cmem
  procf = "/proc/meminfo"
  totalkb = findOneStr(procf, "MemTotal:").match(/[0-9]+/).to_s.to_i
  availkb = findOneStr(procf, "MemAvailable:").match(/[0-9]+/).to_s.to_i
  perc = ((totalkb - availkb) * 100) / totalkb
  return perc.round
end

def cdate
  time = Time.new
  now = time.strftime("%Y-%m-%d %H:%M:%S")
  return now
end

def ccpustart
  return start
end

# /proc/stat
# user   nice system  idle      iowait irq    softirq steal_time virtual
#5818150 0    3852330 212448562 278164 567572 507430  477889     0
def ccpu
  start = getFirstLine("/proc/stat").match(/([0-9]+ )+/).to_s # Pull values from proc
  startmap = start.split.map(&:to_i) #map to an array of intergers
  sleep(1) # delay to generate data
  stop = getFirstLine("/proc/stat").match(/([0-9]+ )+/).to_s # Pull delta values from proc
  stopmap = stop.split.map(&:to_i) # map to an array of integers
  total = stopmap.sum - startmap.sum # delta difference between the two sums
  idle = stopmap[3] - startmap[3] # delta difference between the two idle times
  used = total - idle # subtract idle from total to get rough usage
  notidle = (100 * used / total).to_f # generate percentile usage
  return notidle.round
end

def main
  info = {
    perc: batteries,
    cal: cdate,
    temp: ctemp,
    mem: cmem,
    cpu: ccpu,
  }
  
  #C: 8% | M: 19% | T: 52.0c | B: 82.0% | 2023-06-07 13:01:06
  #if info[:batt].nil? and info[:temp].nil? then
  #  puts "C: #{info[:cpu]}% | M: #{info[:mem]}% | #{info[:cal]}"
  #elsif info[:batt].nil? and !info[:temp].nil? then
  #  puts "C: #{info[:cpu]}% | M: #{info[:mem]}% | T: #{info[:temp]}c | #{info[:cal]}"
  #elsif !info[:batt].nil? and info[:temp].nil? then
  #  puts "C: #{info[:cpu]}% | M: #{info[:mem]}% | B: #{info[:perc]}% | #{info[:cal]}"
  #else
    puts "C: #{info[:cpu]}% | M: #{info[:mem]}% | T: #{info[:temp]}c | B: #{info[:perc]}% | #{info[:cal]}"
  #end
end

main

And fundamentally this is written the same way the nim program was. I'm willing to accept that maybe that's a me issue, I shouldn't use computers that are old enough to buy their own alcohol, and I could probably write more performant code. But I think the intent is important. Without trying to write performant code in a compiled language, like Nim, or Golang, or Rust, you can get really solid performance. The difference is time invested in producing the thing.

So what now?

For me Ruby seems to fit in an interesting gap between Python, Lua, and maybe even Go. Like Golang I find it incredibly easy to work with, I can get ideas into Emacs really quickly, I can start testing those immediately without worrying about compilation, and I have a high degree of confidence that the tool once written can be reproduced and run elsewhere. It just feels a little bit more robust than Python from a long term management perspective if that makes sense.

Will I use it further? I think yes! I've rewritten a few different tools at work from Fennel to Ruby and that has been a delightful and rewarding experience. It also helps that we have a few Ruby on Rails stacks that we manage, so the skillset won't go to waste.

But for the record, I don't think I'll be dropping Lua or Python out of my frequent rotation of languages. They each have their weaknesses and strengths, I'm just tired of cutting myself trying to chase down Python deps. And I can't fathom the idea of learning Nix just to make that problem go away, the trade off in complexity is just not worth it in my mind.