yobi 0.1.0 → 0.2.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.
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "delegate"
5
-
6
3
  module Yobi
7
4
  class Repository
8
5
  # `restic diff`: compares two snapshots.
@@ -15,51 +12,66 @@ module Yobi
15
12
  argv = build_argv("diff", from, to) do |a|
16
13
  a.flag(:metadata) if metadata
17
14
  end
18
- execution = run_restic(argv)
19
- DiffOutcome.new(execution[:exit_code], execution[:output])
15
+ output = Yobi::ResticOutput.new(transform: Yobi::DiffMessageWrapper)
16
+ execution = run_restic(argv, output: output)
17
+ DiffOutcome.new(execution)
20
18
  end
21
19
  end
22
20
 
23
- # The outcome of one {Yobi::Repository#diff} call.
24
- class DiffOutcome < Struct.new(:exit_code, :output)
25
- # @return [Hash] Restic's own `"statistics"` fields (`"changed_files"`, `"added"`, `"removed"`, ...)
26
- def report
27
- @report ||= summary_hash
21
+ # Classifies a raw diff message Hash by its own message_type, wrapping
22
+ # it in the matching typed class. Used both as the {Yobi::ResticOutput}
23
+ # `transform:` for {Yobi::Repository#diff} and by {Yobi::DiffOutcome}'s
24
+ # own post-hoc accessors.
25
+ #
26
+ # @private
27
+ module DiffMessageWrapper
28
+ # @param raw [Hash]
29
+ # @return [Yobi::DiffChange, Yobi::DiffStatistics, Hash]
30
+ # the raw Hash itself for a message_type this version of Yobi doesn't recognize
31
+ def self.call(raw)
32
+ case raw["message_type"]
33
+ when "change" then DiffChange.new(raw)
34
+ when "statistics" then DiffStatistics.new(raw)
35
+ else raw
36
+ end
28
37
  end
38
+ end
29
39
 
30
- # @return [Enumerable<Yobi::DiffChange>]
31
- def changes
32
- @changes ||= DiffChanges.new(output)
33
- end
40
+ # The outcome of one {Yobi::Repository#diff} call.
41
+ class DiffOutcome
42
+ # @return [Yobi::ResticOutput]
43
+ attr_reader :output
34
44
 
35
- private
45
+ # @private
46
+ def initialize(execution)
47
+ @output = execution[:output]
48
+ end
36
49
 
37
- def summary_hash
38
- line = output.last_line
39
- hash = line && JSON.parse(line)
40
- if hash && hash["message_type"] == "statistics"
41
- hash
42
- else
43
- find_summary || {}
44
- end
50
+ # @return [Yobi::DiffStatistics] Restic's own `"statistics"` fields
51
+ def statistics
52
+ @statistics ||= output.messages("statistics").first || DiffStatistics.new({})
45
53
  end
54
+ alias_method :report, :statistics
46
55
 
47
- def find_summary
48
- output.each_line do |line|
49
- next if line.strip.empty?
56
+ # @return [Enumerable<Yobi::DiffChange>]
57
+ def changes
58
+ @changes ||= output.messages("change")
59
+ end
50
60
 
51
- hash = JSON.parse(line)
52
- return hash if hash["message_type"] == "statistics"
61
+ # @return [void]
62
+ def pretty_print(q)
63
+ q.object_group(self) do
64
+ q.breakable
65
+ q.pp statistics
53
66
  end
54
-
55
- nil
56
67
  end
57
68
  end
58
69
 
59
70
  # One `"change"` message from a diff run. `modifier` is Restic's own
60
- # single-character code: `"+"` added, `"-"` removed, `"U"` metadata
61
- # updated, `"M"` content modified, `"T"` type changed, `"?"` bitrot detected.
62
- class DiffChange < SimpleDelegator
71
+ # concatenation of single-character codes: `"+"` added, `"-"` removed,
72
+ # `"U"` metadata updated, `"M"` content modified, `"T"` type changed,
73
+ # `"?"` bitrot detected.
74
+ class DiffChange < Yobi::FancyHash
63
75
  # @return [String]
64
76
  def path
65
77
  self["path"]
@@ -69,26 +81,97 @@ module Yobi
69
81
  def modifier
70
82
  self["modifier"]
71
83
  end
84
+
85
+ # @return [Boolean]
86
+ def added?
87
+ modifier.include?("+")
88
+ end
89
+
90
+ # @return [Boolean]
91
+ def removed?
92
+ modifier.include?("-")
93
+ end
94
+
95
+ # @return [Boolean]
96
+ def type_changed?
97
+ modifier.include?("T")
98
+ end
99
+
100
+ # @return [Boolean]
101
+ def modified?
102
+ modifier.include?("M")
103
+ end
104
+
105
+ # @return [Boolean]
106
+ def metadata_updated?
107
+ modifier.include?("U")
108
+ end
109
+
110
+ # @return [Boolean]
111
+ def bitrot?
112
+ modifier.include?("?")
113
+ end
72
114
  end
73
115
 
74
- # Enumerable over every {Yobi::DiffChange} in a run.
75
- class DiffChanges
76
- include Enumerable
116
+ # The `"statistics"` message from a diff run, the final result once the
117
+ # command finishes. Dispatched to {Yobi::DiffOutcome#statistics}.
118
+ class DiffStatistics < Yobi::FancyHash
119
+ # @return [String, nil]
120
+ def source_snapshot
121
+ self["source_snapshot"]
122
+ end
77
123
 
78
- # @param output [Yobi::ResticOutput]
79
- def initialize(output)
80
- @output = output
124
+ # @return [String, nil]
125
+ def target_snapshot
126
+ self["target_snapshot"]
81
127
  end
82
128
 
83
- # @yieldparam change [Yobi::DiffChange]
84
- # @return [Enumerator] if no block is given
85
- def each
86
- return enum_for(:each) unless block_given?
129
+ # @return [Integer]
130
+ def changed_files
131
+ self["changed_files"] || 0
132
+ end
87
133
 
88
- @output.index["change"].each do |offset|
89
- line = @output.read_line_at(offset)
90
- yield DiffChange.new(JSON.parse(line))
91
- end
134
+ # @return [Yobi::DiffStat]
135
+ def added
136
+ @added ||= DiffStat.new(self["added"] || {})
137
+ end
138
+
139
+ # @return [Yobi::DiffStat]
140
+ def removed
141
+ @removed ||= DiffStat.new(self["removed"] || {})
142
+ end
143
+ end
144
+
145
+ # The `"added"`/`"removed"` sub-object of a {Yobi::DiffStatistics} message.
146
+ class DiffStat < Yobi::FancyHash
147
+ # @return [Integer]
148
+ def files
149
+ self["files"] || 0
150
+ end
151
+
152
+ # @return [Integer]
153
+ def dirs
154
+ self["dirs"] || 0
155
+ end
156
+
157
+ # @return [Integer]
158
+ def others
159
+ self["others"] || 0
160
+ end
161
+
162
+ # @return [Integer]
163
+ def data_blobs
164
+ self["data_blobs"] || 0
165
+ end
166
+
167
+ # @return [Integer]
168
+ def tree_blobs
169
+ self["tree_blobs"] || 0
170
+ end
171
+
172
+ # @return [Integer]
173
+ def bytes
174
+ self["bytes"] || 0
92
175
  end
93
176
  end
94
177
  end
@@ -4,7 +4,7 @@ require "tempfile"
4
4
 
5
5
  module Yobi
6
6
  class Repository
7
- # Valid `archive:` values for {#dump}, as both String and Symbol.
7
+ # @private
8
8
  ARCHIVE_TYPES = Set.new(%w[tar zip].flat_map { [_1, _1.to_sym] }).freeze
9
9
 
10
10
  # `restic dump`: extracts a file/folder from a snapshot. A single file's
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "json"
4
4
  require "time"
5
- require "delegate"
6
5
 
7
6
  module Yobi
8
7
  class Repository
@@ -24,7 +23,7 @@ module Yobi
24
23
  # @param show_pack_id [Boolean] include the pack ID each match is stored in
25
24
  # @param snapshot_ids [Array<String>, String] restrict to these snapshot ID(s)
26
25
  # @param tags [Array<String>, String] filter by tag(s)
27
- # @return [Enumerable<Yobi::MatchesPerSnapshot>]
26
+ # @return [Array<Yobi::MatchesPerSnapshot>]
28
27
  def find(patterns:, blob: false, pack: false, tree: false, hosts: [], human_readable: false,
29
28
  ignore_case: false, long: false, newest: nil, oldest: nil, paths: [], reverse: false,
30
29
  show_pack_id: false, snapshot_ids: [], tags: [])
@@ -46,12 +45,12 @@ module Yobi
46
45
  a.end_of_options.append(patterns)
47
46
  end
48
47
  execution = run_restic(argv)
49
- FindMatches.new(execution[:output])
48
+ JSON.parse(execution[:output].to_s).map { |raw| MatchesPerSnapshot.new(raw) }
50
49
  end
51
50
  end
52
51
 
53
52
  # One matched file/directory from a {Yobi::Repository#find} call.
54
- class FindMatch < SimpleDelegator
53
+ class FindMatch < Yobi::FancyHash
55
54
  # @return [String]
56
55
  def path
57
56
  self["path"]
@@ -114,11 +113,17 @@ module Yobi
114
113
  end
115
114
 
116
115
  # One snapshot's worth of matches from a {Yobi::Repository#find} call.
117
- class MatchesPerSnapshot < SimpleDelegator
118
- # @return [Hash]
116
+ class MatchesPerSnapshot < Yobi::FancyHash
117
+ # Restic's own `find --json` output only gives the snapshot's ID here,
118
+ # not a full record - unlike every other place in this API where
119
+ # "snapshot" means a {Yobi::Snapshot}. Call {Yobi::Repository#snapshots}
120
+ # yourself if you need the full record.
121
+ #
122
+ # @return [String]
119
123
  def snapshot
120
124
  self["snapshot"]
121
125
  end
126
+ alias_method :snapshot_id, :snapshot
122
127
 
123
128
  # @return [Integer]
124
129
  def hits
@@ -130,31 +135,4 @@ module Yobi
130
135
  @matches ||= (self["matches"] || []).map { |raw| Yobi::FindMatch.new(raw) }
131
136
  end
132
137
  end
133
-
134
- # Enumerable over every {Yobi::MatchesPerSnapshot} in one {Yobi::Repository#find} call.
135
- class FindMatches
136
- include Enumerable
137
-
138
- # @return [Yobi::ResticOutput]
139
- attr_reader :output
140
-
141
- # @param output [Yobi::ResticOutput]
142
- def initialize(output)
143
- @output = output
144
- end
145
-
146
- # @yieldparam matches [Yobi::MatchesPerSnapshot]
147
- # @return [Enumerator] if no block is given
148
- def each(&block)
149
- return enum_for(:each) unless block_given?
150
-
151
- matches_per_snapshot.each(&block)
152
- end
153
-
154
- private
155
-
156
- def matches_per_snapshot
157
- @matches_per_snapshot ||= JSON.parse(output.to_s).map { |raw| Yobi::MatchesPerSnapshot.new(raw) }
158
- end
159
- end
160
138
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "delegate"
5
4
 
6
5
  module Yobi
7
6
  class Repository
@@ -34,7 +33,7 @@ module Yobi
34
33
  # @param repack_cacheable_only [Boolean] passed through to the implied prune
35
34
  # @param repack_uncompressed [Boolean] passed through to the implied prune
36
35
  # @param repack_smaller_than [String, nil] passed through to the implied prune
37
- # @return [Enumerable<Yobi::ForgetGroup>]
36
+ # @return [Array<Yobi::ForgetGroup>]
38
37
  def forget(keep_last: nil, keep_hourly: nil, keep_daily: nil, keep_weekly: nil, keep_monthly: nil,
39
38
  keep_yearly: nil, keep_within: nil, keep_within_hourly: nil, keep_within_daily: nil,
40
39
  keep_within_weekly: nil, keep_within_monthly: nil, keep_within_yearly: nil, keep_tags: [],
@@ -70,54 +69,13 @@ module Yobi
70
69
  a.flag(:repack_smaller_than, repack_smaller_than) unless repack_smaller_than.nil?
71
70
  end
72
71
  execution = run_restic(argv)
73
- ForgetGroups.new(execution[:exit_code], execution[:output])
74
- end
75
- end
76
-
77
- # The outcome of one {Yobi::Repository#forget} call.
78
- class ForgetGroups
79
- include Enumerable
80
-
81
- # @return [Integer]
82
- attr_reader :exit_code
83
- # @return [Yobi::ResticOutput]
84
- attr_reader :output
85
-
86
- # @param exit_code [Integer]
87
- # @param output [Yobi::ResticOutput]
88
- def initialize(exit_code, output)
89
- @exit_code = exit_code
90
- @output = output
91
- end
92
-
93
- # @yieldparam group [Yobi::ForgetGroup]
94
- # @return [Enumerator] if no block is given
95
- def each(&block)
96
- return enum_for(:each) unless block_given?
97
-
98
- groups.each(&block)
99
- end
100
-
101
- # @return [Boolean]
102
- def success?
103
- exit_code.zero?
104
- end
105
-
106
- # @return [Boolean]
107
- def partial?
108
- exit_code == 3
109
- end
110
-
111
- private
112
-
113
- def groups
114
- @groups ||= JSON.parse(output.to_s).map { |raw| Yobi::ForgetGroup.new(raw) }
72
+ JSON.parse(execution[:output].to_s).map { |raw| ForgetGroup.new(raw) }
115
73
  end
116
74
  end
117
75
 
118
76
  # One grouping Restic's forget policy was evaluated against (by default,
119
77
  # grouped by host+paths).
120
- class ForgetGroup < SimpleDelegator
78
+ class ForgetGroup < Yobi::FancyHash
121
79
  # @return [String]
122
80
  def host
123
81
  self["host"]
@@ -143,14 +101,14 @@ module Yobi
143
101
  @remove ||= (self["remove"] || []).map { |entry| Yobi::Snapshot.new(entry) }
144
102
  end
145
103
 
146
- # @return [Array<Yobi::ForgetReason>] one per kept snapshot, explaining which policy rule(s) kept it
104
+ # @return [Array<Yobi::KeepReason>] one per kept snapshot, explaining which policy rule(s) kept it
147
105
  def reasons
148
- @reasons ||= (self["reasons"] || []).map { |entry| Yobi::ForgetReason.new(entry) }
106
+ @reasons ||= (self["reasons"] || []).map { |entry| Yobi::KeepReason.new(entry) }
149
107
  end
150
108
  end
151
109
 
152
110
  # Why one snapshot survived a {Yobi::Repository#forget} run.
153
- class ForgetReason < SimpleDelegator
111
+ class KeepReason < Yobi::FancyHash
154
112
  # @return [Yobi::Snapshot]
155
113
  def snapshot
156
114
  @snapshot ||= Yobi::Snapshot.new(self["snapshot"])
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Yobi
6
+ class Repository
7
+ # `restic init`: creates the repository at {#url}.
8
+ #
9
+ # @param copy_chunker_params [Boolean] copy chunker parameters from `from_repo:`/`from_repository_file:`
10
+ # @param from_key_hint [String, nil]
11
+ # @param from_password [String, Array, Symbol, #call, nil] the source
12
+ # repository's password (when copying chunker params from one), same
13
+ # shape as {#initialize}'s `password:`
14
+ # @param from_repo [String, Yobi::Repository, nil] the source repository, or its URL
15
+ # (given a `Repository`, its own `#url`/`#password` are used automatically)
16
+ # @param from_repository_file [String, nil]
17
+ # @param repository_version [String, nil]
18
+ # @return [Yobi::Initialized]
19
+ def init(copy_chunker_params: false, from_key_hint: nil, from_password: nil, from_repo: nil,
20
+ from_repository_file: nil, repository_version: nil)
21
+ if from_repo.is_a?(Repository)
22
+ from_password = from_repo.password if from_password.nil?
23
+ from_repo = from_repo.url
24
+ end
25
+
26
+ argv = build_argv("init") do |a|
27
+ a.flag(:copy_chunker_params) if copy_chunker_params
28
+ a.flag(:from_insecure_no_password) if from_password == :insecure_no_password
29
+ a.flag(:from_key_hint, from_key_hint) unless from_key_hint.nil?
30
+ a.flag(:from_repo, from_repo) unless from_repo.nil?
31
+ a.flag(:from_repository_file, from_repository_file) unless from_repository_file.nil?
32
+ a.flag(:repository_version, repository_version) unless repository_version.nil?
33
+ end
34
+ execution = run_restic(argv, extra_env: password_env(from_password, "RESTIC_FROM_PASSWORD"))
35
+ Initialized.new(JSON.parse(execution[:output].to_s))
36
+ end
37
+
38
+ # Constructs a new {Yobi::Repository} at `url`, already initialized with
39
+ # chunker parameters copied from this one (`{#init}(copy_chunker_params:
40
+ # true, from_repo: self)`), so a later {#copy} between the two can
41
+ # deduplicate. A one-time setup call, not an ongoing sync: nothing keeps
42
+ # the two repositories in sync afterward, and this doesn't mutate this
43
+ # `Repository` instance or copy any snapshots itself.
44
+ #
45
+ # @param url [String] the new repository's location
46
+ # @param password [String, Array, Symbol, #call] the new repository's own password
47
+ # @param backend_credentials [Hash{String => String}, #call] the new repository's own backend credentials
48
+ # @param restic [Yobi::Restic, String, nil] a `Restic` instance to share, defaults to this repository's own
49
+ # @param repository_version [String, nil]
50
+ # @return [Yobi::Repository] the newly initialized mirror
51
+ def init_mirror(url:, password:, backend_credentials: {}, restic: @restic, repository_version: nil)
52
+ Repository.new(url: url, password: password, backend_credentials: backend_credentials, restic: restic).tap do |mirror|
53
+ mirror.init(copy_chunker_params: true, from_repo: self, repository_version: repository_version)
54
+ end
55
+ end
56
+ end
57
+
58
+ # The `"initialized"` message from an {Yobi::Repository#init} run.
59
+ class Initialized < Yobi::FancyHash
60
+ # @return [String] ID of the created repository
61
+ def id
62
+ self["id"]
63
+ end
64
+
65
+ # @return [String] URL of the repository
66
+ def repository
67
+ self["repository"]
68
+ end
69
+ end
70
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "time"
5
- require "delegate"
5
+ require "tempfile"
6
6
 
7
7
  module Yobi
8
8
  class Repository
@@ -10,48 +10,56 @@ module Yobi
10
10
  #
11
11
  # @param host [String, nil] hostname to record on the new key
12
12
  # @param user [String, nil] username to record on the new key
13
- # @param new_insecure_no_password [Boolean] create the new key with an empty password
14
- # @param new_password_file [String, nil] path to a file containing the new key's password
13
+ # @param new_password [String, Array, Symbol, #call] the new key's password,
14
+ # accepting the same shapes as {#initialize}'s `password:` minus `[:command, ...]`: a
15
+ # literal String; a `[:file, "..."]` tuple, resolved natively by Restic
16
+ # itself; `:insecure_no_password`; or anything responding to `#call`. A
17
+ # literal String or callable is written to a briefly-lived,
18
+ # 0600-permissioned tempfile, since Restic itself only accepts a new
19
+ # password by file or interactive prompt, unlike the repository's own
20
+ # `password:`
15
21
  # @return [true]
16
- def key_add(host: nil, user: nil, new_insecure_no_password: false, new_password_file: nil)
17
- argv = build_argv("key", "add") do |a|
18
- a.flag(:host, host) unless host.nil?
19
- a.flag(:user, user) unless user.nil?
20
- a.flag(:new_insecure_no_password) if new_insecure_no_password
21
- a.flag(:new_password_file, new_password_file) unless new_password_file.nil?
22
+ def key_add(new_password:, host: nil, user: nil)
23
+ with_new_password_flag(new_password) do |flag_name, flag_value|
24
+ argv = build_argv("key", "add") do |a|
25
+ a.flag(:host, host) unless host.nil?
26
+ a.flag(:user, user) unless user.nil?
27
+ a.flag(flag_name, flag_value) unless flag_name.nil?
28
+ end
29
+ run_restic(argv)
22
30
  end
23
- run_restic(argv)
24
31
  true
25
32
  end
26
33
  alias_method :add_key, :key_add
27
34
 
28
35
  # `restic key list`: lists every key (password) associated with this repository.
29
36
  #
30
- # @return [Enumerable<Yobi::Key>]
37
+ # @return [Array<Yobi::Key>]
31
38
  def key_list
32
39
  execution = run_restic(build_argv("key", "list"))
33
- Keys.new(execution[:output])
40
+ JSON.parse(execution[:output].to_s).map { |raw| Key.new(raw) }
34
41
  end
35
42
  alias_method :keys, :key_list
36
43
 
37
44
  # `restic key passwd`: creates a new key for this repository and
38
- # removes the key currently in use. Doesn't mutate this `Repository`
39
- # instance's own `password:`. Build a new `Repository` with the
40
- # rotated password to keep talking to this repository afterward.
45
+ # removes the key currently in use. On success, also updates this
46
+ # `Repository` instance's own `password:` to `new_password:`, so it
47
+ # keeps working against this same repository afterward.
41
48
  #
42
49
  # @param host [String, nil] hostname to record on the new key
43
50
  # @param user [String, nil] username to record on the new key
44
- # @param new_insecure_no_password [Boolean] set the new key's password to empty
45
- # @param new_password_file [String, nil] path to a file containing the new key's password
51
+ # @param new_password [String, Array, Symbol, #call] see {#key_add}
46
52
  # @return [true]
47
- def key_passwd(host: nil, user: nil, new_insecure_no_password: false, new_password_file: nil)
48
- argv = build_argv("key", "passwd") do |a|
49
- a.flag(:host, host) unless host.nil?
50
- a.flag(:user, user) unless user.nil?
51
- a.flag(:new_insecure_no_password) if new_insecure_no_password
52
- a.flag(:new_password_file, new_password_file) unless new_password_file.nil?
53
+ def key_passwd(new_password:, host: nil, user: nil)
54
+ with_new_password_flag(new_password) do |flag_name, flag_value|
55
+ argv = build_argv("key", "passwd") do |a|
56
+ a.flag(:host, host) unless host.nil?
57
+ a.flag(:user, user) unless user.nil?
58
+ a.flag(flag_name, flag_value) unless flag_name.nil?
59
+ end
60
+ run_restic(argv)
53
61
  end
54
- run_restic(argv)
62
+ @password = new_password
55
63
  true
56
64
  end
57
65
  alias_method :change_password, :key_passwd
@@ -67,10 +75,36 @@ module Yobi
67
75
  true
68
76
  end
69
77
  alias_method :remove_key, :key_remove
78
+
79
+ private
80
+
81
+ def with_new_password_flag(new_password)
82
+ case new_password
83
+ in :insecure_no_password
84
+ yield :new_insecure_no_password, nil
85
+ in [:file, String => path]
86
+ yield :new_password_file, path
87
+ in String => literal
88
+ with_temp_password_file(literal) { |path| yield :new_password_file, path }
89
+ in callable if callable.respond_to?(:call)
90
+ with_temp_password_file(callable.call.to_s) { |path| yield :new_password_file, path }
91
+ end
92
+ end
93
+
94
+ def with_temp_password_file(password)
95
+ file = Tempfile.new("yobi-new-password")
96
+ file.chmod(0o600)
97
+ file.write(password)
98
+ file.flush
99
+ yield file.path
100
+ ensure
101
+ file&.close
102
+ file&.unlink
103
+ end
70
104
  end
71
105
 
72
106
  # One key (password) associated with the repository, from {Yobi::Repository#key_list}.
73
- class Key < SimpleDelegator
107
+ class Key < Yobi::FancyHash
74
108
  # @return [String]
75
109
  def id
76
110
  self["id"]
@@ -96,31 +130,4 @@ module Yobi
96
130
  @created ||= Time.parse(self["created"])
97
131
  end
98
132
  end
99
-
100
- # Enumerable over every {Yobi::Key} in one {Yobi::Repository#key_list} call.
101
- class Keys
102
- include Enumerable
103
-
104
- # @return [Yobi::ResticOutput]
105
- attr_reader :output
106
-
107
- # @param output [Yobi::ResticOutput]
108
- def initialize(output)
109
- @output = output
110
- end
111
-
112
- # @yieldparam key [Yobi::Key]
113
- # @return [Enumerator] if no block is given
114
- def each(&block)
115
- return enum_for(:each) unless block_given?
116
-
117
- keys.each(&block)
118
- end
119
-
120
- private
121
-
122
- def keys
123
- @keys ||= JSON.parse(output.to_s).map { |raw| Yobi::Key.new(raw) }
124
- end
125
- end
126
133
  end