Structure and Interpretation of Ruby Programs

F78e9e14b7a79742c7109b6d36e3fbd5?s=47 Tim Uruski
December 06, 2016

Structure and Interpretation of Ruby Programs

A lightning talk about how to structure one-off scripts that don't turn into a huge mess as features are added.

F78e9e14b7a79742c7109b6d36e3fbd5?s=128

Tim Uruski

December 06, 2016
Tweet

Transcript

  1. Tim Uruski Clio and YYC Ruby @timuruski

  2. Structure and Interpretation of Computer Programs Ruby

  3. "I need you to export some data."

  4. #!/usr/bin/env ruby require 'cheezburger' db = DatabaseWrapper.default_connection user_id = ARGV.shift

    locations = LocationStream.new(db, user_id) puts locations
  5. "Actually I need it formatted as JSON."

  6. #!/usr/bin/env ruby require 'cheezburger' require 'json' db = DatabaseWrapper.default_connection user_id

    = ARGV.shift locations = LocationStream.new(db, user_id) geojson = { "type" => "FeatureCollection", "features" => locations.map { |location| { "type" => "Feature", "geometry" => { "type" => "Point", "coordinates" => [location.lng, location.lat] } "properties" => { "name": "location.name" } } } } end print JSON.pretty_generate(geojson)
  7. "Can you filter it by date range?"

  8. #!/usr/bin/env ruby require 'cheezburger' require 'json' db = DatabaseWrapper.default_connection user_id

    = ARGV.shift query_date = ARGV.shift || Tume.now min_recorded_at = query_date.beginning_of_day.utc.to_i * 1000 max_recorded_at = query_date.end_of_day.utc.to_i * 1000 query = { order_by: 'recorded_at', start_at: min_recorded_at, end_at: end_recorded_at } locations = LocationStream.new(db, user_id, query) geojson = { "type" => "FeatureCollection", "features" => locations.map { |location| { "type" => "Feature", "geometry" => { "type" => "Point", "coordinates" => [location.lng, location.lat] } "properties" => { "name": "location.name" } } } } end print JSON.pretty_generate(geojson)
  9. "We also need the data from other environments."

  10. #!/usr/bin/env ruby require 'cheezburger' require 'json' if database_url = ENV.fetch('DATABASE_URL')

    db = DatabaseWrapper.new(database_url) else db = DatabaseWrapper.default_connection end user_id = ARGV.shift query_date = ARGV.shift || Tume.now min_recorded_at = query_date.beginning_of_day.utc.to_i * 1000 max_recorded_at = query_date.end_of_day.utc.to_i * 1000 query = { order_by: 'recorded_at', start_at: min_recorded_at, end_at: end_recorded_at } locations = LocationStream.new(db, user_id, query) geojson = { "type" => "FeatureCollection", "features" => locations.map { |location| { "type" => "Feature", "geometry" => { "type" => "Point", "coordinates" => [location.lng, location.lat] } "properties" => { "name": "location.name" } } } } end print JSON.pretty_generate(geojson)
  11. "Can I just give you the user's email address instead

    of that weird ID?"
  12. #!/usr/bin/env ruby require 'cheezburger' require 'json' if database_url = ENV.fetch('DATABASE_URL')

    db = DatabaseWrapper.new(database_url) else db = DatabaseWrapper.default_connection end identifier = ARGV.shift begin user = UserStore.new(db).find(identifier) rescue NoUserFound warn "No user found for #{identifier}" exit 1 end query_date = ARGV.shift || Tume.now min_recorded_at = query_date.beginning_of_day.utc.to_i * 1000 max_recorded_at = query_date.end_of_day.utc.to_i * 1000 query = { order_by: 'recorded_at', start_at: min_recorded_at, end_at: end_recorded_at } locations = LocationStream.new(db, user.id, query) geojson = { "type" => "FeatureCollection", "features" => locations.map { |location| { "type" => "Feature", "geometry" => { "type" => "Point", "coordinates" => [location.lng, location.lat] } "properties" => { "name": "location.name" } } } } end print JSON.pretty_generate(geojson)
  13. "Can you also filter by X, Y and Z fields?"

  14. #!/usr/bin/env ruby require 'cheezburger' require 'json' window_size = 10 stay_radius

    = 500 activity_threshold = 2.5 OptionParser.new do |opts| window_desc = 'Window size in minutes, default: 5' opts.on('-wSIZE', '--window=SIZE', window_desc, Integer) do |value| window_size = value * 60 end activity_desc = 'Activity within window threshold, default: 23' opts.on('-aCOUNT', '--activity=COUNT', activity_desc, Integer) do |value| activity_threshold = value end radius_desc = 'Size of stay radius in metres, default: 50.0' opts.on('-rSIZE', '--radiusSIZE', radius_desc, Float) do |value| stay_radius = value end end.parse! if database_url = ENV.fetch('DATABASE_URL') db = DatabaseWrapper.new(database_url) else db = DatabaseWrapper.default_connection end identifier = ARGV.shift begin user = UserStore.new(db).find(identifier) rescue NoUserFound warn "No user found for #{identifier}" exit 1 end query_date = ARGV.shift || Tume.now min_recorded_at = query_date.beginning_of_day.utc.to_i * 1000 max_recorded_at = query_date.end_of_day.utc.to_i * 1000 query = { order_by: 'recorded_at', start_at: min_recorded_at, end_at: end_recorded_at } locations = LocationStream.new(db, user.id, query) geojson = { "type" => "FeatureCollection", "features" => locations.map { |location| { "type" => "Feature", "geometry" => { "type" => "Point", "coordinates" => [location.lng, location.lat] } "properties" => { "name": "location.name" } } } } end print JSON.pretty_generate(geojson)
  15. "How do I use that script you wrote for exports?"

  16. #!/usr/bin/env ruby require 'json' require 'optparse' require 'bundler/setup' require 'active_support'

    require 'active_support/core_ext/time' require_relative '../lib/cheezburger' include Cheezburger window_size = 10 stay_radius = 500 activity_threshold = 2.5 OptionParser.new do |opts| window_desc = 'Window size in minutes, default: 5' opts.on('-wSIZE', '--window=SIZE', window_desc, Integer) do |value| window_size = value * 60 end activity_desc = 'Activity within window threshold, default: 23' opts.on('-aCOUNT', '--activity=COUNT', activity_desc, Integer) do |value| activity_threshold = value end radius_desc = 'Size of stay radius in metres, default: 50.0' opts.on('-rSIZE', '--radiusSIZE', radius_desc, Float) do |value| stay_radius = value end end.parse! if ARGV.empty? warn "Usage: [DATABASE_URL=<url>] #{$0} <USER_EMAIL or USER_ID> [2016-02-01]" exit 1 end user_id = args.shift date = args.shift database_url = ENV.fetch('DATABASE_URL') database = DatabaseWrapper.new(database_url) def find_user(database, identifier) begin user = UserStore.new(database).find(identifier) rescue NoUserFound warn "No user found for #{identifier}" exit 1 end end def fetch_locations(database, user, query_date) min_recorded_at = query_date.beginning_of_day.utc.to_i * 1000 max_recorded_at = query_date.end_of_day.utc.to_i * 1000 query = { order_by: 'recorded_at', start_at: min_recorded_at, end_at: end_recorded_at } begin LocationStream.new(database, user, query) rescue NoLocationsFound export_date = query_date.strftime('%Y-%m-%d') warn "No locations found for #{export_date}" exit 1 end end def to_coords(location) [location.lng, location.lat] end user = find_user(database, options[:user_id]) query_date = options[:date] ? Time.parse(options[:date]) : Time.now locations = fetch_locations(database, user, query_date) geojson = { "type" => "FeatureCollection", "features" => locations.map { |location| { "type" => "Feature", "geometry" => { "type" => "Point", "coordinates" => coords(location) } "properties" => { "name": "location.name" } } } } end print JSON.pretty_generate(geojson)
  17. "One more thing..."

  18. Time to refactor

  19. "I'll extract some methods, that's a good trick!"

  20. Execution Dependency

  21. Inverted Pyramid Headline Who? What? Where? When? Background Details General

    Info
  22. Execution

  23. Execution Dependency

  24. at_exit do # script goes here end

  25. at_exit do opts = parse_opts result = frobulate(opts) puts result

    end
  26. 1.Wrap the script core in at_exit 2.Organize dependencies downwards 3.Parse

    options in one place
  27. Example

  28. #!/usr/bin/env ruby puts "Hello, #{ARGV.shift || 'world'}"

  29. #!/usr/bin/env ruby require 'optparse' name = 'world' OptionParser.new do |opts|

    opts.on('-n=NAME', '--name=NAME', 'Name to greet') do |value| name = value end end.parse! puts "Hello, #{name}"
  30. #!/usr/bin/env ruby require 'optparse' def greet(name) puts "Hello, #{name}" end

    options = { name: 'world' } OptionParser.new do |opts| opts.on('-n=NAME', '--name=NAME', 'Name to greet') do |value| options[:name] = value end end.parse! greet(options[:name])
  31. #!/usr/bin/env ruby require 'optparse' def greet(name) puts "Hello, #{name}" end

    options = { name: 'world' } OptionParser.new do |opts| opts.on('-n=NAME', '--name=NAME', 'Name to greet') do |value| options[:name] = value end end.parse! greet(options[:name])
  32. #!/usr/bin/env ruby require 'optparse' at_exit do options = parse_options greet(options[:name])

    end def parse_options(args = ARGV) { name: 'world' }.tap do |options| parser = OptionParser.new do |opts| opts.on('-n=NAME', '--name=NAME', 'Name to greet', String) do |value| options[:name] = value end end parser.parse!(args) end end def greet(name) puts "Hello, #{name}" end
  33. #!/usr/bin/env ruby require 'optparse' at_exit do options = parse_options greet(options[:name])

    end def parse_options(args = ARGV) { name: 'world' }.tap do |options| parser = OptionParser.new do |opts| opts.on('-n=NAME', '--name=NAME', 'Name to greet', String) do |value| options[:name] = value end end parser.parse!(args) end end def greet(name) puts "Hello, #{name}" end
  34. #!/usr/bin/env ruby require 'optparse' at_exit do options = parse_options greet(options[:name])

    end def parse_options(args = ARGV) { name: 'world' }.tap do |options| parser = OptionParser.new do |opts| opts.on('-n=NAME', '--name=NAME', 'Name to greet', String) do |value| options[:name] = value end end parser.parse!(args) end end def greet(name) puts "Hello, #{name}" end
  35. 1.Wrap the script core in at_exit 2.Organize dependencies downwards 3.Parse

    options in one place
  36. Confident Ruby http://confidentruby.com/

  37. Structuring Ruby Scripts http://timuruski.net/blog/2016/structuring-ruby-scripts/

  38. Helpful Gist https://git.io/sirp