reason_me_this 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4df9a831994e4b534a9aaeb9fcf9f014414723658d37bb682069bc2fddae6511
4
+ data.tar.gz: 9ce6fa1cafeff01a49479e5019fe1985d844985dddf526010ef42ab019d70c28
5
+ SHA512:
6
+ metadata.gz: 7ddb5ae04b29565f4a4931593c804f9b61de5dd5ef49bbc2b57ac1c0ab731fa88ff52abbb996f3213d194a01a3b336d90298df16e20fb68bb02c255580af5e16
7
+ data.tar.gz: 221f7838a5d8c03fe7385ab3cc67b396e79ce35ed8050dba0440aebd462c91a9023d22db9dd3d33d1ca3f69fb605e16cf83f800e3cf9b62f896ee77421ab2464
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rhys Murray
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # reason_me_this
2
+
3
+ The Ruby client for [Reason Me This](https://reason-me-this.com). It runs, explains, pushes and publishes definitions over HTTP
4
+ and keeps every number exact on the way out and on the way back.
5
+
6
+ It only speaks HTTP: it needs nothing beyond Ruby's standard library and does not load the engine.
7
+
8
+ ## Install
9
+
10
+ ```ruby
11
+ gem "reason_me_this"
12
+ ```
13
+
14
+ ## The rmt CLI
15
+
16
+ The gem installs `rmt`, which builds, proves, publishes and runs blueprints on the service. It answers in
17
+ JSON when its output is not a terminal, so an agent can loop on it, and in text at a terminal.
18
+
19
+ ```sh
20
+ export RMT_API_KEY=... # from https://reason-me-this.com/app/api_keys
21
+ rmt guide # how to build a blueprint
22
+ rmt check bill.yml # exit 2 while it has problems
23
+ rmt test bill.yml cases.yml # exit 3 while a case fails
24
+ rmt push bill bill.yml cases.yml && rmt publish bill
25
+ echo '{"plan": "flat_rate"}' | rmt run bill --trace
26
+ rmt run electricity_bill --facts facts.json # a shipped blueprint, by name
27
+ ```
28
+
29
+ `check`, `test` and `run` on a file send its text to the service and save nothing. Exit codes: 0 success,
30
+ 1 a run failed, 2 problems, 3 a case failed, 64 usage, 69 the service could not be reached, 77 the key was
31
+ refused. `rmt help` lists every command. `RMT_API_URL` points it at another service.
32
+
33
+ ## Configure
34
+
35
+ ```ruby
36
+ require "reason_me_this"
37
+
38
+ ReasonMeThis.configure do |config|
39
+ config.url = ENV["RMT_API_URL"] # https://reason-me-this.com/api
40
+ config.api_key = ENV["RMT_API_KEY"]
41
+ end
42
+ ```
43
+
44
+ In Rails this belongs in `config/initializers/reason_me_this.rb`.
45
+
46
+ ## Run
47
+
48
+ ```ruby
49
+ answer = ReasonMeThis.run("sales_commission@3", facts, calculation: :year)
50
+
51
+ answer.total_paid # => BigDecimal("55830")
52
+ answer[:by_quarter] # => [{start: Date(2026-01-01), payout: BigDecimal("5000"), ...}, ...]
53
+ answer.to_h
54
+ ```
55
+
56
+ | Option | Means |
57
+ | --- | --- |
58
+ | `calculation:` | which calculation in the definition to run |
59
+ | `version:` | a published version number, `"latest"` (the default) or `"draft"` |
60
+ | `trace:` | `true` answers as `explain` does |
61
+
62
+ `"sales_commission@3"` pins the name to published version 3. A `version:` given as well wins over the pin.
63
+
64
+ ### What comes back
65
+
66
+ The service sends numbers as strings so they stay exact. An answer reads them back:
67
+
68
+ | The service sends | You get |
69
+ | --- | --- |
70
+ | `"55830"`, `"0.8"`, `"-12.5"` | `BigDecimal` |
71
+ | `"2026-07-01"` | `Date` |
72
+ | any other string, number, `true`, `false`, `null` | as sent |
73
+ | an object | a hash with symbol keys, its values read the same way |
74
+
75
+ A text output that happens to look like a number (an id of `"1042"`) is read as a number too.
76
+
77
+ ### What goes out
78
+
79
+ Facts go out exactly as you hold them: a `BigDecimal` is written as a JSON number in full, never rounded
80
+ through a `Float` or quoted as a string, and a `Date` or `Time` as an ISO 8601 string. Use `BigDecimal` for
81
+ money and rates; a `Float` goes out as Ruby writes it.
82
+
83
+ ## Explain
84
+
85
+ ```ruby
86
+ explained = ReasonMeThis.explain("sales_commission", facts, calculation: :year)
87
+
88
+ explained.outputs # an answer, as run gives
89
+ explained.steps # each step with its value, in the order they ran
90
+ ```
91
+
92
+ ## Manage definitions
93
+
94
+ ```ruby
95
+ ReasonMeThis.push("bill", yaml: File.read("bill.yml"), cases: File.read("cases.yml"))
96
+ # => {problems: [], passed: 4, failed: 0, cases: [...]}
97
+
98
+ ReasonMeThis.publish("bill") # => 4, the new version
99
+ ReasonMeThis.definitions # => [{name: "bill", published_version: 4, updated_at: "..."}]
100
+ ReasonMeThis.definition("bill") # => {name: "bill", yaml: "...", cases: "...", published_version: 4}
101
+ ```
102
+
103
+ ## Errors
104
+
105
+ Every error is a `ReasonMeThis::Error`, with a message in plain words.
106
+
107
+ | Error | When |
108
+ | --- | --- |
109
+ | `Unauthorized` | the service refused the API key (401) |
110
+ | `NotFound` | no such definition or version (404) |
111
+ | `InvalidFacts` | the facts have problems (422); `#problems` lists them |
112
+ | `StepFailed` | a step failed while running (422); the message says which |
113
+ | `Timeout` | the run took too long on the service (503) |
114
+ | `Unreachable` | the service could not be reached: refused, DNS, or timed out |
115
+ | `Rejected` | `push` or `publish` was refused: problems in the definition or failing cases; `#report` has them |
116
+
117
+ ```ruby
118
+ begin
119
+ ReasonMeThis.run("bill", facts)
120
+ rescue ReasonMeThis::InvalidFacts => e
121
+ e.problems # => ["hours is missing"]
122
+ end
123
+ ```
124
+
125
+ ## In a model
126
+
127
+ `ReasonMeThis::Model` works in any class, ActiveRecord or not.
128
+
129
+ ```ruby
130
+ class Rep < ApplicationRecord
131
+ include ReasonMeThis::Model
132
+
133
+ reasons :statement, with: "sales_commission@3", calculation: :year do |year|
134
+ {deals: deals.closed_in(year).map(&:to_fact), quarters: quarter_targets.in(year).map(&:to_fact)}
135
+ end
136
+ end
137
+
138
+ rep.statement(2026) # => an answer, remembered on this rep for these arguments
139
+ rep.statement(2026).total_paid
140
+ rep.statement_explained(2026) # the same run, explained
141
+ ```
142
+
143
+ The block runs on the instance, takes the method's arguments, and returns the facts.
144
+
145
+ ## More than one service
146
+
147
+ Build a client for each service and use it as you would the module:
148
+
149
+ ```ruby
150
+ client = ReasonMeThis::Client.new(url: tenant.rmt_url, api_key: tenant.rmt_key)
151
+ client.run("sales_commission", facts, calculation: :year)
152
+ ```
153
+
154
+ A model runs on `reason_me_this_client`, which is the configured client unless you say otherwise:
155
+
156
+ ```ruby
157
+ class Rep < ApplicationRecord
158
+ include ReasonMeThis::Model
159
+
160
+ def reason_me_this_client = tenant.reason_me_this_client
161
+ end
162
+ ```
163
+
164
+ ## In tests
165
+
166
+ A client talks through a connection: anything with `request(method, path, body)` that answers
167
+ `[status, parsed_json]`. Give it a fake to run offline:
168
+
169
+ ```ruby
170
+ class FakeConnection
171
+ def initialize(body) = @body = body
172
+ def request(_method, _path, _body = nil) = [200, @body]
173
+ end
174
+
175
+ ReasonMeThis.configure { |config| config.connection = FakeConnection.new("total_paid" => "55830") }
176
+ ```
177
+
178
+ ## Against a local service
179
+
180
+ Run the service on port 3000, make an API key there, then:
181
+
182
+ ```sh
183
+ export RMT_API_URL=http://localhost:3000/api
184
+ export RMT_API_KEY=your-key
185
+ bundle exec ruby -Ilib -r reason_me_this -e 'ReasonMeThis.configure { |c| c.url = ENV["RMT_API_URL"]; c.api_key = ENV["RMT_API_KEY"] }; p ReasonMeThis.definitions'
186
+ ```
187
+
188
+ ## Example
189
+
190
+ `examples/commissions` is a small Rails app that shows a sales rep's commission statement by quarter, worked
191
+ out by the service from the rep's deals. See its README.
192
+
193
+ ## Development
194
+
195
+ ```sh
196
+ bundle install
197
+ bundle exec rspec
198
+ bundle exec rubocop
199
+ ```
data/exe/rmt ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require 'reason_me_this/cli'
3
+
4
+ exit ReasonMeThis::CLI.call(ARGV)
@@ -0,0 +1,17 @@
1
+ module ReasonMeThis
2
+ class Answer
3
+ def initialize(outputs)
4
+ @outputs = Values.read(outputs)
5
+ end
6
+
7
+ def [](name) = @outputs[name.to_sym]
8
+
9
+ def to_h = @outputs
10
+
11
+ def method_missing(name, *args)
12
+ args.empty? && @outputs.key?(name) ? @outputs[name] : super
13
+ end
14
+
15
+ def respond_to_missing?(name, include_private = false) = @outputs.key?(name) || super
16
+ end
17
+ end
@@ -0,0 +1,169 @@
1
+ module ReasonMeThis
2
+ class CLI
3
+ module Output
4
+ def self.for(out, format)
5
+ terminal = out.respond_to?(:tty?) && out.tty?
6
+ return Json.new(out) if (format || (terminal ? :text : :json)) == :json
7
+
8
+ Text.new(out, colour: terminal && !ENV.key?('NO_COLOR'))
9
+ end
10
+
11
+ class Json
12
+ def initialize(out)
13
+ @out = out
14
+ end
15
+
16
+ def show(_kind, payload, **)
17
+ @out.puts(JSON.pretty_generate(payload))
18
+ end
19
+ end
20
+
21
+ class Text
22
+ def initialize(out, colour:)
23
+ @out = out
24
+ @colour = colour
25
+ end
26
+
27
+ def show(kind, payload, **context)
28
+ @out.puts(send(kind, symbolized(payload), **context))
29
+ end
30
+
31
+ private
32
+
33
+ def symbolized(value)
34
+ case value
35
+ when Hash then value.to_h { |key, item| [key.to_sym, symbolized(item)] }
36
+ when Array then value.map { |item| symbolized(item) }
37
+ else value
38
+ end
39
+ end
40
+
41
+ def outputs(values) = fields(values, 0).join("\n")
42
+
43
+ def trace(explained)
44
+ [outputs(explained[:outputs]), '', bold('How it was worked out'), *steps(explained[:steps], 1)].join("\n")
45
+ end
46
+
47
+ def problems(answer)
48
+ listed = answer[:problems]
49
+ return pass('No problems') if listed.empty?
50
+
51
+ [fail("#{listed.size} #{listed.size == 1 ? 'problem' : 'problems'}"), *listed.map { |problem| " • #{problem}" }].join("\n")
52
+ end
53
+
54
+ def error(answer) = fail(answer[:error])
55
+
56
+ def refusal(answer) = answer.key?(:problems) ? problems(answer) : error(answer)
57
+
58
+ def report(answer)
59
+ answer.key?(:error) || answer[:problems]&.any? ? refusal(answer) : cases(answer)
60
+ end
61
+
62
+ def cases(answer)
63
+ passed, failed = answer.values_at(:passed, :failed).map(&:to_i)
64
+ failing = answer[:cases].reject { |result| result[:passed] }
65
+ return pass("#{passed} #{passed == 1 ? 'case' : 'cases'} passed") if failing.empty?
66
+
67
+ [fail("#{failed} of #{passed + failed} cases failed"), *failing.flat_map { |result| failed_case(result) }].join("\n")
68
+ end
69
+
70
+ def failed_case(result)
71
+ detail = if result[:error]
72
+ [" #{result[:error]}"]
73
+ else
74
+ result[:differences].map { |d| " #{d[:output]}: expected #{d[:expected]}, got #{d[:actual]}" }
75
+ end
76
+ ['', " #{fail(result[:name])}", *detail]
77
+ end
78
+
79
+ def published(answer, name:) = pass("Published #{name} v#{answer[:version]}")
80
+
81
+ def definitions(answer)
82
+ rows = answer[:definitions].map do |definition|
83
+ [definition[:name], definition[:published_version] ? "v#{definition[:published_version]}" : 'draft only', definition[:updated_at].to_s[0, 10]]
84
+ end
85
+ table([%w[NAME PUBLISHED UPDATED], *rows]).join("\n")
86
+ end
87
+
88
+ def created(answer) = answer[:created].map { |path| "Wrote #{path}" }.join("\n")
89
+
90
+ def describe(answer)
91
+ types = answer[:steps].map { |name, type| " #{name.to_s.ljust(10)} #{type[:purpose]}" }
92
+ functions = answer[:formulas][:usage].map { |_name, usage| " #{usage}" }
93
+ [bold('Step types'), *types, '', bold('Formula functions'), *functions, '',
94
+ 'Run rmt describe --json for an example of each, with what it gives, or rmt guide for the whole guide.'].join("\n")
95
+ end
96
+
97
+ def fields(values, depth)
98
+ width = values.keys.map { |name| name.to_s.size }.max
99
+ values.flat_map do |name, value|
100
+ label = "#{' ' * depth}#{name}"
101
+ case value
102
+ when Hash then [label, *fields(value, depth + 1)]
103
+ when Array then list(label, value, width, depth)
104
+ else "#{label.ljust(width + (2 * depth))} #{value}"
105
+ end
106
+ end
107
+ end
108
+
109
+ def list(label, value, width, depth)
110
+ return ["#{label.ljust(width + (2 * depth))} #{value.join(', ')}"] unless rows?(value)
111
+
112
+ [label, *table(columns(value)).map { |line| "#{' ' * (depth + 1)}#{line}" }]
113
+ end
114
+
115
+ def rows?(list) = list.any? && list.all?(Hash)
116
+
117
+ def columns(rows)
118
+ names = rows.flat_map(&:keys).uniq
119
+ [names.map(&:to_s), *rows.map { |row| names.map { |name| cell(row[name]) } }]
120
+ end
121
+
122
+ def cell(value) = value.is_a?(Array) || value.is_a?(Hash) ? value.size.to_s : value.to_s
123
+
124
+ def table(lines)
125
+ widths = lines.transpose.map { |column| column.map { |cell| cell.to_s.size }.max }
126
+ lines.map { |line| line.each_with_index.map { |cell, index| cell.to_s.ljust(widths[index]) }.join(' ').rstrip }
127
+ end
128
+
129
+ def steps(list, depth)
130
+ lines = list.map { |step| ["#{' ' * depth}#{step[:step]}", summary(step[:value], step[:of]), detail(step)] }
131
+ names = lines.map { |name, *| name.size }.max
132
+ values = lines.select(&:last).map { |_, value, _| value.size }.max
133
+ list.zip(lines).flat_map { |step, line| [step_line(*line, names, values), *step_rows(step, depth)] }
134
+ end
135
+
136
+ def step_line(name, value, detail, names, values)
137
+ detail ? "#{name.ljust(names)} #{value.ljust(values)} #{detail}" : "#{name.ljust(names)} #{value}"
138
+ end
139
+
140
+ def step_rows(step, depth)
141
+ Array(step[:rows]).each_with_index.flat_map { |row, index| ["#{' ' * (depth + 1)}row #{index + 1}", *steps(row, depth + 2)] }
142
+ end
143
+
144
+ def summary(value, over)
145
+ return "#{value.size} #{value.size == 1 ? 'row' : 'rows'}#{" over #{over}" if over}" if value.is_a?(Array)
146
+
147
+ value.is_a?(Hash) ? value.map { |name, item| "#{name} #{item}" }.join(', ') : value.to_s
148
+ end
149
+
150
+ def detail(step)
151
+ return dim(step[:formula]) if step[:formula]
152
+ return dim("row #{step[:row].map { |name, value| "#{name} #{value}" }.join(', ')}") if step[:row]
153
+
154
+ dim("from #{step[:of]}") if step[:of] && !step[:value].is_a?(Array)
155
+ end
156
+
157
+ def pass(message) = paint("✓ #{message}", 32)
158
+
159
+ def fail(message) = paint("✗ #{message}", 31)
160
+
161
+ def bold(text) = paint(text, 1)
162
+
163
+ def dim(text) = paint(text, 2)
164
+
165
+ def paint(text, code) = @colour ? "\e[#{code}m#{text}\e[0m" : text
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,243 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+ require 'optparse'
4
+ require 'reason_me_this'
5
+ require 'reason_me_this/cli/output'
6
+
7
+ module ReasonMeThis
8
+ class CLI
9
+ HOSTED = 'https://reason-me-this.com/api'.freeze
10
+ KEYS = 'https://reason-me-this.com/app/api_keys'.freeze
11
+
12
+ USAGE = <<~TEXT.freeze
13
+ Usage: rmt COMMAND [arguments] [options]
14
+
15
+ Build and prove a blueprint:
16
+ guide print the guide to building a blueprint
17
+ describe list step types with examples and the functions formulas can call
18
+ check BLUEPRINT list problems with a blueprint file
19
+ test BLUEPRINT CASES run each case's facts and compare the expected outputs
20
+ run BLUEPRINT [--facts FILE] [--calculation NAME] [--trace]
21
+ run a blueprint file; facts come from FILE or standard input
22
+
23
+ Publish and run definitions:
24
+ push NAME BLUEPRINT [CASES] save a definition's draft, checked and tested
25
+ publish NAME publish the draft as the next version
26
+ run NAME [--version N|draft] [...] run a definition, or a shipped blueprint, by name
27
+ list list the definitions and their published versions
28
+ pull NAME [--dir DIR] write DIR/NAME/blueprint.yml and cases.yml
29
+
30
+ A BLUEPRINT ending in .yml or .yaml or containing a / is a file; anything else is a name.
31
+ Every command but guide and describe needs RMT_API_KEY (create one at #{KEYS}).
32
+ RMT_API_URL defaults to #{HOSTED}.
33
+
34
+ Options for any command:
35
+ --json answer in JSON (the default when output is not a terminal)
36
+ --text answer in text (the default at a terminal)
37
+
38
+ Exit codes: 0 success, 1 a run failed, 2 the blueprint has problems, 3 a case failed, 64 usage,
39
+ 69 the service could not be reached, 77 the service refused the key
40
+ TEXT
41
+
42
+ WORKFLOW = <<~MARKDOWN.freeze
43
+ # Building a blueprint
44
+
45
+ A blueprint is a YAML file describing calculations as data. You can turn a source document (a rulebook
46
+ chapter, a price list, a policy) into a blueprint, prove it against worked examples, and run it.
47
+
48
+ ## Workflow
49
+
50
+ 1. Run `rmt describe` to see every step type with an example, and the functions formulas can call.
51
+ 2. Read the source and list the outputs it asks for and the facts they depend on.
52
+ 3. Write worked examples first, in a cases file, from figures in the source where it gives them. Work out
53
+ others by hand and say so in the case name.
54
+ 4. Write the blueprint.
55
+ 5. Run `rmt check BLUEPRINT` until it prints no problems.
56
+ 6. Run `rmt test BLUEPRINT CASES` until every case passes. Change the blueprint, not the cases,
57
+ unless a case was worked out wrongly.
58
+ 7. Run `rmt run BLUEPRINT --facts FACTS.json` for new facts. Add `--trace` to see how every step
59
+ was reached when a value surprises you.
60
+ 8. `rmt push NAME BLUEPRINT CASES` saves it as the draft of the definition NAME, and `rmt publish NAME`
61
+ makes the draft the next version, which applications call. A draft with problems or a failing case
62
+ is refused.
63
+
64
+ Every command prints JSON to standard output when it is not a terminal, which is how an agent runs it,
65
+ and text at a terminal; `--json` or `--text` chooses either way. Exit codes: 0 success, 1 a run failed,
66
+ 2 the blueprint has problems, 3 a case failed, 64 usage, 69 the service could not be reached, 77 the
67
+ service refused the key. A blueprint imports others by name (`imports: [rates]`, `rates@2`,
68
+ `rates@draft`), never by path.
69
+
70
+ MARKDOWN
71
+
72
+ def self.call(argv, env: ENV, connection: nil, **io)
73
+ key = env['RMT_API_KEY'].to_s
74
+ connection ||= Connection.new(env['RMT_API_URL'].to_s.empty? ? HOSTED : env['RMT_API_URL'], key)
75
+ new(out: io.fetch(:out, $stdout), err: io.fetch(:err, $stderr), input: io.fetch(:input, $stdin), connection:, keyed: !key.empty?).call(argv.dup)
76
+ end
77
+
78
+ def initialize(out:, err:, input:, connection:, keyed:)
79
+ @out = out
80
+ @err = err
81
+ @input = input
82
+ @connection = connection
83
+ @keyed = keyed
84
+ end
85
+
86
+ def call(argv)
87
+ @output = Output.for(@out, format(argv))
88
+ command = argv.shift
89
+ return help(argv.first) if command == 'help'
90
+ return help(command) if argv.delete('--help')
91
+
92
+ dispatch(command, argv)
93
+ rescue OptionParser::ParseError, Errno::ENOENT => e
94
+ @err.puts(e.message)
95
+ usage
96
+ end
97
+
98
+ private
99
+
100
+ def dispatch(command, argv)
101
+ case command
102
+ when 'guide', 'describe', 'list' then send(command)
103
+ when 'check', 'test', 'run', 'push', 'publish', 'pull' then argv.first ? send(command, argv) : usage
104
+ else usage
105
+ end
106
+ end
107
+
108
+ def format(argv)
109
+ chosen = %w[--json --text].select { |flag| argv.delete(flag) }
110
+ chosen.last&.delete_prefix('--')&.to_sym
111
+ end
112
+
113
+ def guide
114
+ on_service(:get, '/describe', keyless: true) do |_status, answer|
115
+ @out.print(WORKFLOW, answer['guide'])
116
+ 0
117
+ end
118
+ end
119
+
120
+ def describe
121
+ on_service(:get, '/describe', keyless: true) { |_status, answer| answered(:describe, answer.except('guide'), 0) }
122
+ end
123
+
124
+ def check(argv)
125
+ on_service(:post, '/check', {yaml: File.read(argv.first)}) do |_status, answer|
126
+ answered(:problems, answer.slice('problems'), answer['problems'].any? ? 2 : 0)
127
+ end
128
+ end
129
+
130
+ def test(argv)
131
+ return usage unless argv[1]
132
+
133
+ on_service(:post, '/check', {yaml: File.read(argv[0]), cases: File.read(argv[1])}) { |_status, answer| answered(:report, answer, proven(answer)) }
134
+ end
135
+
136
+ def run(argv)
137
+ options = {}
138
+ OptionParser.new do |parser|
139
+ parser.on('--facts FILE') { |file| options[:facts] = file }
140
+ parser.on('--calculation NAME') { |name| options[:calculation] = name }
141
+ parser.on('--version VERSION') { |version| options[:version] = version }
142
+ parser.on('--trace') { options[:trace] = true }
143
+ end.parse!(argv)
144
+ facts = read_facts(options) or return 1
145
+ body = {facts:, **options.slice(:calculation, :version, :trace)}
146
+ return ran(:post, "/definitions/#{argv.first}/run", body, options) unless path?(argv.first)
147
+
148
+ ran(:post, '/try', {yaml: File.read(argv.first), **body.except(:version)}, options)
149
+ end
150
+
151
+ def ran(method, path, body, options)
152
+ on_service(method, path, body) do |status, answer|
153
+ if status == 200
154
+ answered(options[:trace] ? :trace : :outputs, answer, 0)
155
+ else
156
+ answered(:refusal, answer, 1)
157
+ end
158
+ end
159
+ end
160
+
161
+ def read_facts(options)
162
+ JSON.parse(options[:facts] ? File.read(options[:facts]) : @input.read, decimal_class: BigDecimal)
163
+ rescue JSON::ParserError => e
164
+ answered(:problems, {'problems' => ["The facts are not valid JSON: #{e.message}"]}, 1)
165
+ nil
166
+ end
167
+
168
+ def push(argv)
169
+ name, blueprint, cases = argv
170
+ return usage unless blueprint
171
+
172
+ body = {yaml: File.read(blueprint), cases: cases ? File.read(cases) : ''}
173
+ on_service(:put, "/definitions/#{name}", body) { |_status, answer| answered(:report, answer, proven(answer)) }
174
+ end
175
+
176
+ def publish(argv)
177
+ on_service(:post, "/definitions/#{argv.first}/publish") do |status, answer|
178
+ status == 201 ? answered(:published, answer, 0, name: argv.first) : answered(:report, answer, proven(answer))
179
+ end
180
+ end
181
+
182
+ def list
183
+ on_service(:get, '/definitions') { |status, answer| status == 200 ? answered(:definitions, answer, 0) : answered(:refusal, answer, 1) }
184
+ end
185
+
186
+ def pull(argv)
187
+ options = {dir: '.'}
188
+ OptionParser.new { |parser| parser.on('--dir DIR') { |dir| options[:dir] = dir } }.parse!(argv)
189
+ name = argv.first
190
+ on_service(:get, "/definitions/#{name}") do |status, answer|
191
+ next answered(:refusal, answer, 1) unless status == 200
192
+
193
+ write_new({'blueprint.yml' => answer['yaml'], 'cases.yml' => answer['cases']}.transform_keys { |file| File.join(options[:dir], name, file) })
194
+ end
195
+ end
196
+
197
+ def write_new(files)
198
+ existing = files.keys.select { |path| File.exist?(path) }
199
+ return answered(:problems, {'problems' => existing.map { |path| "#{path} already exists" }}, 1) if existing.any?
200
+
201
+ files.each do |path, content|
202
+ FileUtils.mkdir_p(File.dirname(path))
203
+ File.write(path, content)
204
+ end
205
+ answered(:created, {'created' => files.keys}, 0)
206
+ end
207
+
208
+ def on_service(method, path, body = nil, keyless: false)
209
+ return answered(:problems, {'problems' => ["#{path} needs an API key: create one at #{KEYS} and set RMT_API_KEY"]}, 64) unless keyless || @keyed
210
+
211
+ status, answer = @connection.request(method, path, body)
212
+ return answered(:refusal, answer, 77) if [401, 403].include?(status)
213
+
214
+ yield(status, answer)
215
+ rescue Unreachable => e
216
+ answered(:refusal, {'error' => e.message}, 69)
217
+ end
218
+
219
+ def proven(answer)
220
+ return 1 unless answer.key?('problems')
221
+ return 2 if answer['problems'].any?
222
+
223
+ answer['failed'].to_i.positive? ? 3 : 0
224
+ end
225
+
226
+ def path?(argument) = argument.end_with?('.yml', '.yaml') || argument.include?('/')
227
+
228
+ def answered(kind, answer, code, context = {})
229
+ @output.show(kind, answer, **context)
230
+ code
231
+ end
232
+
233
+ def help(_command)
234
+ @out.puts(USAGE)
235
+ 0
236
+ end
237
+
238
+ def usage
239
+ @err.puts(USAGE)
240
+ 64
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,3 @@
1
+ module ReasonMeThis
2
+ Configuration = Struct.new(:url, :api_key, :connection)
3
+ end
@@ -0,0 +1,41 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'openssl'
4
+
5
+ module ReasonMeThis
6
+ class Connection
7
+ def initialize(url, api_key)
8
+ @url = url.to_s.chomp('/')
9
+ @api_key = api_key
10
+ end
11
+
12
+ def request(method, path, body = nil)
13
+ raise Error.new('no URL is configured for Reason Me This') if @url.empty?
14
+
15
+ uri = URI("#{@url}#{path}")
16
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 10, read_timeout: 60) do |http|
17
+ http.request(build(method, uri, body))
18
+ end
19
+ [response.code.to_i, parsed(response)]
20
+ rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
21
+ raise Unreachable.new("could not reach the service at #{@url}: #{e.message}")
22
+ end
23
+
24
+ private
25
+
26
+ def build(method, uri, body)
27
+ Net::HTTP.const_get(method.to_s.capitalize).new(uri).tap do |request|
28
+ request['Authorization'] = "Bearer #{@api_key}"
29
+ request['Content-Type'] = 'application/json'
30
+ request['Accept'] = 'application/json'
31
+ request.body = JSON.generate(Values.write(body)) if body
32
+ end
33
+ end
34
+
35
+ def parsed(response)
36
+ JSON.parse(response.body.to_s.empty? ? '{}' : response.body)
37
+ rescue JSON::ParserError
38
+ {'error' => "the service answered HTTP #{response.code} with something other than JSON"}
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ module ReasonMeThis
2
+ class Error < StandardError; end
3
+ class Unauthorized < Error; end
4
+ class NotFound < Error; end
5
+ class StepFailed < Error; end
6
+ class Timeout < Error; end
7
+ class Unreachable < Error; end
8
+
9
+ class Refused < Error
10
+ attr_reader :report
11
+
12
+ def initialize(report)
13
+ @report = report
14
+ super(message)
15
+ end
16
+
17
+ def problems = report.fetch(:problems, [])
18
+ end
19
+
20
+ class InvalidFacts < Refused
21
+ def message = "the facts have problems: #{problems.join('; ')}"
22
+ end
23
+
24
+ class Rejected < Refused
25
+ def message
26
+ failed = "#{report[:failed]} cases failed" if report[:failed].to_i.positive?
27
+ "the definition was not accepted: #{[*problems, failed].compact.join('; ')}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ module ReasonMeThis
2
+ Explanation = Data.define(:outputs, :steps)
3
+ end
@@ -0,0 +1,21 @@
1
+ module ReasonMeThis
2
+ module Model
3
+ def self.included(base) = base.extend(ClassMethods)
4
+
5
+ module ClassMethods
6
+ def reasons(name, with:, calculation: nil, &facts)
7
+ {name => :run, "#{name}_explained": :explain}.each do |method, call|
8
+ define_method(method) do |*args|
9
+ reasonings[[method, args]] ||= reason_me_this_client.public_send(call, with, instance_exec(*args, &facts), calculation:)
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+ def reason_me_this_client = ReasonMeThis.default
16
+
17
+ private
18
+
19
+ def reasonings = @reasonings ||= {}
20
+ end
21
+ end
@@ -0,0 +1,48 @@
1
+ require 'bigdecimal'
2
+ require 'date'
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module ReasonMeThis
7
+ module Values
8
+ NUMBER = /\A-?\d+(\.\d+)?\z/
9
+ DATE = /\A\d{4}-\d{2}-\d{2}\z/
10
+
11
+ module_function
12
+
13
+ def read(value)
14
+ deep(value) do |leaf|
15
+ case leaf
16
+ when NUMBER then BigDecimal(leaf)
17
+ when DATE then date(leaf)
18
+ else leaf
19
+ end
20
+ end
21
+ end
22
+
23
+ # A decimal goes out as a JSON number written in full, so the service reads the value that was typed.
24
+ def write(value)
25
+ deep(value) do |leaf|
26
+ case leaf
27
+ when BigDecimal then JSON::Fragment.new(leaf.to_s('F'))
28
+ when Date, Time then leaf.iso8601
29
+ else leaf
30
+ end
31
+ end
32
+ end
33
+
34
+ def date(text)
35
+ Date.iso8601(text)
36
+ rescue Date::Error
37
+ text
38
+ end
39
+
40
+ def deep(value, &)
41
+ case value
42
+ when Hash then value.to_h { |key, item| [key.is_a?(String) ? key.to_sym : key, deep(item, &)] }
43
+ when Array then value.map { |item| deep(item, &) }
44
+ else yield value
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ module ReasonMeThis
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,70 @@
1
+ require 'reason_me_this/version'
2
+ require 'reason_me_this/values'
3
+ require 'reason_me_this/answer'
4
+ require 'reason_me_this/errors'
5
+ require 'reason_me_this/explanation'
6
+ require 'reason_me_this/connection'
7
+ require 'reason_me_this/configuration'
8
+ require 'reason_me_this/model'
9
+ require 'forwardable'
10
+
11
+ module ReasonMeThis
12
+ class << self
13
+ extend Forwardable
14
+
15
+ def_delegators :default, :run, :explain, :push, :publish, :definitions, :definition
16
+
17
+ def configure
18
+ yield configuration
19
+ @default = nil
20
+ end
21
+
22
+ def configuration = @configuration ||= Configuration.new
23
+
24
+ def default = @default ||= Client.new(**configuration.to_h.compact)
25
+ end
26
+
27
+ class Client
28
+ def initialize(url: nil, api_key: nil, connection: Connection.new(url, api_key))
29
+ @connection = connection
30
+ end
31
+
32
+ def run(name, facts, calculation: nil, version: nil, trace: false)
33
+ name, pinned = name.to_s.split('@', 2)
34
+ version ||= pinned&.match?(/\A\d+\z/) ? Integer(pinned) : pinned
35
+ answer = request(:post, at(name, :run), {facts:, calculation:, version:, trace: (true if trace)}.compact)
36
+ trace ? Explanation.new(outputs: Answer.new(answer[:outputs]), steps: answer[:steps]) : Answer.new(answer)
37
+ end
38
+
39
+ def explain(name, facts, calculation: nil, version: nil) = run(name, facts, calculation:, version:, trace: true)
40
+
41
+ def push(name, yaml:, cases: '') = request(:put, at(name), {yaml:, cases:}, invalid: Rejected)
42
+
43
+ def publish(name) = request(:post, at(name, :publish), invalid: Rejected)[:version]
44
+
45
+ def definitions = request(:get, '/definitions')[:definitions]
46
+
47
+ def definition(name) = request(:get, at(name))
48
+
49
+ private
50
+
51
+ def at(name, *action) = ['/definitions', name, *action].join('/')
52
+
53
+ def request(method, path, body = nil, invalid: InvalidFacts)
54
+ status, answer = @connection.request(method, path, body)
55
+ answer = Values.read(answer)
56
+ status.between?(200, 299) ? answer : raise(refusal(status, answer, invalid))
57
+ end
58
+
59
+ def refusal(status, answer, invalid)
60
+ case [status, answer]
61
+ in [401, _] then Unauthorized.new('the service refused the API key')
62
+ in [404, {error:}] then NotFound.new(error)
63
+ in [422, {problems:}] then invalid.new(answer)
64
+ in [422, {error:}] then StepFailed.new(error)
65
+ in [503, {error:}] then Timeout.new(error)
66
+ else Error.new(["the service answered HTTP #{status}", answer[:error]].compact.join(': '))
67
+ end
68
+ end
69
+ end
70
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: reason_me_this
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rhys Murray
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bigdecimal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.10'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.10'
40
+ description: 'The client for reason-me-this.com: runs, explains, pushes and publishes
41
+ blueprints over HTTP, keeping numbers exact.'
42
+ executables:
43
+ - rmt
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - LICENSE.txt
48
+ - README.md
49
+ - exe/rmt
50
+ - lib/reason_me_this.rb
51
+ - lib/reason_me_this/answer.rb
52
+ - lib/reason_me_this/cli.rb
53
+ - lib/reason_me_this/cli/output.rb
54
+ - lib/reason_me_this/configuration.rb
55
+ - lib/reason_me_this/connection.rb
56
+ - lib/reason_me_this/errors.rb
57
+ - lib/reason_me_this/explanation.rb
58
+ - lib/reason_me_this/model.rb
59
+ - lib/reason_me_this/values.rb
60
+ - lib/reason_me_this/version.rb
61
+ homepage: https://reason-me-this.com
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ documentation_uri: https://reason-me-this.com/docs/
66
+ source_code_uri: https://github.com/rhys117/reason-me-this
67
+ rubygems_mfa_required: 'true'
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.4'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.20
83
+ specification_version: 4
84
+ summary: Run, explain and publish Reason Me This blueprints from Ruby, and the rmt
85
+ CLI
86
+ test_files: []