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.
data/lib/yobi/restic.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tempfile"
4
3
  require "open3"
5
4
  require "json"
6
5
 
@@ -164,10 +163,10 @@ module Yobi
164
163
 
165
164
  # `restic version`: the installed binary's own version info.
166
165
  #
167
- # @return [Hash] `"version"`, `"go_version"`, `"go_os"`, `"go_arch"`
166
+ # @return [Yobi::ResticVersion]
168
167
  def version
169
168
  execution = run(build_argv("version"), skip_version_check: true)
170
- JSON.parse(execution[:output].to_s)
169
+ Yobi::ResticVersion.new(parse_version_output(execution[:output].to_s))
171
170
  end
172
171
 
173
172
  # `restic cache`: lists and optionally cleans local cache directories.
@@ -193,13 +192,16 @@ module Yobi
193
192
  # @param argv [Array<String>]
194
193
  # @param extra_env [Hash{String => String}]
195
194
  # @param skip_version_check [Boolean] used internally by {#version} to avoid recursing into {#ensure_minimum_version!}
196
- # @yieldparam message [Hash] a parsed JSON line, as the command runs
195
+ # @param output [Yobi::ResticOutput, nil] a caller-configured one (e.g. with its own `transform:`),
196
+ # used instead of a plain one this creates itself; forces streaming even without a block
197
+ # @yieldparam message [Object] each message, live, as the command runs - transformed via
198
+ # `output`'s own `transform:` if it has one, the raw parsed Hash otherwise
197
199
  # @return [Hash] `{exit_code:, output:, argv:}` on exit code 0/3
198
200
  # @raise [Yobi::RepositoryNotFound, Yobi::RepositoryLocked, Yobi::AuthenticationFailed, Yobi::ResticCommandFailed]
199
- def run(argv, extra_env: {}, skip_version_check: false, &block)
201
+ def run(argv, extra_env: {}, skip_version_check: false, output: nil, &block)
200
202
  ensure_minimum_version! unless skip_version_check
201
- execution = if block
202
- execute_with_streaming(argv, extra_env, &block)
203
+ execution = if output || block
204
+ execute_with_streaming(argv, extra_env, output: output, &block)
203
205
  else
204
206
  execute(argv, extra_env)
205
207
  end
@@ -241,15 +243,14 @@ module Yobi
241
243
  # @return [Object] the block's own return value, otherwise
242
244
  def run_dump(argv, extra_env: {})
243
245
  ensure_minimum_version!
244
- file = Tempfile.new("yobi-restic-output")
245
- file.unlink
246
+ output = Yobi::ResticOutput.new
246
247
  read_end, write_end = IO.pipe
247
248
  read_end.binmode
248
249
 
249
- pid = Process.spawn(env.merge(extra_env), restic_path, *argv, out: write_end, err: file)
250
+ pid = Process.spawn(env.merge(extra_env), restic_path, *argv, out: write_end, err: output.file)
250
251
  write_end.close
251
252
 
252
- return Yobi::IOHandle.new(read_end, pid: pid, output_file: file, argv: argv) unless block_given?
253
+ return Yobi::IOHandle.new(read_end, pid: pid, output: output, argv: argv) unless block_given?
253
254
 
254
255
  begin
255
256
  result = yield read_end
@@ -258,10 +259,10 @@ module Yobi
258
259
  _, status = Process.wait2(pid)
259
260
  end
260
261
 
261
- self.class.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(file), argv: argv)
262
+ self.class.dispatch(exit_code: status.exitstatus, output: output, argv: argv)
262
263
  result
263
264
  rescue Errno::ENOENT
264
- file&.close
265
+ output&.file&.close
265
266
  raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
266
267
  end
267
268
 
@@ -284,6 +285,20 @@ module Yobi
284
285
 
285
286
  private
286
287
 
288
+ # An old enough Restic ignores --json for `version` entirely and
289
+ # prints plain text instead (e.g. "restic 0.9.6 compiled with
290
+ # go1.13.4 on linux/amd64"), which JSON.parse can't handle.
291
+ VERSION_LINE_PATTERN = /restic\s+(\d+\.\d+\.\d+\S*)/
292
+
293
+ def parse_version_output(raw)
294
+ JSON.parse(raw)
295
+ rescue JSON::ParserError
296
+ match = VERSION_LINE_PATTERN.match(raw)
297
+ raise unless match
298
+
299
+ {"version" => match[1]}
300
+ end
301
+
287
302
  def build_argv(*base)
288
303
  builder = ArgvBuilder.new
289
304
  builder.append(*base)
@@ -304,38 +319,64 @@ module Yobi
304
319
  end
305
320
 
306
321
  def execute(argv, extra_env)
307
- file = Tempfile.new("yobi-restic-output")
308
- file.unlink
309
- pid = Process.spawn(env.merge(extra_env), restic_path, *argv, out: file, err: file)
322
+ output = Yobi::ResticOutput.new
323
+ pid = Process.spawn(env.merge(extra_env), restic_path, *argv, out: output.file, err: output.file)
310
324
  _, status = Process.wait2(pid)
311
- {exit_code: status.exitstatus, output: Yobi::ResticOutput.new(file), argv: argv}
325
+ {exit_code: status.exitstatus, output: output, argv: argv}
312
326
  rescue Errno::ENOENT
313
- file&.close
327
+ output&.file&.close
314
328
  raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
315
329
  end
316
330
 
317
- def execute_with_streaming(argv, extra_env)
318
- file = Tempfile.new("yobi-restic-output")
319
- file.unlink
331
+ def execute_with_streaming(argv, extra_env, output: nil)
332
+ output ||= Yobi::ResticOutput.new
320
333
 
321
- Open3.popen2e(env.merge(extra_env), restic_path, *argv) do |stdin, merged_output, wait_thr|
334
+ Open3.popen3(env.merge(extra_env), restic_path, *argv) do |stdin, stdout, stderr, wait_thr|
322
335
  stdin.close
323
- merged_output.each_line do |line|
324
- file.write(line)
325
- parsed = parse_streamed_line(line)
326
- yield parsed if parsed
336
+ readers = {stdout => :stdout, stderr => :stderr}
337
+
338
+ until readers.empty?
339
+ ready, = IO.select(readers.keys)
340
+ ready.each do |io|
341
+ line = io.gets
342
+ if line.nil?
343
+ readers.delete(io)
344
+ next
345
+ end
346
+
347
+ parsed = output.write_and_parse(line, readers[io])
348
+ yield parsed if parsed && block_given?
349
+ end
327
350
  end
328
- {exit_code: wait_thr.value.exitstatus, output: Yobi::ResticOutput.new(file), argv: argv}
351
+
352
+ {exit_code: wait_thr.value.exitstatus, output: output, argv: argv}
329
353
  end
330
354
  rescue Errno::ENOENT
331
- file&.close
355
+ output&.file&.close
332
356
  raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
333
357
  end
358
+ end
359
+
360
+ # The result of one {Yobi::Restic#version} call.
361
+ class ResticVersion < Yobi::FancyHash
362
+ # @return [String]
363
+ def version
364
+ self["version"]
365
+ end
334
366
 
335
- def parse_streamed_line(line)
336
- JSON.parse(line)
337
- rescue JSON::ParserError
338
- nil
367
+ # @return [String, nil]
368
+ def go_version
369
+ self["go_version"]
370
+ end
371
+
372
+ # @return [String, nil]
373
+ def go_os
374
+ self["go_os"]
375
+ end
376
+
377
+ # @return [String, nil]
378
+ def go_arch
379
+ self["go_arch"]
339
380
  end
340
381
  end
341
382
  end
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Yobi
4
- # Captures a line's message_type without fully parsing it as JSON.
5
- #
6
- # @private
7
- MESSAGE_TYPE_LINE_PATTERN = /"message_type"\s*:\s*"(\w+)"/
3
+ require "json"
4
+ require "tempfile"
8
5
 
6
+ module Yobi
9
7
  # Wraps the tempfile a repository operation's stdout+stderr are both
10
8
  # written to, with random access so a caller asking only for the last
11
9
  # line (see {#last_line}) doesn't have to read the entire output.
@@ -15,11 +13,19 @@ module Yobi
15
13
  # Default read window size, in bytes, for {#last_line}.
16
14
  DEFAULT_TAIL_CHUNK_SIZE = 4096
17
15
 
18
- # @param file [File]
16
+ # @return [File] a fresh, already-unlinked tempfile of its own
17
+ attr_reader :file
18
+
19
19
  # @param tail_chunk_size [Integer] read window size for {#last_line}
20
- def initialize(file, tail_chunk_size: DEFAULT_TAIL_CHUNK_SIZE)
21
- @file = file
20
+ # @param transform [Proc, nil] used by {#write_and_parse} during a streaming run to transform
21
+ # each message, and as {#messages}' default transform afterward, when no explicit one is given
22
+ def initialize(tail_chunk_size: DEFAULT_TAIL_CHUNK_SIZE, transform: nil)
23
+ @file = Tempfile.new("yobi-restic-output")
24
+ @file.unlink
22
25
  @tail_chunk_size = tail_chunk_size
26
+ @transform = transform
27
+ @index = nil
28
+ @stderr_offsets = []
23
29
  end
24
30
 
25
31
  # Byte offsets of every line, grouped by message_type. Built once and
@@ -69,26 +75,177 @@ module Yobi
69
75
  @file.each_line(&block)
70
76
  end
71
77
 
78
+ # Every message recorded in {#index}, restricted to one +message_type+
79
+ # if given, in file order. Without an explicit transform block, uses
80
+ # the +transform:+ given to {#initialize} (if any); with neither, just
81
+ # yields/returns the raw parsed Hash. Whenever a transform (explicit
82
+ # or the stored one) applies, wraps the result in a {LazyList}
83
+ # instead of yielding.
84
+ #
85
+ # @param message_type [String, nil] every message, if omitted
86
+ # @yieldparam message [Hash] the raw parsed message, to transform
87
+ # @return [Yobi::ResticOutput::LazyList] if a transform block (or a stored transform) applies
88
+ # @return [Enumerator] of raw Hashes, otherwise
89
+ def messages(message_type = nil, &transform)
90
+ transform ||= @transform
91
+ return raw_messages(message_type) unless transform
92
+
93
+ offsets = message_type ? index[message_type] : index.values.flatten.sort
94
+ LazyList.new(self, offsets, transform)
95
+ end
96
+
97
+ # Every line known to have arrived on stderr rather than stdout during
98
+ # a streaming run (see {#write_and_parse}), in file order.
99
+ #
100
+ # @yieldparam line [String]
101
+ # @return [Enumerator] if no block is given
102
+ def stderr_lines
103
+ return enum_for(:stderr_lines) unless block_given?
104
+
105
+ @stderr_offsets.each { |offset| yield read_line_at(offset) }
106
+ end
107
+
108
+ # Called once per line read from a live streaming run's stdout/stderr.
109
+ # Writes it to the file, parses it, and - if it's a JSON message -
110
+ # indexes it by message_type and returns it transformed via the
111
+ # +transform:+ given to {#initialize} (or the raw parsed Hash, if none
112
+ # was given). A non-JSON line's offset is recorded in {#stderr_lines}
113
+ # instead, if +origin+ is +:stderr+; either way, returns nil.
114
+ #
115
+ # @param line [String]
116
+ # @param origin [:stdout, :stderr]
117
+ # @return [Object, nil]
118
+ def write_and_parse(line, origin)
119
+ offset = @file.pos
120
+ @file.write(line)
121
+
122
+ parsed = begin
123
+ JSON.parse(line)
124
+ rescue JSON::ParserError
125
+ nil
126
+ end
127
+
128
+ if parsed
129
+ (@index ||= Hash.new { |hash, message_type| hash[message_type] = [] })[parsed["message_type"]] << offset if parsed["message_type"]
130
+ @transform ? @transform.call(parsed) : parsed
131
+ elsif origin == :stderr
132
+ @stderr_offsets << offset
133
+ nil
134
+ end
135
+ end
136
+
72
137
  # @return [String] the full captured output
73
138
  def to_s
74
139
  @file.rewind
75
140
  @file.read
76
141
  end
77
142
 
143
+ # @return [String]
144
+ def inspect
145
+ "#<#{self.class} #{@file.size} bytes>"
146
+ end
147
+
78
148
  private
79
149
 
80
- def build_index
81
- index = Hash.new { |hash, message_type| hash[message_type] = [] }
150
+ def raw_messages(message_type)
151
+ return enum_for(:raw_messages, message_type) unless block_given?
152
+
153
+ if message_type
154
+ index[message_type].each { |offset| yield JSON.parse(read_line_at(offset)) }
155
+ else
156
+ each_parsed_line { |_offset, parsed| yield parsed }
157
+ end
158
+ end
159
+
160
+ def each_parsed_line
82
161
  @file.rewind
83
162
  loop do
84
163
  offset = @file.pos
85
164
  line = @file.gets
86
165
  break unless line
87
166
 
88
- match = MESSAGE_TYPE_LINE_PATTERN.match(line)
89
- index[match[1]] << offset if match
167
+ parsed = begin
168
+ JSON.parse(line)
169
+ rescue JSON::ParserError
170
+ nil
171
+ end
172
+ yield(offset, parsed) if parsed && parsed["message_type"]
90
173
  end
174
+ end
175
+
176
+ def build_index
177
+ index = Hash.new { |hash, message_type| hash[message_type] = [] }
178
+ each_parsed_line { |offset, parsed| index[parsed["message_type"]] << offset }
91
179
  index
92
180
  end
181
+
182
+ # A lazily-transformed view over a set of {#index} offsets, printing
183
+ # like a plain Array (bounded to a preview, unlike one). Returned by
184
+ # {ResticOutput#messages} when given a transform block.
185
+ # #inspect/#pretty_print's preview is memoized, and #each reuses it
186
+ # rather than re-reading/re-transforming those same offsets again
187
+ # when the caller iterates the whole thing.
188
+ #
189
+ # @private
190
+ class LazyList
191
+ include Enumerable
192
+
193
+ # Items #inspect/#pretty_print show before truncating with "...".
194
+ # One louder than ActiveRecord::Relation#inspect's own preview size.
195
+ INSPECT_PREVIEW_SIZE = 11
196
+
197
+ # @param output [Yobi::ResticOutput]
198
+ # @param offsets [Array<Integer>]
199
+ # @param transform [Proc] applied to each offset's raw parsed Hash
200
+ def initialize(output, offsets, transform)
201
+ @output = output
202
+ @offsets = offsets
203
+ @transform = transform
204
+ end
205
+
206
+ # @yieldparam item [Object]
207
+ # @return [Enumerator] if no block is given
208
+ def each
209
+ return enum_for(:each) unless block_given?
210
+
211
+ preview.each { |item| yield item }
212
+ @offsets[preview.size..].each { |offset| yield transform_at(offset) }
213
+ end
214
+
215
+ # The number of messages, known upfront from {#index} - unlike
216
+ # +Enumerable#count+, doesn't read/transform a single one to answer.
217
+ #
218
+ # @return [Integer]
219
+ def size
220
+ @offsets.size
221
+ end
222
+ alias_method :length, :size
223
+
224
+ # @return [String]
225
+ def inspect
226
+ shown = preview.first(INSPECT_PREVIEW_SIZE).map(&:inspect).join(", ")
227
+ shown += ", ..." if preview.size > INSPECT_PREVIEW_SIZE
228
+ "[#{shown}]"
229
+ end
230
+
231
+ # @return [void]
232
+ def pretty_print(q)
233
+ q.group(1, "[", "]") do
234
+ q.seplist(preview.first(INSPECT_PREVIEW_SIZE)) { |item| q.pp item }
235
+ q.text ", ..." if preview.size > INSPECT_PREVIEW_SIZE
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ # @return [Array<Object>]
242
+ def preview
243
+ @preview ||= @offsets.first(INSPECT_PREVIEW_SIZE + 1).map { |offset| transform_at(offset) }
244
+ end
245
+
246
+ def transform_at(offset)
247
+ @transform.call(JSON.parse(@output.read_line_at(offset)))
248
+ end
249
+ end
93
250
  end
94
251
  end
data/lib/yobi/snapshot.rb CHANGED
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
- require "delegate"
5
4
 
6
5
  module Yobi
7
6
  # One snapshot, as Restic reports it. Shared across {Yobi::Repository#snapshots},
8
7
  # {Yobi::Repository#ls}, and {Yobi::Repository#forget}'s keep/remove entries.
9
- class Snapshot < SimpleDelegator
8
+ class Snapshot < Yobi::FancyHash
10
9
  # @return [String]
11
10
  def id
12
11
  self["id"]
@@ -41,5 +40,85 @@ module Yobi
41
40
  def parent_id
42
41
  self["parent"]
43
42
  end
43
+
44
+ # @return [Yobi::SnapshotSummary]
45
+ def summary
46
+ @summary ||= SnapshotSummary.new(self["summary"] || {})
47
+ end
48
+ end
49
+
50
+ # The `"summary"` field of a {Yobi::Snapshot}, the stats recorded when it
51
+ # was created.
52
+ # https://restic.readthedocs.io/en/stable/075_scripting.html#snapshotsummary-object
53
+ class SnapshotSummary < Yobi::FancyHash
54
+ # @return [Time, nil]
55
+ def backup_start
56
+ @backup_start ||= Time.parse(self["backup_start"]) if self["backup_start"]
57
+ end
58
+
59
+ # @return [Time, nil]
60
+ def backup_end
61
+ @backup_end ||= Time.parse(self["backup_end"]) if self["backup_end"]
62
+ end
63
+
64
+ # @return [Integer]
65
+ def files_new
66
+ self["files_new"] || 0
67
+ end
68
+
69
+ # @return [Integer]
70
+ def files_changed
71
+ self["files_changed"] || 0
72
+ end
73
+
74
+ # @return [Integer]
75
+ def files_unmodified
76
+ self["files_unmodified"] || 0
77
+ end
78
+
79
+ # @return [Integer]
80
+ def dirs_new
81
+ self["dirs_new"] || 0
82
+ end
83
+
84
+ # @return [Integer]
85
+ def dirs_changed
86
+ self["dirs_changed"] || 0
87
+ end
88
+
89
+ # @return [Integer]
90
+ def dirs_unmodified
91
+ self["dirs_unmodified"] || 0
92
+ end
93
+
94
+ # @return [Integer]
95
+ def data_blobs
96
+ self["data_blobs"] || 0
97
+ end
98
+
99
+ # @return [Integer]
100
+ def tree_blobs
101
+ self["tree_blobs"] || 0
102
+ end
103
+
104
+ # @return [Integer]
105
+ def data_added
106
+ self["data_added"] || 0
107
+ end
108
+
109
+ # @return [Integer]
110
+ def data_added_packed
111
+ self["data_added_packed"] || 0
112
+ end
113
+
114
+ # @return [Integer]
115
+ def total_files_processed
116
+ self["total_files_processed"] || 0
117
+ end
118
+
119
+ # @return [Integer]
120
+ def total_bytes_processed
121
+ self["total_bytes_processed"] || 0
122
+ end
44
123
  end
45
124
  end
data/lib/yobi/version.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # A Ruby library for the Restic backup tool, wrapping the `restic` CLI in
3
+ # A Ruby library for the Restic backup program, wrapping the `restic` CLI in
4
4
  # plain Ruby objects instead of shelling out to flags and raw JSON by hand.
5
5
  module Yobi
6
6
  # @return [String]
7
- VERSION = "0.1.0"
7
+ VERSION = "0.2.0"
8
8
  end
data/lib/yobi.rb CHANGED
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "yobi/version"
4
+ require_relative "yobi/fancy_hash"
4
5
  require_relative "yobi/errors"
5
6
  require_relative "yobi/argv_builder"
6
7
  require_relative "yobi/restic_output"
7
8
  require_relative "yobi/io_handle"
8
- require_relative "yobi/mount"
9
+ require_relative "yobi/mount_handle"
9
10
  require_relative "yobi/restic"
10
11
  require_relative "yobi/snapshot"
11
12
  require_relative "yobi/repository"