Custom Steps
When no step type and no formula can express something, write a step in Ruby, give check what it needs, and load it with –steps.
Licensed on request
The engine (the Ruby gem that runs blueprints in your own process, with its local CLI, Studio and the Rails integration) is not published. It is licensed on request: email rhys117@gmail.com to ask about a license.
After reading this guide, you will know:
- When a custom step is the right tool, and when it is not.
- How to scaffold one with
reckoning new-step. - How a step registers itself, and what
takesandproblemsgivecheck. - How to load custom steps, and why a blueprint can never load them itself.
When to Write One
Prefer data where it works. A formula, a table and an imported calculation are easier to check and share than code. Splitting time at clock hours and walking a tree of parts inside parts are the usual reasons to reach for a step: neither is a formula over values the blueprint already has.
Scaffolding
$ reckoning new-step sum_field
Wrote steps/sum_field.rb
Wrote steps/sum_field.yml
Wrote steps/sum_field.cases.yml
That is the step, an example blueprint that uses it, and cases for the example. They pass as generated:
$ reckoning --steps steps test steps/sum_field.yml steps/sum_field.cases.yml
Loaded custom steps from steps. They are Ruby code; only load steps you trust.
✓ 1 case passed
--dir DIR writes somewhere other than steps/. The name must be snake_case, and new-step refuses to
overwrite files that exist.
The generated step:
Reckoning::Blueprint.register_step(
:sum_field,
purpose: 'Adds up one field across the rows of a list or table.',
example: <<~YAML,
- name: total
sum_field: {of: lines, field: amount}
YAML
takes: %i[of field],
problems: -> (spec, scope) { scope.undefined([spec[:of]]) }
) do |spec, _blueprint|
rows = spec.fetch(:of).to_sym
field = spec.fetch(:field).to_sym
lambda do |values|
Reckoning::Outcome.new(value: values.fetch(rows).sum { |row| row.fetch(field) }, detail: {of: rows, field:})
end
end
What a Step Is
register_step(name, purpose:, example:, takes:, needs:, problems:) adds a step type. The block receives
the step’s YAML settings (spec, with symbol keys) and the blueprint, and returns something that answers
call(values) with Reckoning::Outcome.new(value:, detail:). The block runs once, when the blueprint
loads; call runs every time the step does.
| Option | Means |
|---|---|
purpose, example |
what reckoning describe shows for it |
takes |
the keys the step accepts. check reports any other: sum_field does not take extra; it takes of, field |
needs |
the keys it requires, takes by default |
problems |
-> (spec, scope) { [...] }: the names and formulas it reads, so check reports a misspelt one |
The scope a problems hook receives answers:
| Method | Gives |
|---|---|
scope.undefined(names) |
a problem for each name that is not an input, table or earlier step |
scope.references(formulas) |
problems for each formula that cannot be read or uses an unknown name |
scope.known?(name) |
whether a name is known by this step |
scope.calculation(name) |
a calculation of the blueprint, for steps that run one |
With a problems hook, a custom step is checked as fully as a built-in one.
A Real One: Splitting a Shift at Clock Hours
Pay loadings change at 7am, 6pm and 9pm. A shift from 4pm to 10pm has to be split at those hours before
each piece can be priced. That needs Reckoning::Interval, so it is a step:
# steps/split_hours.rb
Reckoning::Blueprint.register_step(
:split_hours,
purpose: 'Splits the hours from one time of day to another at the given clock hours, one row per piece.',
example: <<~YAML,
- name: pieces
split_hours: {from: start, to: finish, at: [7, 18, 21]}
YAML
takes: %i[from to at],
problems: -> (spec, scope) { scope.undefined([spec[:from], spec[:to]]) }
) do |spec, _blueprint|
from = spec.fetch(:from).to_sym
to = spec.fetch(:to).to_sym
cuts = spec.fetch(:at)
day = Time.utc(2000, 1, 1)
clock = -> (hour) { day + (hour * 3600) }
lambda do |values|
shift = Reckoning::Interval.new(clock.call(values.fetch(from)), clock.call(values.fetch(to)))
pieces = shift.split_at(cuts.map(&clock)).map do |piece|
{from: BigDecimal((piece.started_at - day) / 3600, 4), to: BigDecimal((piece.ended_at - day) / 3600, 4), hours: BigDecimal(piece.minutes) / 60}
end
Reckoning::Outcome.new(value: pieces, detail: {from:, to:, at: cuts})
end
end
The rest stays data: a table of loadings and a run over the pieces.
tables:
rates:
- {until: 7, rate: 1.5}
- {until: 18, rate: 1}
- {until: 21, rate: 1.25}
- {rate: 1.5}
calculations:
shift:
inputs:
start: {type: number}
finish: {type: number}
hourly: {type: number}
steps:
- name: pieces
split_hours: {from: start, to: finish, at: [7, 18, 21]}
- name: paid
run: {calculation: piece, over: pieces}
- name: pay
sum: {output: amount, of: paid}
outputs: [paid, pay]
piece:
inputs:
from: {type: number}
hours: {type: number}
hourly: {type: number}
steps:
- name: loading
lookup: {table: rates, where: {}, value: rate, band: {by: from, below: until}}
- name: amount
formula: hours * hourly * loading
outputs: [from, hours, loading, amount]
$ reckoning --steps steps test shift.yml shift_cases.yml
Loaded custom steps from steps. They are Ruby code; only load steps you trust.
✓ 2 cases passed
$ echo '{"start": 16, "finish": 22, "hourly": 30}' | reckoning --steps steps run shift.yml
Loaded custom steps from steps. They are Ruby code; only load steps you trust.
paid
from hours loading amount
16 2 1 60
18 3 1.25 112.5
21 1 1.5 45
pay 217.5
The problems hook catches a misspelt name, as it would for a built-in step:
✗ 1 problem
• shift: step pieces uses begin, which is not an input or an earlier step
Loading Steps
Custom steps load only when a command is given --steps DIR, which loads every .rb file in it first.
Without it, the step type does not exist:
$ reckoning check shift.yml
✗ 1 problem
• shift: step pieces needs one of formula, rules, lookup, run, repeat, sum, group, compare
A blueprint can never load code by itself, so a blueprint from anyone is only data. Steps are Ruby, so never load steps you have not read. The CLI says so on standard error every time it loads them.
From Ruby, require the step file before loading the blueprint. A hosted service never loads custom
steps: steps there come only from packs installed with the service.
Where Steps Go Next
A custom step that several blueprints need can be promoted: moved into a gem, registered when the gem loads, and given its own specs. A pack is the usual home (see Packs).
Next Steps
- Calculations in Ruby -
Outcome,Intervaland the step helpers. - Packs - Ship steps and blueprints in a gem.
- CLI Commands and Exit Codes -
new-stepand--steps.