gcs_put 0.1.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/sig/gcs_put.rbs ADDED
@@ -0,0 +1,345 @@
1
+ # The Ruby GCP SDK can only upload things it can measure in advance. GCS itself has supported
2
+ # resumable uploads of unknown size for ages though. This gem gives you a writable object
3
+ # which chops what you write to it into correctly sized chunks and PUTs them into a
4
+ # resumable upload session, so you never need to know the size up front.
5
+ module GCSPut
6
+ extend Forwardable
7
+ CHUNK_SIZE_UNIT: untyped
8
+ DEFAULT_CHUNK_SIZE: untyped
9
+ VERSION: untyped
10
+
11
+ # Base class for everything the gem raises
12
+ class Error < StandardError
13
+ end
14
+
15
+ # Raised by transports for failures worth retrying - connection resets, timeouts and the like
16
+ class TransientError < GCSPut::Error
17
+ end
18
+
19
+ # Raised when GCS answers a chunk PUT with something we cannot recover from
20
+ class UploadFailed < GCSPut::Error
21
+ # _@param_ `message`
22
+ #
23
+ # _@param_ `response`
24
+ def initialize: (String message, ?response: GCSPut::Transport::Response?) -> void
25
+
26
+ # _@return_ — the response which caused the failure, if there was one
27
+ attr_reader response: GCSPut::Transport::Response?
28
+ end
29
+
30
+ # Supplies the `issuer` and `signer` that `Google::Cloud::Storage::File#signed_url` needs
31
+ # when there is no private key around to sign with
32
+ module Signer
33
+ # When running on GCE, GKE, Cloud Run etc. under a service account there is no private key
34
+ # on the box to sign the URL with. The SDK then needs an `issuer` (the account email) and a
35
+ # `signer` lambda which asks the IAM credentials API to sign for us. For that to be allowed the
36
+ # service account must have `roles/iam.serviceAccountTokenCreator` on itself.
37
+ # Lifted from https://github.com/googleapis/google-cloud-ruby/issues/13307#issuecomment-1894546343
38
+ #
39
+ # _@return_ — either `{issuer:, signer:}` or an empty hash when not on compute engine
40
+ def self.url_issuer_and_signer: () -> ::Hash[untyped, untyped]
41
+
42
+ # _@param_ `service_account_email`
43
+ #
44
+ # _@return_ — a lambda which takes the string to sign and returns the signature
45
+ def self.iam_signer_for: (String service_account_email) -> Proc
46
+ end
47
+
48
+ # The uploader needs two HTTP verbs and does not care what performs them. A transport is any
49
+ # object with `put(uri, body, headers)`, `post(uri, body, headers)` and `close`, where the verbs
50
+ # return a `Transport::Response`. Failures worth retrying (connection resets, timeouts and the
51
+ # like) must surface as `GCSPut::TransientError` so the uploader knows it may query
52
+ # the session and carry on. Anything else is allowed to propagate.
53
+ module Transport
54
+ # What a transport hands back from `put` and `post`
55
+ class Response
56
+ # sord duck - #to_i looks like a duck type with an equivalent RBS interface, replacing with _ToI
57
+ # sord duck - #to_h looks like a duck type, replacing with untyped
58
+ # sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
59
+ # _@param_ `status`
60
+ #
61
+ # _@param_ `headers` — header names in any case
62
+ #
63
+ # _@param_ `body`
64
+ def initialize: (_ToI status, untyped headers, _ToS body) -> void
65
+
66
+ # _@param_ `name` — header name in any case
67
+ def []: (String name) -> String?
68
+
69
+ # _@return_ — the HTTP status code
70
+ attr_reader status: Integer
71
+
72
+ # _@return_ — the headers, with lowercased names
73
+ attr_reader headers: ::Hash[String, String]
74
+
75
+ # _@return_ — the body, empty if there was none
76
+ attr_reader body: String
77
+ end
78
+
79
+ # Uses a Faraday connection, so you can pick the adapter, timeouts and instrumentation yourself.
80
+ # Check the bytes arrive intact with whatever adapter you choose - we send a `Content-MD5` with
81
+ # every chunk precisely because httpx 1.4.0 used to mangle request bodies,
82
+ # see https://gitlab.com/os85/httpx/-/issues/338
83
+ class Faraday
84
+ TRANSIENT_ERRORS: untyped
85
+
86
+ # sord warn - Faraday::Connection wasn't able to be resolved to a constant in this project
87
+ # _@param_ `connection` — a connection of your own, or `nil` to get a default one
88
+ def initialize: (?Faraday::Connection? connection) -> void
89
+
90
+ # sord warn - URI::Generic wasn't able to be resolved to a constant in this project
91
+ # _@param_ `uri`
92
+ #
93
+ # _@param_ `body`
94
+ #
95
+ # _@param_ `headers`
96
+ def put: (URI::Generic uri, String body, ::Hash[String, String] headers) -> GCSPut::Transport::Response
97
+
98
+ # sord warn - URI::Generic wasn't able to be resolved to a constant in this project
99
+ # _@param_ `uri`
100
+ #
101
+ # _@param_ `body`
102
+ #
103
+ # _@param_ `headers`
104
+ def post: (URI::Generic uri, String body, ::Hash[String, String] headers) -> GCSPut::Transport::Response
105
+
106
+ # A connection you passed in is yours to close, we only close the one we made
107
+ def close: () -> void
108
+
109
+ # sord omit - no YARD type given for "verb", using untyped
110
+ # sord omit - no YARD type given for "uri", using untyped
111
+ # sord omit - no YARD type given for "body", using untyped
112
+ # sord omit - no YARD type given for "headers", using untyped
113
+ # sord omit - no YARD return type given, using untyped
114
+ def request: (
115
+ untyped verb,
116
+ untyped uri,
117
+ untyped body,
118
+ untyped headers
119
+ ) -> untyped
120
+ end
121
+
122
+ # Keeps one connection per host open for the duration of the upload
123
+ class NetHTTP
124
+ TRANSIENT_ERRORS: untyped
125
+
126
+ # _@param_ `http_options` — forwarded to `Net::HTTP.start` (`open_timeout:`, `read_timeout:`...)
127
+ def initialize: (**::Hash[untyped, untyped] http_options) -> void
128
+
129
+ # sord warn - URI::Generic wasn't able to be resolved to a constant in this project
130
+ # _@param_ `uri`
131
+ #
132
+ # _@param_ `body`
133
+ #
134
+ # _@param_ `headers`
135
+ def put: (URI::Generic uri, String body, ::Hash[String, String] headers) -> GCSPut::Transport::Response
136
+
137
+ # sord warn - URI::Generic wasn't able to be resolved to a constant in this project
138
+ # _@param_ `uri`
139
+ #
140
+ # _@param_ `body`
141
+ #
142
+ # _@param_ `headers`
143
+ def post: (URI::Generic uri, String body, ::Hash[String, String] headers) -> GCSPut::Transport::Response
144
+
145
+ # Closes the kept connections. Safe to call repeatedly, the next request reopens as needed
146
+ def close: () -> void
147
+
148
+ # sord omit - no YARD type given for "request_class", using untyped
149
+ # sord omit - no YARD type given for "uri", using untyped
150
+ # sord omit - no YARD type given for "body", using untyped
151
+ # sord omit - no YARD type given for "headers", using untyped
152
+ # sord omit - no YARD return type given, using untyped
153
+ def request: (
154
+ untyped request_class,
155
+ untyped uri,
156
+ untyped body,
157
+ untyped headers
158
+ ) -> untyped
159
+
160
+ # sord omit - no YARD type given for "uri", using untyped
161
+ # sord omit - no YARD return type given, using untyped
162
+ def connection_for: (untyped uri) -> untyped
163
+ end
164
+ end
165
+
166
+ # Chops an arbitrary stream of writes into evenly sized chunks. Every chunk except the last
167
+ # will be exactly `chunk_size` bytes, and the last one can be anything from 0 bytes up to and
168
+ # including `chunk_size`. A chunk which fills up exactly is held back until `finish` so that
169
+ # a stream ending on a chunk boundary still delivers that chunk flagged as the last one.
170
+ #
171
+ # chunker = ByteChunker.new(chunk_size: 3) { |bytes, is_last| puts [bytes, is_last].inspect }
172
+ # chunker << "ab" << "cdefg" # => ["abc", false], ["def", false]
173
+ # chunker.finish # => ["g", true]
174
+ class ByteChunker
175
+ # _@param_ `chunk_size` — the size that every chunk except the last must have
176
+ def initialize: (chunk_size: Integer) -> void
177
+
178
+ # _@param_ `bin_str` — the bytes to append
179
+ def <<: (String bin_str) -> self
180
+
181
+ # _@param_ `bin_str` — the bytes to append
182
+ #
183
+ # _@return_ — the number of bytes appended, like `IO#write`
184
+ def write: (String bin_str) -> Integer
185
+
186
+ # Delivers whatever is left in the buffer as the last chunk. The last chunk
187
+ # gets delivered even when empty - it is what closes the upload
188
+ def finish: () -> void
189
+
190
+ def deliver_full_chunks: () -> void
191
+ end
192
+
193
+ # A writable object which streams what you write into a GCS resumable upload session using
194
+ # ranged PUTs, see https://cloud.google.com/storage/docs/performing-resumable-uploads#chunked-upload
195
+ # You do not need to know the size of the output in advance. You do need to `finish` the upload,
196
+ # since it is the last PUT (with the total size filled in) which closes the GCS object.
197
+ #
198
+ # gcs_file = bucket.file("upload.bin", skip_lookup: true)
199
+ # GCSPut.with_gcs_file(gcs_file) do |io|
200
+ # io.write("Hello resumable")
201
+ # 20.times { io.write(Random.bytes(1024 * 1024)) }
202
+ # end
203
+ #
204
+ # Every chunk is retried from the byte offset GCS reports as persisted, so a chunk which
205
+ # only partially made it over the wire gets topped up rather than resent from the start.
206
+ #
207
+ # Starting the session needs a signed POST URL, and signing needs a private key. Under
208
+ # workload identity there is no key, so we fall back to the IAM signBlob API - which means
209
+ # the service account must hold `roles/iam.serviceAccountTokenCreator` on itself. See
210
+ # https://github.com/googleapis/google-cloud-ruby/issues/13307
211
+ class ResumableUpload
212
+ extend Forwardable
213
+
214
+ # Appends bytes to the upload, sending out a chunk whenever one fills up
215
+ #
216
+ # _@param_ `bin_str`
217
+ #
218
+ # _@return_ — the number of bytes appended, like `IO#write`
219
+ def write: (String bin_str) -> Integer
220
+
221
+ # Appends bytes to the upload, sending out a chunk whenever one fills up
222
+ #
223
+ # _@param_ `bin_str`
224
+ def <<: (String bin_str) -> self
225
+
226
+ # sord warn - Google::Cloud::Storage::File wasn't able to be resolved to a constant in this project
227
+ # sord duck - #put looks like a duck type, replacing with untyped
228
+ # sord duck - #post looks like a duck type, replacing with untyped
229
+ # sord duck - #close looks like a duck type, replacing with untyped
230
+ # Signs a session start URL for the object, starts the session and returns the upload.
231
+ # With a block, yields the upload, finishes it once the block returns and returns the total size.
232
+ #
233
+ # _@param_ `gcs_file` — the object to upload into, does not need to exist yet
234
+ #
235
+ # _@param_ `content_type` — the content type of the resulting object
236
+ #
237
+ # _@param_ `transport` — see `GCSPut::Transport`
238
+ #
239
+ # _@param_ `signed_url_options` — passed to `gcs_file.signed_url`, see `Signer.url_issuer_and_signer`
240
+ #
241
+ # _@param_ `options` — see {#initialize}
242
+ #
243
+ # _@return_ — the upload, or the total size when given a block
244
+ def self.with_gcs_file: (
245
+ Google::Cloud::Storage::File gcs_file,
246
+ ?content_type: String,
247
+ ?transport: untyped,
248
+ ?signed_url_options: ::Hash[untyped, untyped],
249
+ **::Hash[untyped, untyped] options
250
+ ) -> (GCSPut::ResumableUpload | Integer)
251
+
252
+ # sord duck - #put looks like a duck type, replacing with untyped
253
+ # sord duck - #post looks like a duck type, replacing with untyped
254
+ # sord duck - #close looks like a duck type, replacing with untyped
255
+ # Starts a session from a signed POST URL (one with `x-goog-resumable: start` among its signed headers)
256
+ # and returns the upload, see https://cloud.google.com/storage/docs/performing-resumable-uploads#initiate-session
257
+ # With a block, yields the upload, finishes it once the block returns and returns the total size.
258
+ #
259
+ # _@param_ `signed_post_url`
260
+ #
261
+ # _@param_ `content_type` — must match the content type the URL was signed with
262
+ #
263
+ # _@param_ `transport` — see `GCSPut::Transport`
264
+ #
265
+ # _@param_ `options` — see {#initialize}
266
+ #
267
+ # _@return_ — the upload, or the total size when given a block
268
+ def self.with_signed_post_url: (
269
+ String signed_post_url,
270
+ ?content_type: String,
271
+ ?transport: untyped,
272
+ **::Hash[untyped, untyped] options
273
+ ) -> (GCSPut::ResumableUpload | Integer)
274
+
275
+ # Wraps an already started session. With a block, yields the upload, finishes it once
276
+ # the block returns and returns the total size.
277
+ #
278
+ # _@param_ `session_url` — the `Location` returned by the session start
279
+ #
280
+ # _@param_ `options` — see {#initialize}
281
+ #
282
+ # _@return_ — the upload, or the total size when given a block
283
+ def self.with_session_url: (String session_url, **::Hash[untyped, untyped] options) -> (GCSPut::ResumableUpload | Integer)
284
+
285
+ # sord duck - #put looks like a duck type, replacing with untyped
286
+ # sord duck - #post looks like a duck type, replacing with untyped
287
+ # sord duck - #close looks like a duck type, replacing with untyped
288
+ # Prefer the `with_*` factories. This does no HTTP by itself, the first request goes out
289
+ # once a chunk fills up or `finish` gets called
290
+ #
291
+ # _@param_ `session_url` — the `Location` returned by the session start
292
+ #
293
+ # _@param_ `chunk_size` — must be a multiple of 256 KiB
294
+ #
295
+ # _@param_ `content_type` — must match the content type the session was started with
296
+ #
297
+ # _@param_ `max_attempts` — how many times a single chunk may be sent before giving up
298
+ #
299
+ # _@param_ `transport` — see `GCSPut::Transport`
300
+ def initialize: (
301
+ String session_url,
302
+ ?chunk_size: Integer,
303
+ ?content_type: String,
304
+ ?max_attempts: Integer,
305
+ ?transport: untyped
306
+ ) -> void
307
+
308
+ # Sends the remaining buffered bytes as the final chunk and closes the GCS object.
309
+ # Also available as `close` so that writers which close their underlying IO, like
310
+ # `Zlib::GzipWriter`, finish the upload for you
311
+ #
312
+ # _@return_ — the total number of bytes uploaded
313
+ def finish: () -> Integer
314
+
315
+ # _@param_ `chunk`
316
+ #
317
+ # _@param_ `is_last`
318
+ def upload_chunk: (String chunk, bool is_last) -> void
319
+
320
+ # _@param_ `body`
321
+ #
322
+ # _@param_ `from`
323
+ #
324
+ # _@param_ `total` — the total size, or "*" while still unknown
325
+ def put_bytes: (String body, from: Integer, total: (Integer | String)) -> GCSPut::Transport::Response
326
+
327
+ # Asks GCS how much of the upload it has and updates `bytes_persisted` accordingly, see
328
+ # https://cloud.google.com/storage/docs/performing-resumable-uploads#status-check
329
+ # A status check which fails itself just leaves us with what we already knew
330
+ #
331
+ # _@return_ — whether the session turned out to be finalized already
332
+ def sync_with_session_finds_it_finalized?: () -> bool
333
+
334
+ # The `Range` header is "bytes=0-N" with N being the last persisted byte, and absent if nothing persisted yet
335
+ #
336
+ # _@param_ `response`
337
+ def persisted_offset_from: (GCSPut::Transport::Response response) -> Integer
338
+
339
+ # _@return_ — the session URL, valid for a week and usable from any process
340
+ attr_reader session_url: String
341
+
342
+ # _@return_ — the number of bytes GCS has confirmed as persisted so far
343
+ attr_reader bytes_persisted: Integer
344
+ end
345
+ end
metadata ADDED
@@ -0,0 +1,206 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gcs_put
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-10-05 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: google-cloud-storage
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.40'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.40'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webmock
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: faraday
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: magic_frozen_string_literal
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: standard
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 1.35.1
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 1.35.1
110
+ - !ruby/object:Gem::Dependency
111
+ name: yard
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.9'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.9'
124
+ - !ruby/object:Gem::Dependency
125
+ name: sord
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: redcarpet
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ description: Gives you a writable IO-like object which streams what you write into
153
+ it to a GCS object using the resumable upload protocol over a signed URL. The size
154
+ of the upload does not need to be known beforehand.
155
+ email:
156
+ - me@julik.nl
157
+ executables: []
158
+ extensions: []
159
+ extra_rdoc_files: []
160
+ files:
161
+ - ".gitignore"
162
+ - ".standard.yml"
163
+ - ".yardopts"
164
+ - CHANGELOG.md
165
+ - Gemfile
166
+ - LICENSE
167
+ - README.md
168
+ - Rakefile
169
+ - gcs_put.gemspec
170
+ - lib/gcs_put.rb
171
+ - lib/gcs_put/byte_chunker.rb
172
+ - lib/gcs_put/resumable_upload.rb
173
+ - lib/gcs_put/signer.rb
174
+ - lib/gcs_put/transport.rb
175
+ - lib/gcs_put/transport/faraday.rb
176
+ - lib/gcs_put/transport/net_http.rb
177
+ - lib/gcs_put/version.rb
178
+ - rbi/gcs_put.rbi
179
+ - sig/gcs_put.rbs
180
+ homepage: https://github.com/julik/gcs_put
181
+ licenses:
182
+ - MIT
183
+ metadata:
184
+ homepage_uri: https://github.com/julik/gcs_put
185
+ source_code_uri: https://github.com/julik/gcs_put
186
+ changelog_uri: https://github.com/julik/gcs_put/blob/main/CHANGELOG.md
187
+ allowed_push_host: https://rubygems.org
188
+ rdoc_options: []
189
+ require_paths:
190
+ - lib
191
+ required_ruby_version: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: 3.2.0
196
+ required_rubygems_version: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - ">="
199
+ - !ruby/object:Gem::Version
200
+ version: '0'
201
+ requirements: []
202
+ rubygems_version: 3.6.2
203
+ specification_version: 4
204
+ summary: Streaming resumable uploads to Google Cloud Storage without knowing the size
205
+ in advance.
206
+ test_files: []