Merge branch '5104-ruby-sdk-collections-wip'
[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     LOCATOR_REGEXP = /^([[:xdigit:]]{32})(\+([[:digit:]]+))?(\+([[:upper:]][[:alnum:]+@_-]*))?$/
22
23     def initialize(hasharg, sizearg, hintarg)
24       @hash = hasharg
25       @size = sizearg
26       @hints = hintarg
27     end
28
29     def self.valid? tok
30       !!(LOCATOR_REGEXP.match tok)
31     end
32
33     # Locator.parse returns a Locator object parsed from the string tok.
34     # Returns nil if tok could not be parsed as a valid locator.
35     def self.parse(tok)
36       begin
37         Locator.parse!(tok)
38       rescue ArgumentError => e
39         nil
40       end
41     end
42
43     # Locator.parse! returns a Locator object parsed from the string tok,
44     # raising an ArgumentError if tok cannot be parsed.
45     def self.parse!(tok)
46       if tok.nil? or tok.empty?
47         raise ArgumentError.new "locator is nil or empty"
48       end
49
50       m = LOCATOR_REGEXP.match(tok.strip)
51       unless m
52         raise ArgumentError.new "not a valid locator #{tok}"
53       end
54
55       tokhash, _, toksize, _, trailer = m[1..5]
56       tokhints = []
57       if trailer
58         trailer.split('+').each do |hint|
59           if hint =~ /^[[:upper:]][[:alnum:]@_-]+$/
60             tokhints.push(hint)
61           else
62             raise ArgumentError.new "unknown hint #{hint}"
63           end
64         end
65       end
66
67       Locator.new(tokhash, toksize, tokhints)
68     end
69
70     # Returns the signature hint supplied with this locator,
71     # or nil if the locator was not signed.
72     def signature
73       @hints.grep(/^A/).first
74     end
75
76     # Returns an unsigned Locator.
77     def without_signature
78       Locator.new(@hash, @size, @hints.reject { |o| o.start_with?("A") })
79     end
80
81     def strip_hints
82       Locator.new(@hash, @size, [])
83     end
84
85     def strip_hints!
86       @hints = []
87       self
88     end
89
90     def to_s
91       if @size
92         [ @hash, @size, *@hints ].join('+')
93       else
94         [ @hash, *@hints ].join('+')
95       end
96     end
97   end
98
99   class Manifest
100     # Class to parse a manifest text and provide common views of that data.
101     def initialize(manifest_text)
102       @text = manifest_text
103       @files = nil
104     end
105
106     def each_line
107       return to_enum(__method__) unless block_given?
108       @text.each_line do |line|
109         stream_name = nil
110         block_tokens = []
111         file_tokens = []
112         line.scan /\S+/ do |token|
113           if stream_name.nil?
114             stream_name = unescape token
115           elsif file_tokens.empty? and Locator.valid? token
116             block_tokens << token
117           else
118             file_tokens << unescape(token)
119           end
120         end
121         # Ignore blank lines
122         next if stream_name.nil?
123         yield [stream_name, block_tokens, file_tokens]
124       end
125     end
126
127     def unescape(s)
128       # Parse backslash escapes in a Keep manifest stream or file name.
129       s.gsub(/\\(\\|[0-7]{3})/) do |_|
130         case $1
131         when '\\'
132           '\\'
133         else
134           $1.to_i(8).chr
135         end
136       end
137     end
138
139     def split_file_token token
140       start_pos, filesize, filename = token.split(':', 3)
141       if filename.nil?
142         raise ArgumentError.new "Invalid file token '#{token}'"
143       end
144       [start_pos.to_i, filesize.to_i, unescape(filename)]
145     end
146
147     def each_file_spec
148       return to_enum(__method__) unless block_given?
149       @text.each_line do |line|
150         stream_name = nil
151         in_file_tokens = false
152         line.scan /\S+/ do |token|
153           if stream_name.nil?
154             stream_name = unescape token
155           elsif in_file_tokens or not Locator.valid? token
156             in_file_tokens = true
157             yield [stream_name] + split_file_token(token)
158           end
159         end
160       end
161       true
162     end
163
164     def files
165       if @files.nil?
166         file_sizes = Hash.new(0)
167         each_file_spec do |streamname, _, filesize, filename|
168           file_sizes[[streamname, filename]] += filesize
169         end
170         @files = file_sizes.each_pair.map do |(streamname, filename), size|
171           [streamname, filename, size]
172         end
173       end
174       @files
175     end
176
177     def files_count(stop_after=nil)
178       # Return the number of files represented in this manifest.
179       # If stop_after is provided, files_count will read the manifest
180       # incrementally, and return immediately when it counts that number of
181       # files.  This can help you avoid parsing the entire manifest if you
182       # just want to check if a small number of files are specified.
183       if stop_after.nil? or not @files.nil?
184         return files.size
185       end
186       seen_files = {}
187       each_file_spec do |streamname, _, _, filename|
188         seen_files[[streamname, filename]] = true
189         return stop_after if (seen_files.size >= stop_after)
190       end
191       seen_files.size
192     end
193
194     def exact_file_count?(want_count)
195       files_count(want_count + 1) == want_count
196     end
197
198     def minimum_file_count?(want_count)
199       files_count(want_count) >= want_count
200     end
201
202     def has_file?(want_stream, want_file=nil)
203       if want_file.nil?
204         want_stream, want_file = File.split(want_stream)
205       end
206       each_file_spec do |streamname, _, _, name|
207         if streamname == want_stream and name == want_file
208           return true
209         end
210       end
211       false
212     end
213   end
214 end