bitfab 0.70.0 → 0.71.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1db22eed10f5c7fd7e24777507be907e01a1a65f7df1a10ff388bc347e17b5f7
4
- data.tar.gz: e30cd6c045153777120348af94e6884ee77fef98cb433e14c470886b6085842a
3
+ metadata.gz: 6a9158d8ba5528a0445a8a84471b6556f1c068b9e34d1b11d9a7759e2a339dae
4
+ data.tar.gz: 1c00b3b0e7de1d47c34598752047772fc604f045715dd14b28a3d9c191986e21
5
5
  SHA512:
6
- metadata.gz: 838efe0f9650542d604b48c330aa1dbd033f96091dd7a24ad050d1b8929854cee0af10342d2a3882aaef97c33ab4fb492b1e8a3f1237e74bfdc0ac0a51546064
7
- data.tar.gz: 6244270e97642c69cad5fc9c08bb26fa7492149d2d98f04ec6432c4c9f314ba577649f5af2d13913ba39704f0fca46c0d03d435845b647532f9068d97da323aa
6
+ metadata.gz: ef4585c3380e0ae2370fbb9f9e7a736bad89e7c04c7d2526e1f758a6583fcf82ae6ba84dd0720e99b7525dac11fa0331aa58e13606648f9ef288b1fae2b129de
7
+ data.tar.gz: ab972e7e48cb60881d62fe597513ae46f6e1649d7ee950d81fe79b7d0d5f523e43761f5381eca2222ec54fc75f940961d0e6d2b9e451fd39a293c9791c9e749a
data/lib/bitfab/baml.rb CHANGED
@@ -111,6 +111,15 @@ module Bitfab
111
111
  nil
112
112
  end
113
113
 
114
+ def reported_cost(call)
115
+ body = call&.http_response&.body&.json
116
+ usage = body["usage"] if body.is_a?(Hash)
117
+ cost = usage["cost"] if usage.is_a?(Hash)
118
+ (cost.is_a?(Numeric) && cost.finite?) ? cost : nil
119
+ rescue
120
+ nil
121
+ end
122
+
114
123
  def enrich_span(collector)
115
124
  log = collector.last
116
125
  calls = log&.calls || []
@@ -131,6 +140,8 @@ module Bitfab
131
140
  model: body["model"] || call&.http_request&.url&.match(%r{/models/([^/:]+)})&.[](1),
132
141
  inputTokens: usage&.input_tokens || call&.usage&.input_tokens,
133
142
  outputTokens: usage&.output_tokens || call&.usage&.output_tokens,
143
+ cachedInputTokens: usage&.cached_input_tokens || call&.usage&.cached_input_tokens,
144
+ costUsd: reported_cost(call),
134
145
  durationMs: log&.timing&.duration_ms
135
146
  }.compact
136
147
  Bitfab.current_span.add_context(metadata) unless metadata.empty?
data/lib/bitfab/client.rb CHANGED
@@ -147,16 +147,22 @@ module Bitfab
147
147
  # diff against the branch this one was cut from, such as the branch below
148
148
  # it in a stack, else the default branch; "default-branch" the diff
149
149
  # against where HEAD split from the default branch (origin/HEAD, else
150
- # origin/main, origin/master, main, master); "skip" no code change, such
151
- # as for a baseline run; "manual" the files in code_change_files. The
152
- # three git choices include uncommitted edits and untracked files, and
153
- # warn and attach nothing when they find no changed files.
150
+ # origin/main, origin/master, main, master); "ref" the diff against where
151
+ # HEAD split from the branch or commit in code_change_base; "skip" no code
152
+ # change, such as for a baseline run; "manual" the files in
153
+ # code_change_files. The four git choices include uncommitted edits and
154
+ # untracked files, and warn and attach nothing when they find no changed
155
+ # files.
154
156
  # @param code_change_description [String, nil] description of the code
155
157
  # change, stored on the experiment. Defaults to one generated from the
156
158
  # captured files. Not accepted with "skip".
157
159
  # @param code_change_files [Array<Hash>, nil] the edited files when
158
160
  # code_change is "manual", and only then, each as { path:, before:, after: }
159
161
  # (use "" for new/deleted files).
162
+ # @param code_change_base [String, nil] the branch or commit to diff
163
+ # against when code_change is "ref", and only then, such as "dev",
164
+ # "release/2.3", or a commit SHA. A branch that exists only on the remote
165
+ # is read as origin/<name>. A name git cannot resolve raises ArgumentError.
160
166
  # @param experiment_group_id [String, nil] optional UUID grouping multiple
161
167
  # replay runs into a single experiment batch. The run joins that group,
162
168
  # and an id no group has yet creates it
@@ -222,7 +228,7 @@ module Bitfab
222
228
  # @return [Hash] with :items, :experiment_id, :experiment_url, and the
223
229
  # deprecated aliases :test_run_id and :test_run_url carrying the same values
224
230
  def replay(receiver, method_name = nil, trace_function_key:, limit: nil, trace_ids: nil, concurrency: nil, max_concurrency: ReplayConcurrency::UNSET,
225
- name: nil, notes: nil, metadata: nil, code_change: nil, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, experiment_group_name: nil, dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, skip_assertion_judging: false, judge_assertions: nil, dry_run: false,
231
+ name: nil, notes: nil, metadata: nil, code_change: nil, code_change_description: nil, code_change_files: nil, code_change_base: nil, experiment_group_id: nil, experiment_group_name: nil, dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, skip_assertion_judging: false, judge_assertions: nil, dry_run: false,
226
232
  adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil,
227
233
  on_experiment_start: nil, experimental_selective_replay: nil)
228
234
  Replay.run(
@@ -245,6 +251,7 @@ module Bitfab
245
251
  code_change:,
246
252
  code_change_description:,
247
253
  code_change_files:,
254
+ code_change_base:,
248
255
  experiment_group_id:,
249
256
  experiment_group_name:,
250
257
  dataset_id:,
@@ -149,7 +149,10 @@ RENAMED_FLAGS = {
149
149
  SEED_FLAGS = ("--seed", "--cases", "--from-trace", "--run")
150
150
  PATH_FLAGS = ("--registry", "--params", "--code-change-file")
151
151
  CODE_CHANGE_FLAG = "--code-change"
152
- BRANCH_CODE_CHANGES = ("last-branch", "default-branch")
152
+ CODE_CHANGE_BASE_FLAG = "--code-change-base"
153
+ BRANCH_CODE_CHANGES = ("last-branch", "default-branch", "ref")
154
+ GIT_CODE_CHANGES = ("working-tree", *BRANCH_CODE_CHANGES)
155
+ DESCRIBED_REF = re.compile(r"[^\x00-\x1f\x7f]{1,200}")
153
156
  CHECKOUT_DEPTH = 2
154
157
  CODE_CHANGE_PATH_ENV = "BITFAB_CODE_CHANGE_PATH"
155
158
  CODE_CHANGE_MAX_FILES = 60
@@ -220,7 +223,9 @@ Options added by --cloud:
220
223
  checkout of the repository, with GitHub access that can
221
224
  read it and run Actions (a job token needs contents: read
222
225
  and actions: write, passed as GH_TOKEN), and a workflow on
223
- BRANCH that declares a request input.
226
+ BRANCH that declares a request input. With no snapshot
227
+ there is nothing to diff against, so it takes
228
+ --code-change skip or manual, as does a dispatched run.
224
229
 
225
230
  A workflow can also dispatch bitfab-replay.yml itself on any branch other than a
226
231
  bitfab-replay/ snapshot branch, passing the replay as JSON in the request input:
@@ -1029,8 +1034,30 @@ def default_branch_point(root):
1029
1034
  return {"sha": sha, "ref": default} if sha else None
1030
1035
 
1031
1036
 
1037
+ def chosen_base_point(root, chosen):
1038
+ if not chosen or chosen.startswith("-"):
1039
+ return None
1040
+ for ref in (chosen, f"origin/{chosen}"):
1041
+ if quiet_git(root, "rev-parse", "--verify", "-q", f"{ref}^{{commit}}"):
1042
+ sha = quiet_git(root, "merge-base", "HEAD", ref)
1043
+ return {"sha": sha, "ref": chosen} if sha else None
1044
+ raise ValueError(
1045
+ f"{CODE_CHANGE_BASE_FLAG} {chosen} names no branch or commit in this repository; pass a branch name or a commit SHA, and fetch it first if it only exists on the remote"
1046
+ )
1047
+
1048
+
1049
+ def refuse_git_code_change(replay, where):
1050
+ kind = flag_value(replay, CODE_CHANGE_FLAG)
1051
+ if kind in GIT_CODE_CHANGES:
1052
+ raise ValueError(
1053
+ f"{where} as it is on GitHub, with no snapshot of a checkout, so --code-change {kind} has nothing to compare against there; pass --code-change skip, or --code-change manual with --code-change-file, or start it with bitfab-replay --cloud and no --cloud-ref, which resolves {kind} from your checkout before upload"
1054
+ )
1055
+
1056
+
1032
1057
  def code_change_base(root, replay):
1033
1058
  kind = flag_value(replay, CODE_CHANGE_FLAG)
1059
+ if kind == "ref":
1060
+ return chosen_base_point(root, flag_value(replay, CODE_CHANGE_BASE_FLAG))
1034
1061
  if kind == "last-branch":
1035
1062
  return branch_point(root)
1036
1063
  if kind == "default-branch":
@@ -1839,6 +1866,7 @@ def submit_as_is(root, repo, workflow, parsed, path):
1839
1866
  branch = parsed["cloud"]["--cloud-ref"]
1840
1867
  request, _ = replay_request(root, parsed)
1841
1868
  check_arguments(parsed["replay"])
1869
+ refuse_git_code_change(parsed["replay"], f"--cloud-ref replays {branch}")
1842
1870
  sha = branch_head(repo, branch)
1843
1871
  if "--cloud-preview" in parsed["switches"]:
1844
1872
  return {
@@ -2089,6 +2117,7 @@ def dispatched_request():
2089
2117
  raise ValueError("The request id does not match the execution_id input")
2090
2118
  args = request.get("args")
2091
2119
  validate_arguments(args)
2120
+ refuse_git_code_change(args, f"A dispatched replay runs {branch}")
2092
2121
  if request.get("check") is True and "--dry-run" not in args:
2093
2122
  args = [*args, "--dry-run"]
2094
2123
  normalized = {
@@ -2227,7 +2256,7 @@ def branch_code_change(root, base, commit, request):
2227
2256
  isinstance(sha, str)
2228
2257
  and SHA.fullmatch(sha)
2229
2258
  and isinstance(ref, str)
2230
- and BRANCH_NAME.fullmatch(ref)
2259
+ and DESCRIBED_REF.fullmatch(ref)
2231
2260
  and (
2232
2261
  sha == base
2233
2262
  or quiet_git(root, "rev-parse", "--verify", "-q", f"{commit}^2") == sha
data/lib/bitfab/replay.rb CHANGED
@@ -143,11 +143,11 @@ module Bitfab
143
143
  # Replay historical traces through a traced method and create an experiment.
144
144
  module Replay
145
145
  # The values Client#replay accepts for +code_change:+.
146
- CODE_CHANGE_TYPES = %w[working-tree last-branch default-branch skip manual].freeze
146
+ CODE_CHANGE_TYPES = %w[working-tree last-branch default-branch ref skip manual].freeze
147
147
  # The +code_change:+ choices, as named in the errors that refuse a replay.
148
148
  CODE_CHANGE_CHOICES = '"working-tree" (uncommitted changes vs HEAD), "last-branch" (the diff against the ' \
149
- 'branch this one was cut from), "default-branch" (the diff against the default branch), "skip" (no code ' \
150
- 'change), or "manual" (pass the files yourself)'
149
+ 'branch this one was cut from), "default-branch" (the diff against the default branch), "ref" (the diff ' \
150
+ 'against a branch or commit you name), "skip" (no code change), or "manual" (pass the files yourself)'
151
151
  UNSET_DEFAULT = Object.new.freeze
152
152
  OPTION_DEFAULTS = {mock: "marked"}.freeze
153
153
  PERSISTENCE_TIMEOUT_SECONDS = 30.0
@@ -237,16 +237,22 @@ module Bitfab
237
237
  # diff against the branch this one was cut from, such as the branch below
238
238
  # it in a stack, else the default branch; "default-branch" the diff
239
239
  # against where HEAD split from the default branch (origin/HEAD, else
240
- # origin/main, origin/master, main, master); "skip" no code change, such
241
- # as for a baseline run; "manual" the files in code_change_files. The
242
- # three git choices include uncommitted edits and untracked files, and
243
- # warn and attach nothing when they find no changed files.
240
+ # origin/main, origin/master, main, master); "ref" the diff against where
241
+ # HEAD split from the branch or commit in code_change_base; "skip" no code
242
+ # change, such as for a baseline run; "manual" the files in
243
+ # code_change_files. The four git choices include uncommitted edits and
244
+ # untracked files, and warn and attach nothing when they find no changed
245
+ # files.
244
246
  # @param code_change_description [String, nil] description of the code
245
247
  # change, stored on the experiment. Defaults to one generated from the
246
248
  # captured files. Not accepted with "skip".
247
249
  # @param code_change_files [Array<Hash>, nil] the edited files when
248
250
  # code_change is "manual", and only then, each as { path:, before:, after: }
249
251
  # (empty string for new/deleted files).
252
+ # @param code_change_base [String, nil] the branch or commit to diff
253
+ # against when code_change is "ref", and only then, such as "dev",
254
+ # "release/2.3", or a commit SHA. A branch that exists only on the remote
255
+ # is read as origin/<name>. A name git cannot resolve raises ArgumentError.
250
256
  # @param experiment_group_id [String, nil] optional UUID grouping multiple
251
257
  # replay runs into a single experiment batch. The run joins that group,
252
258
  # and an id no group has yet creates it
@@ -313,11 +319,11 @@ module Bitfab
313
319
  # The experiment fields are nil for a dry run, which creates no experiment,
314
320
  # and progress events then carry a nil :experiment_id too.
315
321
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, notes: nil, metadata: nil,
316
- concurrency: nil, max_concurrency: ReplayConcurrency::UNSET, code_change: nil, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, experiment_group_name: nil,
322
+ concurrency: nil, max_concurrency: ReplayConcurrency::UNSET, code_change: nil, code_change_description: nil, code_change_files: nil, code_change_base: nil, experiment_group_id: nil, experiment_group_name: nil,
317
323
  dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, skip_assertion_judging: false, judge_assertions: nil, dry_run: false,
318
324
  adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil,
319
325
  on_experiment_start: nil, experimental_selective_replay: nil)
320
- validate_code_change(code_change, code_change_description, code_change_files)
326
+ validate_code_change(code_change, code_change_description, code_change_files, code_change_base)
321
327
  explicit_options = method(:run).parameters.filter_map { |type, option| [option, binding.local_variable_get(option)] if type == :key }
322
328
  skip_assertion_judging = resolve_skip_assertion_judging(skip_assertion_judging, judge_assertions)
323
329
  concurrency_options = ReplayConcurrency.resolve(concurrency, attempts, max_concurrency)
@@ -396,7 +402,7 @@ module Bitfab
396
402
 
397
403
  include_db_branch_lease = db_branch_enabled?(db_branch) && !dry_run
398
404
 
399
- code_change_description, code_change_files = resolve_code_change(code_change, code_change_description, code_change_files, name)
405
+ code_change_description, code_change_files = resolve_code_change(code_change, code_change_description, code_change_files, name, code_change_base)
400
406
 
401
407
  resolved_db_branch_settings = db_branch_settings(db_branch)
402
408
  replay_data = http_client.start_replay(
@@ -655,7 +661,7 @@ module Bitfab
655
661
  settings.empty? ? nil : settings
656
662
  end
657
663
 
658
- def validate_code_change(code_change, description, files)
664
+ def validate_code_change(code_change, description, files, base = nil)
659
665
  if code_change.nil?
660
666
  raise ArgumentError, "replay needs a code_change naming the code this run tests: #{CODE_CHANGE_CHOICES}."
661
667
  end
@@ -669,17 +675,24 @@ module Bitfab
669
675
  if code_change != "manual" && !files.nil?
670
676
  raise ArgumentError, "code_change_files only applies with code_change \"manual\" (got \"#{code_change}\")."
671
677
  end
678
+ if code_change == "ref" && !(base.is_a?(String) && !base.strip.empty? && !base.strip.start_with?("-"))
679
+ raise ArgumentError,
680
+ 'code_change "ref" needs code_change_base: the branch or commit to diff against, such as "dev" or a commit SHA.'
681
+ end
682
+ if code_change != "ref" && !base.nil?
683
+ raise ArgumentError, "code_change_base only applies with code_change \"ref\" (got \"#{code_change}\")."
684
+ end
672
685
  if code_change == "skip" && !description.nil?
673
686
  raise ArgumentError, 'code_change "skip" attaches no code change, so it cannot take a code_change_description.'
674
687
  end
675
688
  code_change
676
689
  end
677
690
 
678
- def resolve_code_change(code_change, description, files, label = nil)
691
+ def resolve_code_change(code_change, description, files, label = nil, base = nil)
679
692
  return [nil, nil] if code_change == "skip"
680
693
  return [description, files] if code_change == "manual"
681
694
 
682
- captured = read_code_change_file || capture_code_change_from_git(Dir.pwd, code_change, label)
695
+ captured = read_code_change_file || capture_code_change_from_git(Dir.pwd, code_change, label, base&.strip)
683
696
  if captured.nil? || captured[1].nil? || captured[1].empty?
684
697
  warn "Bitfab: code_change \"#{code_change}\" found no changed files, so this replay has no code change attached."
685
698
  return [description, nil]
@@ -713,8 +726,21 @@ module Bitfab
713
726
  !git(root, ["rev-parse", "--verify", "#{ref}^{object}"]).nil?
714
727
  end
715
728
 
716
- def cc_resolve_base(root, type)
729
+ def cc_resolve_chosen_base(root, chosen)
730
+ [chosen, "origin/#{chosen}"].each do |ref|
731
+ next unless cc_ref_exists?(root, "#{ref}^{commit}")
732
+
733
+ base = (git(root, ["merge-base", "HEAD", ref]) || "").strip
734
+ return base.empty? ? nil : [base, "vs #{chosen}"]
735
+ end
736
+ raise ArgumentError,
737
+ "code_change_base \"#{chosen}\" names no branch or commit in this repository. Pass a branch name, such as " \
738
+ "\"dev\", or a commit SHA; fetch it first if it only exists on the remote."
739
+ end
740
+
741
+ def cc_resolve_base(root, type, chosen = nil)
717
742
  case type
743
+ when "ref" then chosen ? cc_resolve_chosen_base(root, chosen) : nil
718
744
  when "last-branch" then cc_resolve_branch_point(root)
719
745
  when "default-branch" then cc_resolve_default_branch_point(root)
720
746
  else cc_ref_exists?(root, "HEAD") ? ["HEAD", "uncommitted (vs HEAD)"] : nil
@@ -809,12 +835,12 @@ module Bitfab
809
835
  0
810
836
  end
811
837
 
812
- def capture_code_change_from_git(cwd, type, label = nil)
838
+ def capture_code_change_from_git(cwd, type, label = nil, chosen = nil)
813
839
  root = git(cwd, ["rev-parse", "--show-toplevel"])
814
840
  return nil if root.nil? || root.strip.empty?
815
841
 
816
842
  root = root.strip
817
- resolved = cc_resolve_base(root, type)
843
+ resolved = cc_resolve_base(root, type, chosen)
818
844
  return nil if resolved.nil?
819
845
 
820
846
  entries = cc_list_changes(root, resolved[0])
@@ -849,6 +875,8 @@ module Bitfab
849
875
  head = ((label && !label.strip.empty?) ? label.strip : nil) || (subject.empty? ? nil : subject) || "Working-tree change"
850
876
  word = (files.length == 1) ? "file" : "files"
851
877
  ["#{head} (#{files.length} #{word} changed #{against})", files]
878
+ rescue ArgumentError
879
+ raise
852
880
  rescue
853
881
  nil
854
882
  end
@@ -204,6 +204,7 @@ module Bitfab
204
204
  end
205
205
 
206
206
  options[:code_change] = args.fetch(:code_change)
207
+ options[:code_change_base] = args[:code_change_base] if args[:code_change_base]
207
208
  if args[:code_change_file]
208
209
  code_change = load_code_change(args[:code_change_file])
209
210
  options[:code_change_description] = code_change.fetch("description")
@@ -337,10 +338,11 @@ module Bitfab
337
338
  options.on("--max-concurrency N", Integer) do |value|
338
339
  args[:max_concurrency] = positive_integer("--max-concurrency", value)
339
340
  end
340
- options.on("--code-change working-tree|last-branch|default-branch|skip|manual",
341
+ options.on("--code-change working-tree|last-branch|default-branch|ref|skip|manual",
341
342
  "Required: the code change this run tests; working-tree is the uncommitted changes vs HEAD, " \
342
343
  "last-branch the diff against the branch this one was cut from, default-branch the diff against the " \
343
- "default branch, skip none, manual the edits in --code-change-file.") do |value|
344
+ "default branch, ref the diff against --code-change-base, skip none, manual the edits in " \
345
+ "--code-change-file.") do |value|
344
346
  unless Replay::CODE_CHANGE_TYPES.include?(value)
345
347
  raise ArgumentError,
346
348
  "Unknown --code-change '#{value}'. Pass #{Replay::CODE_CHANGE_CHOICES}. " \
@@ -350,6 +352,8 @@ module Bitfab
350
352
  end
351
353
  options.on("--code-change-file PATH",
352
354
  "With --code-change manual: a JSON file of { description, files }.") { |value| args[:code_change_file] = value }
355
+ options.on("--code-change-base BRANCH|COMMIT",
356
+ "With --code-change ref: the branch or commit to diff against.") { |value| args[:code_change_base] = value }
353
357
  options.on("--experiment-group-id UUID",
354
358
  "Add this run to an experiment group. A UUID no group has yet creates the group.") { |value| args[:experiment_group_id] = value }
355
359
  options.on("--experiment-group-name NAME",
@@ -390,12 +394,17 @@ module Bitfab
390
394
  def require_code_change(args)
391
395
  if args[:code_change].nil?
392
396
  raise ArgumentError,
393
- "--code-change is required: #{Replay::CODE_CHANGE_CHOICES}. \"manual\" takes the edits from --code-change-file PATH."
397
+ "--code-change is required: #{Replay::CODE_CHANGE_CHOICES}. \"ref\" takes the branch or commit from " \
398
+ "--code-change-base, and \"manual\" takes the edits from --code-change-file PATH."
394
399
  end
395
- return args if (args[:code_change] == "manual") == !args[:code_change_file].nil?
400
+ unless (args[:code_change] == "manual") == !args[:code_change_file].nil?
401
+ raise ArgumentError,
402
+ "--code-change-file goes with --code-change manual, and --code-change manual needs --code-change-file PATH."
403
+ end
404
+ return args if (args[:code_change] == "ref") == !args[:code_change_base].nil?
396
405
 
397
406
  raise ArgumentError,
398
- "--code-change-file goes with --code-change manual, and --code-change manual needs --code-change-file PATH."
407
+ "--code-change-base goes with --code-change ref, and --code-change ref needs --code-change-base BRANCH|COMMIT."
399
408
  end
400
409
 
401
410
  def registry_dataset_ids(options)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.70.0"
4
+ VERSION = "0.71.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.70.0
4
+ version: 0.71.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team