redis-client 0.1.0 → 0.3.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +19 -0
- data/Gemfile.lock +2 -2
- data/README.md +89 -3
- data/Rakefile +15 -6
- data/ext/redis_client/hiredis/export.clang +2 -0
- data/ext/redis_client/hiredis/export.gcc +7 -0
- data/ext/redis_client/hiredis/extconf.rb +24 -9
- data/ext/redis_client/hiredis/hiredis_connection.c +13 -1
- data/lib/redis_client/command_builder.rb +83 -0
- data/lib/redis_client/config.rb +9 -48
- data/lib/redis_client/connection_mixin.rb +38 -0
- data/lib/redis_client/decorator.rb +84 -0
- data/lib/redis_client/hiredis_connection.rb +16 -1
- data/lib/redis_client/middlewares.rb +12 -0
- data/lib/redis_client/pooled.rb +55 -35
- data/lib/redis_client/ruby_connection/buffered_io.rb +153 -0
- data/lib/redis_client/{resp3.rb → ruby_connection/resp3.rb} +1 -27
- data/lib/redis_client/{connection.rb → ruby_connection.rb} +51 -5
- data/lib/redis_client/version.rb +1 -1
- data/lib/redis_client.rb +239 -106
- metadata +11 -5
- data/lib/redis_client/buffered_io.rb +0 -149
data/lib/redis_client.rb
CHANGED
@@ -1,11 +1,86 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "set"
|
4
|
+
|
3
5
|
require "redis_client/version"
|
6
|
+
require "redis_client/command_builder"
|
4
7
|
require "redis_client/config"
|
5
8
|
require "redis_client/sentinel_config"
|
6
|
-
require "redis_client/
|
9
|
+
require "redis_client/middlewares"
|
7
10
|
|
8
11
|
class RedisClient
|
12
|
+
@driver_definitions = {}
|
13
|
+
@drivers = {}
|
14
|
+
|
15
|
+
@default_driver = nil
|
16
|
+
|
17
|
+
class << self
|
18
|
+
def register_driver(name, &block)
|
19
|
+
@driver_definitions[name] = block
|
20
|
+
end
|
21
|
+
|
22
|
+
def driver(name)
|
23
|
+
return name if name.is_a?(Class)
|
24
|
+
|
25
|
+
name = name.to_sym
|
26
|
+
unless @driver_definitions.key?(name)
|
27
|
+
raise ArgumentError, "Unknown driver #{name.inspect}, expected one of: `#{DRIVER_DEFINITIONS.keys.inspect}`"
|
28
|
+
end
|
29
|
+
|
30
|
+
@drivers[name] ||= @driver_definitions[name]&.call
|
31
|
+
end
|
32
|
+
|
33
|
+
def default_driver
|
34
|
+
unless @default_driver
|
35
|
+
@driver_definitions.each_key do |name|
|
36
|
+
if @default_driver = driver(name)
|
37
|
+
break
|
38
|
+
end
|
39
|
+
rescue LoadError
|
40
|
+
end
|
41
|
+
end
|
42
|
+
@default_driver
|
43
|
+
end
|
44
|
+
|
45
|
+
def default_driver=(name)
|
46
|
+
@default_driver = driver(name)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
register_driver :hiredis do
|
51
|
+
require "redis_client/hiredis_connection"
|
52
|
+
HiredisConnection
|
53
|
+
end
|
54
|
+
|
55
|
+
register_driver :ruby do
|
56
|
+
require "redis_client/ruby_connection"
|
57
|
+
RubyConnection
|
58
|
+
end
|
59
|
+
|
60
|
+
module Common
|
61
|
+
attr_reader :config, :id
|
62
|
+
attr_accessor :connect_timeout, :read_timeout, :write_timeout
|
63
|
+
|
64
|
+
def initialize(
|
65
|
+
config,
|
66
|
+
id: config.id,
|
67
|
+
connect_timeout: config.connect_timeout,
|
68
|
+
read_timeout: config.read_timeout,
|
69
|
+
write_timeout: config.write_timeout
|
70
|
+
)
|
71
|
+
@config = config
|
72
|
+
@id = id
|
73
|
+
@connect_timeout = connect_timeout
|
74
|
+
@read_timeout = read_timeout
|
75
|
+
@write_timeout = write_timeout
|
76
|
+
@command_builder = config.command_builder
|
77
|
+
end
|
78
|
+
|
79
|
+
def timeout=(timeout)
|
80
|
+
@connect_timeout = @read_timeout = @write_timeout = timeout
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
9
84
|
Error = Class.new(StandardError)
|
10
85
|
|
11
86
|
ConnectionError = Class.new(Error)
|
@@ -30,10 +105,14 @@ class RedisClient
|
|
30
105
|
|
31
106
|
AuthenticationError = Class.new(CommandError)
|
32
107
|
PermissionError = Class.new(CommandError)
|
108
|
+
ReadOnlyError = Class.new(CommandError)
|
109
|
+
WrongTypeError = Class.new(CommandError)
|
33
110
|
|
34
111
|
CommandError::ERRORS = {
|
35
112
|
"WRONGPASS" => AuthenticationError,
|
36
113
|
"NOPERM" => PermissionError,
|
114
|
+
"READONLY" => ReadOnlyError,
|
115
|
+
"WRONGTYPE" => WrongTypeError,
|
37
116
|
}.freeze
|
38
117
|
|
39
118
|
class << self
|
@@ -52,23 +131,16 @@ class RedisClient
|
|
52
131
|
super(config(**(arg || {}), **kwargs))
|
53
132
|
end
|
54
133
|
end
|
134
|
+
|
135
|
+
def register(middleware)
|
136
|
+
Middlewares.extend(middleware)
|
137
|
+
end
|
55
138
|
end
|
56
139
|
|
57
|
-
|
58
|
-
attr_accessor :connect_timeout, :read_timeout, :write_timeout
|
140
|
+
include Common
|
59
141
|
|
60
|
-
def initialize(
|
61
|
-
|
62
|
-
id: config.id,
|
63
|
-
connect_timeout: config.connect_timeout,
|
64
|
-
read_timeout: config.read_timeout,
|
65
|
-
write_timeout: config.write_timeout
|
66
|
-
)
|
67
|
-
@config = config
|
68
|
-
@id = id
|
69
|
-
@connect_timeout = connect_timeout
|
70
|
-
@read_timeout = read_timeout
|
71
|
-
@write_timeout = write_timeout
|
142
|
+
def initialize(config, **)
|
143
|
+
super
|
72
144
|
@raw_connection = nil
|
73
145
|
@disable_reconnection = false
|
74
146
|
end
|
@@ -83,86 +155,104 @@ class RedisClient
|
|
83
155
|
alias_method :then, :with
|
84
156
|
|
85
157
|
def timeout=(timeout)
|
86
|
-
|
158
|
+
super
|
159
|
+
raw_connection.read_timeout = raw_connection.write_timeout = timeout if connected?
|
160
|
+
end
|
161
|
+
|
162
|
+
def read_timeout=(timeout)
|
163
|
+
super
|
164
|
+
raw_connection.read_timeout = timeout if connected?
|
165
|
+
end
|
166
|
+
|
167
|
+
def write_timeout=(timeout)
|
168
|
+
super
|
169
|
+
raw_connection.write_timeout = timeout if connected?
|
87
170
|
end
|
88
171
|
|
89
172
|
def pubsub
|
90
|
-
sub = PubSub.new(ensure_connected)
|
173
|
+
sub = PubSub.new(ensure_connected, @command_builder)
|
91
174
|
@raw_connection = nil
|
92
175
|
sub
|
93
176
|
end
|
94
177
|
|
95
|
-
def call(*command)
|
96
|
-
command =
|
178
|
+
def call(*command, **kwargs)
|
179
|
+
command = @command_builder.generate!(command, kwargs)
|
97
180
|
result = ensure_connected do |connection|
|
98
|
-
|
99
|
-
|
181
|
+
Middlewares.call(command, config) do
|
182
|
+
connection.call(command, nil)
|
183
|
+
end
|
100
184
|
end
|
101
185
|
|
102
|
-
if
|
103
|
-
|
186
|
+
if block_given?
|
187
|
+
yield result
|
104
188
|
else
|
105
189
|
result
|
106
190
|
end
|
107
191
|
end
|
108
192
|
|
109
|
-
def call_once(*command)
|
110
|
-
command =
|
193
|
+
def call_once(*command, **kwargs)
|
194
|
+
command = @command_builder.generate!(command, kwargs)
|
111
195
|
result = ensure_connected(retryable: false) do |connection|
|
112
|
-
|
113
|
-
|
196
|
+
Middlewares.call(command, config) do
|
197
|
+
connection.call(command, nil)
|
198
|
+
end
|
114
199
|
end
|
115
200
|
|
116
|
-
if
|
117
|
-
|
201
|
+
if block_given?
|
202
|
+
yield result
|
118
203
|
else
|
119
204
|
result
|
120
205
|
end
|
121
206
|
end
|
122
207
|
|
123
|
-
def blocking_call(timeout, *command)
|
124
|
-
command =
|
208
|
+
def blocking_call(timeout, *command, **kwargs)
|
209
|
+
command = @command_builder.generate!(command, kwargs)
|
125
210
|
result = ensure_connected do |connection|
|
126
|
-
|
127
|
-
|
211
|
+
Middlewares.call(command, config) do
|
212
|
+
connection.call(command, timeout)
|
213
|
+
end
|
128
214
|
end
|
129
215
|
|
130
|
-
if
|
131
|
-
|
216
|
+
if block_given?
|
217
|
+
yield result
|
132
218
|
else
|
133
219
|
result
|
134
220
|
end
|
135
221
|
end
|
136
222
|
|
137
|
-
def scan(*args, &block)
|
223
|
+
def scan(*args, **kwargs, &block)
|
138
224
|
unless block_given?
|
139
|
-
return to_enum(__callee__, *args)
|
225
|
+
return to_enum(__callee__, *args, **kwargs)
|
140
226
|
end
|
141
227
|
|
228
|
+
args = @command_builder.generate!(args, kwargs)
|
142
229
|
scan_list(1, ["SCAN", 0, *args], &block)
|
143
230
|
end
|
144
231
|
|
145
|
-
def sscan(key, *args, &block)
|
232
|
+
def sscan(key, *args, **kwargs, &block)
|
146
233
|
unless block_given?
|
147
|
-
return to_enum(__callee__, key, *args)
|
234
|
+
return to_enum(__callee__, key, *args, **kwargs)
|
148
235
|
end
|
149
236
|
|
237
|
+
args = @command_builder.generate!(args, kwargs)
|
150
238
|
scan_list(2, ["SSCAN", key, 0, *args], &block)
|
151
239
|
end
|
152
240
|
|
153
|
-
def hscan(key, *args, &block)
|
241
|
+
def hscan(key, *args, **kwargs, &block)
|
154
242
|
unless block_given?
|
155
|
-
return to_enum(__callee__, key, *args)
|
243
|
+
return to_enum(__callee__, key, *args, **kwargs)
|
156
244
|
end
|
157
245
|
|
246
|
+
args = @command_builder.generate!(args, kwargs)
|
158
247
|
scan_pairs(2, ["HSCAN", key, 0, *args], &block)
|
159
248
|
end
|
160
249
|
|
161
|
-
def zscan(key, *args, &block)
|
250
|
+
def zscan(key, *args, **kwargs, &block)
|
162
251
|
unless block_given?
|
163
|
-
return to_enum(__callee__, key, *args)
|
252
|
+
return to_enum(__callee__, key, *args, **kwargs)
|
164
253
|
end
|
165
254
|
|
255
|
+
args = @command_builder.generate!(args, kwargs)
|
166
256
|
scan_pairs(2, ["ZSCAN", key, 0, *args], &block)
|
167
257
|
end
|
168
258
|
|
@@ -177,27 +267,37 @@ class RedisClient
|
|
177
267
|
end
|
178
268
|
|
179
269
|
def pipelined
|
180
|
-
pipeline = Pipeline.new
|
270
|
+
pipeline = Pipeline.new(@command_builder)
|
181
271
|
yield pipeline
|
182
272
|
|
183
273
|
if pipeline._size == 0
|
184
274
|
[]
|
185
275
|
else
|
186
|
-
ensure_connected(retryable: pipeline._retryable?) do |connection|
|
187
|
-
|
276
|
+
results = ensure_connected(retryable: pipeline._retryable?) do |connection|
|
277
|
+
commands = pipeline._commands
|
278
|
+
Middlewares.call_pipelined(commands, config) do
|
279
|
+
connection.call_pipelined(commands, pipeline._timeouts)
|
280
|
+
end
|
188
281
|
end
|
282
|
+
|
283
|
+
pipeline._coerce!(results)
|
189
284
|
end
|
190
285
|
end
|
191
286
|
|
192
287
|
def multi(watch: nil, &block)
|
193
|
-
|
288
|
+
transaction = nil
|
289
|
+
|
290
|
+
results = if watch
|
194
291
|
# WATCH is stateful, so we can't reconnect if it's used, the whole transaction
|
195
292
|
# has to be redone.
|
196
293
|
ensure_connected(retryable: false) do |connection|
|
197
294
|
call("WATCH", *watch)
|
198
295
|
begin
|
199
296
|
if transaction = build_transaction(&block)
|
200
|
-
|
297
|
+
commands = transaction._commands
|
298
|
+
results = Middlewares.call_pipelined(commands, config) do
|
299
|
+
connection.call_pipelined(commands, nil)
|
300
|
+
end.last
|
201
301
|
else
|
202
302
|
call("UNWATCH")
|
203
303
|
[]
|
@@ -213,19 +313,29 @@ class RedisClient
|
|
213
313
|
[]
|
214
314
|
else
|
215
315
|
ensure_connected(retryable: transaction._retryable?) do |connection|
|
216
|
-
|
316
|
+
commands = transaction._commands
|
317
|
+
Middlewares.call_pipelined(commands, config) do
|
318
|
+
connection.call_pipelined(commands, nil)
|
319
|
+
end.last
|
217
320
|
end
|
218
321
|
end
|
219
322
|
end
|
323
|
+
|
324
|
+
if transaction
|
325
|
+
transaction._coerce!(results)
|
326
|
+
else
|
327
|
+
results
|
328
|
+
end
|
220
329
|
end
|
221
330
|
|
222
331
|
class PubSub
|
223
|
-
def initialize(raw_connection)
|
332
|
+
def initialize(raw_connection, command_builder)
|
224
333
|
@raw_connection = raw_connection
|
334
|
+
@command_builder = command_builder
|
225
335
|
end
|
226
336
|
|
227
|
-
def call(*command)
|
228
|
-
raw_connection.write(
|
337
|
+
def call(*command, **kwargs)
|
338
|
+
raw_connection.write(@command_builder.generate!(command, kwargs))
|
229
339
|
nil
|
230
340
|
end
|
231
341
|
|
@@ -251,20 +361,26 @@ class RedisClient
|
|
251
361
|
end
|
252
362
|
|
253
363
|
class Multi
|
254
|
-
def initialize
|
364
|
+
def initialize(command_builder)
|
365
|
+
@command_builder = command_builder
|
255
366
|
@size = 0
|
256
367
|
@commands = []
|
368
|
+
@blocks = nil
|
257
369
|
@retryable = true
|
258
370
|
end
|
259
371
|
|
260
|
-
def call(*command)
|
261
|
-
|
372
|
+
def call(*command, **kwargs, &block)
|
373
|
+
command = @command_builder.generate!(command, kwargs)
|
374
|
+
(@blocks ||= [])[@commands.size] = block if block_given?
|
375
|
+
@commands << command
|
262
376
|
nil
|
263
377
|
end
|
264
378
|
|
265
|
-
def call_once(*command)
|
379
|
+
def call_once(*command, **kwargs)
|
380
|
+
command = @command_builder.generate!(command, kwargs)
|
266
381
|
@retryable = false
|
267
|
-
@commands
|
382
|
+
(@blocks ||= [])[@commands.size] = block if block_given?
|
383
|
+
@commands << command
|
268
384
|
nil
|
269
385
|
end
|
270
386
|
|
@@ -272,6 +388,10 @@ class RedisClient
|
|
272
388
|
@commands
|
273
389
|
end
|
274
390
|
|
391
|
+
def _blocks
|
392
|
+
@blocks
|
393
|
+
end
|
394
|
+
|
275
395
|
def _size
|
276
396
|
@commands.size
|
277
397
|
end
|
@@ -287,18 +407,38 @@ class RedisClient
|
|
287
407
|
def _retryable?
|
288
408
|
@retryable
|
289
409
|
end
|
410
|
+
|
411
|
+
def _coerce!(results)
|
412
|
+
if results
|
413
|
+
results.each do |result|
|
414
|
+
if result.is_a?(CommandError)
|
415
|
+
raise result
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
@blocks&.each_with_index do |block, index|
|
420
|
+
if block
|
421
|
+
results[index - 1] = block.call(results[index - 1])
|
422
|
+
end
|
423
|
+
end
|
424
|
+
end
|
425
|
+
|
426
|
+
results
|
427
|
+
end
|
290
428
|
end
|
291
429
|
|
292
430
|
class Pipeline < Multi
|
293
|
-
def initialize
|
431
|
+
def initialize(_command_builder)
|
294
432
|
super
|
295
433
|
@timeouts = nil
|
296
434
|
end
|
297
435
|
|
298
|
-
def blocking_call(timeout, *command)
|
436
|
+
def blocking_call(timeout, *command, **kwargs)
|
437
|
+
command = @command_builder.generate!(command, kwargs)
|
299
438
|
@timeouts ||= []
|
300
439
|
@timeouts[@commands.size] = timeout
|
301
|
-
@commands
|
440
|
+
(@blocks ||= [])[@commands.size] = block if block_given?
|
441
|
+
@commands << command
|
302
442
|
nil
|
303
443
|
end
|
304
444
|
|
@@ -309,12 +449,24 @@ class RedisClient
|
|
309
449
|
def _empty?
|
310
450
|
@commands.empty?
|
311
451
|
end
|
452
|
+
|
453
|
+
def _coerce!(results)
|
454
|
+
return results unless results
|
455
|
+
|
456
|
+
@blocks&.each_with_index do |block, index|
|
457
|
+
if block
|
458
|
+
results[index] = block.call(results[index])
|
459
|
+
end
|
460
|
+
end
|
461
|
+
|
462
|
+
results
|
463
|
+
end
|
312
464
|
end
|
313
465
|
|
314
466
|
private
|
315
467
|
|
316
468
|
def build_transaction
|
317
|
-
transaction = Multi.new
|
469
|
+
transaction = Multi.new(@command_builder)
|
318
470
|
transaction.call("MULTI")
|
319
471
|
yield transaction
|
320
472
|
transaction.call("EXEC")
|
@@ -347,29 +499,6 @@ class RedisClient
|
|
347
499
|
nil
|
348
500
|
end
|
349
501
|
|
350
|
-
def call_pipelined(connection, commands, timeouts = nil)
|
351
|
-
exception = nil
|
352
|
-
|
353
|
-
size = commands.size
|
354
|
-
results = Array.new(commands.size)
|
355
|
-
connection.write_multi(commands)
|
356
|
-
|
357
|
-
size.times do |index|
|
358
|
-
timeout = timeouts && timeouts[index]
|
359
|
-
result = connection.read(timeout)
|
360
|
-
if result.is_a?(CommandError)
|
361
|
-
exception ||= result
|
362
|
-
end
|
363
|
-
results[index] = result
|
364
|
-
end
|
365
|
-
|
366
|
-
if exception
|
367
|
-
raise exception
|
368
|
-
else
|
369
|
-
results
|
370
|
-
end
|
371
|
-
end
|
372
|
-
|
373
502
|
def ensure_connected(retryable: true)
|
374
503
|
if @disable_reconnection
|
375
504
|
yield @raw_connection
|
@@ -407,32 +536,36 @@ class RedisClient
|
|
407
536
|
end
|
408
537
|
|
409
538
|
def raw_connection
|
410
|
-
@raw_connection ||=
|
411
|
-
|
412
|
-
config,
|
413
|
-
connect_timeout: connect_timeout,
|
414
|
-
read_timeout: read_timeout,
|
415
|
-
write_timeout: write_timeout,
|
416
|
-
)
|
417
|
-
|
418
|
-
prelude = config.connection_prelude.dup
|
419
|
-
|
420
|
-
if id
|
421
|
-
prelude << ["CLIENT", "SETNAME", id.to_s]
|
422
|
-
end
|
539
|
+
@raw_connection ||= connect
|
540
|
+
end
|
423
541
|
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
542
|
+
def connect
|
543
|
+
connection = config.driver.new(
|
544
|
+
config,
|
545
|
+
connect_timeout: connect_timeout,
|
546
|
+
read_timeout: read_timeout,
|
547
|
+
write_timeout: write_timeout,
|
548
|
+
)
|
549
|
+
|
550
|
+
prelude = config.connection_prelude.dup
|
431
551
|
|
432
|
-
|
552
|
+
if id
|
553
|
+
prelude << ["CLIENT", "SETNAME", id.to_s]
|
433
554
|
end
|
555
|
+
|
556
|
+
# The connection prelude is deliberately not sent to Middlewares
|
557
|
+
if config.sentinel?
|
558
|
+
prelude << ["ROLE"]
|
559
|
+
role, = connection.call_pipelined(prelude, nil).last
|
560
|
+
config.check_role!(role)
|
561
|
+
else
|
562
|
+
connection.call_pipelined(prelude, nil)
|
563
|
+
end
|
564
|
+
|
565
|
+
connection
|
434
566
|
end
|
435
567
|
end
|
436
568
|
|
437
|
-
require "redis_client/resp3"
|
438
569
|
require "redis_client/pooled"
|
570
|
+
|
571
|
+
RedisClient.default_driver
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: redis-client
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jean Boussier
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-05-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: connection_pool
|
@@ -39,6 +39,8 @@ files:
|
|
39
39
|
- LICENSE.md
|
40
40
|
- README.md
|
41
41
|
- Rakefile
|
42
|
+
- ext/redis_client/hiredis/export.clang
|
43
|
+
- ext/redis_client/hiredis/export.gcc
|
42
44
|
- ext/redis_client/hiredis/extconf.rb
|
43
45
|
- ext/redis_client/hiredis/hiredis_connection.c
|
44
46
|
- ext/redis_client/hiredis/vendor/.gitignore
|
@@ -87,12 +89,16 @@ files:
|
|
87
89
|
- ext/redis_client/hiredis/vendor/win32.h
|
88
90
|
- lib/redis-client.rb
|
89
91
|
- lib/redis_client.rb
|
90
|
-
- lib/redis_client/
|
92
|
+
- lib/redis_client/command_builder.rb
|
91
93
|
- lib/redis_client/config.rb
|
92
|
-
- lib/redis_client/
|
94
|
+
- lib/redis_client/connection_mixin.rb
|
95
|
+
- lib/redis_client/decorator.rb
|
93
96
|
- lib/redis_client/hiredis_connection.rb
|
97
|
+
- lib/redis_client/middlewares.rb
|
94
98
|
- lib/redis_client/pooled.rb
|
95
|
-
- lib/redis_client/
|
99
|
+
- lib/redis_client/ruby_connection.rb
|
100
|
+
- lib/redis_client/ruby_connection/buffered_io.rb
|
101
|
+
- lib/redis_client/ruby_connection/resp3.rb
|
96
102
|
- lib/redis_client/sentinel_config.rb
|
97
103
|
- lib/redis_client/version.rb
|
98
104
|
- redis-client.gemspec
|