Merge branch 'master' into 2761-diagnostic-suite
[arvados.git] / sdk / ruby / lib / arvados / keep.rb
1 module Keep
2   class Locator
3     # A Locator is used to parse and manipulate Keep locator strings.
4     #
5     # Locators obey the following syntax:
6     #
7     #   locator      ::= address hint*
8     #   address      ::= digest size-hint
9     #   digest       ::= <32 hexadecimal digits>
10     #   size-hint    ::= "+" [0-9]+
11     #   hint         ::= "+" hint-type hint-content
12     #   hint-type    ::= [A-Z]
13     #   hint-content ::= [A-Za-z0-9@_-]+
14     #
15     # Individual hints may have their own required format:
16     #
17     #   sign-hint      ::= "+A" <40 lowercase hex digits> "@" sign-timestamp
18     #   sign-timestamp ::= <8 lowercase hex digits>
19     attr_reader :hash, :hints, :size
20
21     def initialize(hasharg, sizearg, hintarg)
22       @hash = hasharg
23       @size = sizearg
24       @hints = hintarg
25     end
26
27     # Locator.parse returns a Locator object parsed from the string tok.
28     # Returns nil if tok could not be parsed as a valid locator.
29     def self.parse(tok)
30       begin
31         Locator.parse!(tok)
32       rescue ArgumentError => e
33         nil
34       end
35     end
36
37     # Locator.parse! returns a Locator object parsed from the string tok,
38     # raising an ArgumentError if tok cannot be parsed.
39     def self.parse!(tok)
40       if tok.nil? or tok.empty?
41         raise ArgumentError.new "locator is nil or empty"
42       end
43
44       m = /^([[:xdigit:]]{32})(\+([[:digit:]]+))?(\+([[:upper:]][[:alnum:]+@_-]*))?$/.match(tok.strip)
45       unless m
46         raise ArgumentError.new "not a valid locator #{tok}"
47       end
48
49       tokhash, _, toksize, _, trailer = m[1..5]
50       tokhints = []
51       if trailer
52         trailer.split('+').each do |hint|
53           if hint =~ /^[[:upper:]][[:alnum:]@_-]+$/
54             tokhints.push(hint)
55           else
56             raise ArgumentError.new "unknown hint #{hint}"
57           end
58         end
59       end
60
61       Locator.new(tokhash, toksize, tokhints)
62     end
63
64     # Returns the signature hint supplied with this locator,
65     # or nil if the locator was not signed.
66     def signature
67       @hints.grep(/^A/).first
68     end
69
70     # Returns an unsigned Locator.
71     def without_signature
72       Locator.new(@hash, @size, @hints.reject { |o| o.start_with?("A") })
73     end
74
75     def strip_hints
76       Locator.new(@hash, @size, [])
77     end
78
79     def strip_hints!
80       @hints = []
81       self
82     end
83
84     def to_s
85       if @size
86         [ @hash, @size, *@hints ].join('+')
87       else
88         [ @hash, *@hints ].join('+')
89       end
90     end
91   end
92
93   class Manifest
94     # Class to parse a manifest text and provide common views of that data.
95     def initialize(manifest_text)
96       @text = manifest_text
97       @files = nil
98     end
99
100     def each_line
101       return to_enum(__method__) unless block_given?
102       @text.each_line do |line|
103         tokens = line.split
104         stream_name = unescape(tokens.shift)
105         blocks = []
106         while loc = Locator.parse(tokens.first)
107           blocks << loc
108           tokens.shift
109         end
110         yield [stream_name, blocks, tokens.map { |s| unescape(s) }]
111       end
112     end
113
114     def unescape(s)
115       # Parse backslash escapes in a Keep manifest stream or file name.
116       s.gsub(/\\(\\|[0-7]{3})/) do |_|
117         case $1
118         when '\\'
119           '\\'
120         else
121           $1.to_i(8).chr
122         end
123       end
124     end
125
126     def each_file_spec(speclist)
127       return to_enum(__method__, speclist) unless block_given?
128       speclist.each do |filespec|
129         start_pos, filesize, filename = filespec.split(':', 3)
130         yield [start_pos.to_i, filesize.to_i, filename]
131       end
132     end
133
134     def files
135       if @files.nil?
136         file_sizes = Hash.new(0)
137         each_line do |streamname, blocklist, filelist|
138           each_file_spec(filelist) do |_, filesize, filename|
139             file_sizes[[streamname, filename]] += filesize
140           end
141         end
142         @files = file_sizes.each_pair.map do |(streamname, filename), size|
143           [streamname, filename, size]
144         end
145       end
146       @files
147     end
148
149     def files_count(stop_after=nil)
150       # Return the number of files represented in this manifest.
151       # If stop_after is provided, files_count will read the manifest
152       # incrementally, and return immediately when it counts that number of
153       # files.  This can help you avoid parsing the entire manifest if you
154       # just want to check if a small number of files are specified.
155       if stop_after.nil? or not @files.nil?
156         return files.size
157       end
158       seen_files = {}
159       each_line do |streamname, blocklist, filelist|
160         each_file_spec(filelist) do |_, _, filename|
161           seen_files[[streamname, filename]] = true
162           return stop_after if (seen_files.size >= stop_after)
163         end
164       end
165       seen_files.size
166     end
167
168     def exact_file_count?(want_count)
169       files_count(want_count + 1) == want_count
170     end
171
172     def minimum_file_count?(want_count)
173       files_count(want_count) >= want_count
174     end
175
176     def has_file?(want_stream, want_file=nil)
177       if want_file.nil?
178         want_stream, want_file = File.split(want_stream)
179       end
180       each_line do |stream_name, _, filelist|
181         if (stream_name == want_stream) and
182             each_file_spec(filelist).any? { |_, _, name| name == want_file }
183           return true
184         end
185       end
186       false
187     end
188   end
189 end