shifty 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 +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.travis.yml +17 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +459 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/docs/shifty/worker.md +80 -0
- data/lib/shifty.rb +5 -0
- data/lib/shifty/dsl.rb +180 -0
- data/lib/shifty/gang.rb +46 -0
- data/lib/shifty/roster.rb +46 -0
- data/lib/shifty/version.rb +3 -0
- data/lib/shifty/worker.rb +80 -0
- data/shifty.gemspec +29 -0
- metadata +148 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 46d6ec38da041475fa499d8d79042e685d9eb3c4ab1dbce24ab70579b466acc2
|
4
|
+
data.tar.gz: b8cc19ee79ad7321e113f8ec7311f52020566506ff8a7e84dc36c6eb840e57dd
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c11b09e67ff91d611ddedff9404d81b043f9211e0a96a452ff6d9fde0480955e65050b00fb6a71cabc867e35ef3ae28815943d371b56c1a3a1a425a80e5847f0
|
7
|
+
data.tar.gz: 46d0ef549da9cdfc57043b2563d1a4549abb44abffbe424228b18d97758e871e1ad279c9e5d8824d584a1ab66e43100a514c5ccb82fcce161280318d06762a53
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
env:
|
2
|
+
global:
|
3
|
+
- CC_TEST_REPORTER_ID=de10d77685b449162f3f3d568e1ab75d94c0f281afceee218502cf0995e49aa6
|
4
|
+
sudo: false
|
5
|
+
language: ruby
|
6
|
+
rvm:
|
7
|
+
- 2.5.0
|
8
|
+
- jruby-19mode
|
9
|
+
before_install: gem install bundler -v 1.16.1
|
10
|
+
before_script:
|
11
|
+
- curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
|
12
|
+
- chmod +x ./cc-test-reporter
|
13
|
+
- ./cc-test-reporter before-build
|
14
|
+
script:
|
15
|
+
- bundle exec rspec
|
16
|
+
after_script:
|
17
|
+
- ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2018 Joel Helbling
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,459 @@
|
|
1
|
+
[](https://badge.fury.io/rb/shifty)
|
2
|
+
[](https://travis-ci.org/joelhelbling/shifty)
|
3
|
+
[](https://codeclimate.com/github/joelhelbling/shifty/maintainability)
|
4
|
+
[](https://codeclimate.com/github/joelhelbling/shifty/test_coverage)
|
5
|
+
|
6
|
+
# The Shifty Framework
|
7
|
+
|
8
|
+
_"How many Ruby fibers does it take to screw in a lightbulb?"_
|
9
|
+
|
10
|
+
## Quick Start
|
11
|
+
|
12
|
+
Add this line to your application's Gemfile:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
gem 'shifty'
|
16
|
+
```
|
17
|
+
|
18
|
+
And then execute:
|
19
|
+
|
20
|
+
$ bundle
|
21
|
+
|
22
|
+
Or install it yourself:
|
23
|
+
|
24
|
+
$ gem install steplader
|
25
|
+
|
26
|
+
And then use it:
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
require 'shifty'
|
30
|
+
```
|
31
|
+
|
32
|
+
## Shifty DSL
|
33
|
+
|
34
|
+
The DSL provides convenient shorthand for several common types of
|
35
|
+
workers. Let's look at them.
|
36
|
+
|
37
|
+
But first, be sure to include the DSL mixin:
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
include Shifty::DSL
|
41
|
+
```
|
42
|
+
|
43
|
+
### Source Worker
|
44
|
+
|
45
|
+
At the headwater of every Shifty pipeline, there is a source worker
|
46
|
+
which is able to generate its own work items.
|
47
|
+
|
48
|
+
Here is possibly the simplest of source workers, which provides the same
|
49
|
+
string each time it is invoked:
|
50
|
+
|
51
|
+
```ruby
|
52
|
+
worker = source_worker { "Number 9" }
|
53
|
+
|
54
|
+
worker.shift #=> "Number 9"
|
55
|
+
worker.shift #=> "Number 9"
|
56
|
+
worker.shift #=> "Number 9"
|
57
|
+
# ...ad nauseum...
|
58
|
+
```
|
59
|
+
|
60
|
+
Sometimes you want a worker which generates until it doesn't:
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
counter = 0
|
64
|
+
worker = source_worker do
|
65
|
+
if counter < 3
|
66
|
+
counter += 1
|
67
|
+
counter + 1000
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
worker.shift #=> 1001
|
72
|
+
worker.shift #=> 1002
|
73
|
+
worker.shift #=> 1003
|
74
|
+
worker.shift #=> nil
|
75
|
+
worker.shift #=> nil (and will be nil henceforth)
|
76
|
+
```
|
77
|
+
|
78
|
+
If all you want is a source worker which generates a series of numbers
|
79
|
+
the DSL provides an easier way to do it:
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
w1 = source_worker [0,1,2]
|
83
|
+
w2 = source_worker (0..2)
|
84
|
+
|
85
|
+
w1.shift == w2.shift #=> 0
|
86
|
+
w1.shift == w2.shift #=> 1
|
87
|
+
w1.shift == w2.shift #=> 2
|
88
|
+
w1.shift == w2.shift #=> nil (and henceforth, etc.)
|
89
|
+
```
|
90
|
+
|
91
|
+
### Relay Worker
|
92
|
+
|
93
|
+
This is perhaps the most "normal" kind of worker in Shifty. It
|
94
|
+
accepts a value and returns some transformation of that value:
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
squarer = relay_worker do |number|
|
98
|
+
number ** 2
|
99
|
+
end
|
100
|
+
```
|
101
|
+
|
102
|
+
Of course a relay worker needs a source from which to get values upon
|
103
|
+
which to operate:
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
source = source_worker (0..3)
|
107
|
+
|
108
|
+
squarer.supply = source
|
109
|
+
```
|
110
|
+
|
111
|
+
Or, if you prefer, the DSL provides a vertical pipe for linking the
|
112
|
+
workers together into a pipeline:
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
pipeline = source | squarer
|
116
|
+
|
117
|
+
pipeline.shift #=> 0
|
118
|
+
pipeline.shift #=> 1
|
119
|
+
pipeline.shift #=> 4
|
120
|
+
pipeline.shift #=> 9
|
121
|
+
pipeline.shift #=> nil
|
122
|
+
```
|
123
|
+
|
124
|
+
### Side Worker
|
125
|
+
|
126
|
+
As we travel down the pipeline, from time to time, we will want to
|
127
|
+
stop and smell the roses, or write to a log file, or drop a beat, or
|
128
|
+
create some other kind of side-effect. That's the purpose of the
|
129
|
+
`side_worker`. A side worker will pass through the same value that
|
130
|
+
it received, but as it does so, it can perform some kind side-effect
|
131
|
+
work.
|
132
|
+
|
133
|
+
```
|
134
|
+
source = source_worker (0..3)
|
135
|
+
|
136
|
+
evens = []
|
137
|
+
even_stasher = side_effect do |value|
|
138
|
+
if value % 2 == 0
|
139
|
+
evens << value
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# re-using "squarer" from above example...
|
144
|
+
pipeline = source | even_stasher | squarer
|
145
|
+
|
146
|
+
pipeline.shift #=> 0
|
147
|
+
pipeline.shift #=> 1
|
148
|
+
pipeline.shift #=> 4
|
149
|
+
pipeline.shift #=> 9
|
150
|
+
pipeline.shift #=> nil
|
151
|
+
|
152
|
+
evens #=> [0, 2]
|
153
|
+
```
|
154
|
+
|
155
|
+
Notice that the output is the same as the previous example, even though
|
156
|
+
we put this `even_stasher` `side_worker` in the middle of the pipeline.
|
157
|
+
|
158
|
+
However, we can also see that the `evens` array now contains the even numbers from `source` (the side effect).
|
159
|
+
|
160
|
+
_But wait,_ you want to say, _can't we still create side effects with
|
161
|
+
regular ole relay workers?_ Why, yes. Yes you can. Ruby being what
|
162
|
+
it is, there really isn't a way to prevent the implementation of any
|
163
|
+
worker from creating side effects.
|
164
|
+
|
165
|
+
_And still wait,_ you'll also be wanting to say, _isn't it possible
|
166
|
+
that a side worker could mutate the value as it's passed through?_ And
|
167
|
+
again, yes. It would be very difficult\* in the Ruby language (where
|
168
|
+
so many things are passed by reference) to perfectly prevent a side
|
169
|
+
worker from mutating the value. But please don't.
|
170
|
+
|
171
|
+
The side effect worker's purpose is to provide _intentionality_ and
|
172
|
+
clarity. When you're creating a side effect, let it be very clear.
|
173
|
+
And don't do regular, pure functional style work in the same worker.
|
174
|
+
|
175
|
+
This will make side effects easier to troubleshoot; if you pull them
|
176
|
+
out of the pipeline, the pipeline's output shouldn't change. By the
|
177
|
+
same token, if there is a problem with a side effect, troubleshooting
|
178
|
+
it will be much simpler if the side effects are already isolated and
|
179
|
+
named.
|
180
|
+
|
181
|
+
Another measure for preventing side workers from creating unwanted
|
182
|
+
side-effects is to use `:hardened` mode. That's right, `side_worker`
|
183
|
+
has a mode (which defaults to `:normal`). When set to `:hardened`,
|
184
|
+
each value is `Marshal.dump`ed and `Marshal.load`ed before being
|
185
|
+
passed to the side worker's callable. This helps to ensure that
|
186
|
+
the original value will be passed through to the next worker in the
|
187
|
+
queue in a pristine state (unmodified), and that no future or
|
188
|
+
subsequent modification can take place.
|
189
|
+
|
190
|
+
Given a source...
|
191
|
+
|
192
|
+
```ruby
|
193
|
+
source = source_worker [[:foo], [:bar]]
|
194
|
+
```
|
195
|
+
|
196
|
+
Consider this unsafe, unhardened side worker:
|
197
|
+
|
198
|
+
```ruby
|
199
|
+
unsafe = side_worker { |v| v << :boo }
|
200
|
+
```
|
201
|
+
|
202
|
+
This produces unwanted mutations, because of the `<<`.
|
203
|
+
|
204
|
+
```ruby
|
205
|
+
pipeline = source | unsafe
|
206
|
+
|
207
|
+
pipeline.shift #=> [:foo, :boo] <-- Oh noes!
|
208
|
+
pipeline.shift #=> [:bar, :boo] <-- Disaster!
|
209
|
+
pipeline.shift #=> nil
|
210
|
+
```
|
211
|
+
|
212
|
+
Let's harden it:
|
213
|
+
|
214
|
+
```ruby
|
215
|
+
unsafe = side_worker(:hardened) { |v| v << :boo }
|
216
|
+
```
|
217
|
+
|
218
|
+
The `:hardened` side worker doesn't have access to the original
|
219
|
+
value, and therefore its shenanigans are moot with respect to
|
220
|
+
the main workflow:
|
221
|
+
|
222
|
+
```ruby
|
223
|
+
pipeline = source | unsafe
|
224
|
+
|
225
|
+
pipeline.shift #=> [:foo] <-- No changes
|
226
|
+
pipeline.shift #=> [:bar] <-- Much better
|
227
|
+
pipeline.shift #=> nil
|
228
|
+
```
|
229
|
+
|
230
|
+
### Filter Worker
|
231
|
+
|
232
|
+
The filter worker simply passes through the values which are given
|
233
|
+
to it, but _only_ those values which result in truthiness when your
|
234
|
+
provided callable is run against it. Values which result in
|
235
|
+
falsiness are simply discarded.
|
236
|
+
|
237
|
+
```ruby
|
238
|
+
source = source_worker (0..5)
|
239
|
+
|
240
|
+
filter = filter_worker do |value|
|
241
|
+
value % 2 == 0
|
242
|
+
end
|
243
|
+
|
244
|
+
pipeline = source | filter
|
245
|
+
|
246
|
+
pipeline.shift #=> 0
|
247
|
+
pipeline.shift #=> 2
|
248
|
+
pipeline.shift #=> 4
|
249
|
+
pipeline.shift #=> nil
|
250
|
+
```
|
251
|
+
|
252
|
+
### Batch Worker
|
253
|
+
|
254
|
+
The batch worker gathers outputs into batches and the returns each
|
255
|
+
batch as its output.
|
256
|
+
|
257
|
+
```ruby
|
258
|
+
source = source_worker (0..7)
|
259
|
+
|
260
|
+
batch = batch_worker gathering: 3
|
261
|
+
|
262
|
+
pipeline = source | batch
|
263
|
+
|
264
|
+
pipeline.shift #=> [0, 1, 2]
|
265
|
+
pipeline.shift #=> [3, 4, 5]
|
266
|
+
pipeline.shift #=> [6, 7]
|
267
|
+
pipeline.shift #=> nil
|
268
|
+
```
|
269
|
+
|
270
|
+
Notice how the final batch doesn't have full compliment of three.
|
271
|
+
|
272
|
+
Fixed-numbered batch workers are useful for things like pagination,
|
273
|
+
perhaps, but sometimes we don't want a batch to include a specific
|
274
|
+
number of items, but to batch together all items until a certain
|
275
|
+
condition is met. So batch worker can also accept a callable:
|
276
|
+
|
277
|
+
```ruby
|
278
|
+
source = source_worker [
|
279
|
+
"some", "rain", "must\n", "fall", "but", "ok" ]
|
280
|
+
|
281
|
+
line_reader = batch_worker do |value|
|
282
|
+
value.end_with? "\n"
|
283
|
+
end
|
284
|
+
|
285
|
+
pipeline = source | line_reader
|
286
|
+
|
287
|
+
pipeline.shift #=> ["some", "rain", "must\n"]
|
288
|
+
pipeline.shift #=> ["fall", "but", "ok"]
|
289
|
+
pipeline.shift #=> nil
|
290
|
+
```
|
291
|
+
|
292
|
+
### Splitter Worker
|
293
|
+
|
294
|
+
The splitter worker accepts a value from its supply, and generates an array
|
295
|
+
and then successively returns each element of the array. Once the array
|
296
|
+
has been expended, the splitter worker appeals to its supplier for another
|
297
|
+
value and the process repeats.
|
298
|
+
|
299
|
+
```ruby
|
300
|
+
source = source_worker [
|
301
|
+
'A bold', 'move westward' ]
|
302
|
+
|
303
|
+
splitter = splitter_worker do |value|
|
304
|
+
value.split(' ')
|
305
|
+
end
|
306
|
+
|
307
|
+
pipeline = source | splitter
|
308
|
+
|
309
|
+
pipeline.shift #=> 'A'
|
310
|
+
pipeline.shift #=> 'bold'
|
311
|
+
pipeline.shift #=> 'move'
|
312
|
+
pipeline.shift #=> 'westward'
|
313
|
+
pipeline.shift #=> nil
|
314
|
+
```
|
315
|
+
|
316
|
+
### Trailing Worker
|
317
|
+
|
318
|
+
|
319
|
+
The trailing worker accepts a value, and returns an array containing the
|
320
|
+
last _n_ values. This worker could be useful for things like rolling averages.
|
321
|
+
|
322
|
+
No values are returned until n values had been accumulated. New values are
|
323
|
+
_unshifted_ into the zero-th position in the array of trailing values, so
|
324
|
+
that a given value will graduate through the array until it reaches the last
|
325
|
+
position and then subsequently removed.
|
326
|
+
|
327
|
+
Maybe it's easiest to just give an example:
|
328
|
+
|
329
|
+
```ruby
|
330
|
+
source = source_worker (0..5)
|
331
|
+
trailer = trailing_worker 4
|
332
|
+
|
333
|
+
pipeline = source | trailer
|
334
|
+
|
335
|
+
pipeline.shift #=> [3,2,1,0]
|
336
|
+
pipeline.shift #=> [4,3,2,1]
|
337
|
+
pipeline.shift #=> [5,4,3,2]
|
338
|
+
pipeline.shift #=> nil
|
339
|
+
```
|
340
|
+
|
341
|
+
Note that as soon as a nil is received from the supplying queue, the trailing
|
342
|
+
worker simply provides a nil.
|
343
|
+
|
344
|
+
|
345
|
+
## Origins of Shifty
|
346
|
+
|
347
|
+
Shifty was Shifty, which grew out of experimentation with Ruby fibers, after reading
|
348
|
+
[Dave Thomas' demo of Ruby fibers](http://pragdave.me/blog/2007/12/30/pipelines-using-fibers-in-ruby-19/),
|
349
|
+
wherein he created a pipeline of fiber processes, emulating the style and syntax of the
|
350
|
+
\*nix command line. I noticed that, courtesy of fibers' extremely low surface area,
|
351
|
+
fiber-to-fiber collaborators could operate with extremely low coupling. That was the
|
352
|
+
original motivation for creating the framework.
|
353
|
+
|
354
|
+
After playing around with the new framework a bit, I began to notice
|
355
|
+
other interesting characteristics of this paradigm.
|
356
|
+
|
357
|
+
### Escalator vs Elevator
|
358
|
+
|
359
|
+
Suppose we are performing several operations on the members of a largish
|
360
|
+
collection. If we daisy-chain enumerable operators together (which is so
|
361
|
+
easy and fun with Ruby) we will notice that if something goes awry with
|
362
|
+
item number two during operation number seven, we nonetheless had to wait
|
363
|
+
through a complete run of all items through operations 1 - 6 before we
|
364
|
+
receive the bad news early in operation seven. Imagine if the first six
|
365
|
+
operations take a long time to each complete? Furthermore, what if the
|
366
|
+
operations on all incomplete items must be reversed in some way (e.g.
|
367
|
+
cleaned up or rolled back)? It would be far less messy and far more
|
368
|
+
expedient if each item could be processed though all operations before
|
369
|
+
the next one is begun.
|
370
|
+
|
371
|
+
This is the design paradigm which shifty makes easy. Although all
|
372
|
+
the workers in your assembly line can be coded in the same context (which
|
373
|
+
is one of the big selling points of the daisy-chaining of enumerable
|
374
|
+
methods,incidentally), you also get the advantage of passing each item
|
375
|
+
though the entire op-chain before starting the next.
|
376
|
+
|
377
|
+
### Think Locally, Act Globally
|
378
|
+
|
379
|
+
Because shifty workers use fibers as their basic API, the are almost
|
380
|
+
unaware of the outside world. And they can pretty much be written as such.
|
381
|
+
At the heart of each Shifty worker is a task which you provide, which
|
382
|
+
is a callable ruby object (such as a Proc or a lambda).
|
383
|
+
|
384
|
+
The scope of the work is whatever scope existed in the task when you
|
385
|
+
initially created the worker. And if you want a worker to maintain its
|
386
|
+
own internal state, you can simply include a loop within the worker's
|
387
|
+
task, and use the `#handoff` method to pass along the worker's product at
|
388
|
+
the appropriate point in the loop.
|
389
|
+
|
390
|
+
For example:
|
391
|
+
|
392
|
+
```ruby
|
393
|
+
realestate_maker = Shifty::Worker.new do
|
394
|
+
oceans = %w[Atlantic Pacific Indiana Arctic]
|
395
|
+
previous_ocean = "Atlantic"
|
396
|
+
while current_ocean = oceans.sample
|
397
|
+
drain current_ocean #=> let's posit that this is a long-running async process!
|
398
|
+
handoff previous_ocean
|
399
|
+
previous_ocean = current_ocean
|
400
|
+
end
|
401
|
+
```
|
402
|
+
|
403
|
+
Anything scoped to the outside of that loop but inside the worker's task
|
404
|
+
will essentially become that worker's initial state. This means we often
|
405
|
+
don't need to bother with instance variables, accessors, and other
|
406
|
+
features of classes which deal with maintaining instances' state.
|
407
|
+
|
408
|
+
### The Wormhole Effect
|
409
|
+
|
410
|
+
Despite the fact that the work is done in separate threads, the _scope_
|
411
|
+
of the work is the scope of the coordinating body of code. This means
|
412
|
+
that even the remaining coupling in such systems --which is primarily
|
413
|
+
just _common objects_ coupling-- this little remaining coupling is
|
414
|
+
mitigated by the possibility of having coupled code _live together_.
|
415
|
+
I call this feature the _folded collaborators_ effect.
|
416
|
+
|
417
|
+
Consider the following ~~code~~ vaporware:
|
418
|
+
|
419
|
+
```ruby
|
420
|
+
SUBJECT = 'kitteh'
|
421
|
+
|
422
|
+
include Shifty::DSL
|
423
|
+
|
424
|
+
tweet_getter = source_worker do
|
425
|
+
twitter_api.fetch_my_tweets
|
426
|
+
end
|
427
|
+
|
428
|
+
about_me = filter_worker do |tweet|
|
429
|
+
tweet.referenced.include? SUBJECT
|
430
|
+
end
|
431
|
+
|
432
|
+
tweet_formatter = relay_worker do |tweet|
|
433
|
+
apply_format_to tweet
|
434
|
+
end
|
435
|
+
|
436
|
+
kitteh_tweets = tweet_getter | about_me | tweet_formatter
|
437
|
+
|
438
|
+
while tweet = kitteh_tweets.shift
|
439
|
+
display(tweet)
|
440
|
+
end
|
441
|
+
```
|
442
|
+
|
443
|
+
None of these tasks have hard coupling with each other. If we were to
|
444
|
+
insert another worker between the filter and the formatter, neither of
|
445
|
+
those workers would need changes in their code, assuming the inserted
|
446
|
+
worker plays nicely with the objects they're all passing along.
|
447
|
+
|
448
|
+
Which brings me to the point: these workers to have a dependency upon the
|
449
|
+
objects they're handing off and receiving. But we have the capability to
|
450
|
+
coordinate those workers in a centralized location (such as in this code
|
451
|
+
example).
|
452
|
+
|
453
|
+
## Shifty::Worker
|
454
|
+
|
455
|
+
The Shifty::Worker documentation (which was the old README) is now
|
456
|
+
[here](docs/shifty/worker.md).
|
457
|
+
|
458
|
+
## Roadmap
|
459
|
+
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "shifty"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
# Shifty::Worker
|
2
|
+
|
3
|
+
_The workhorse of the Shifty framework._
|
4
|
+
|
5
|
+
## Workers have tasks...
|
6
|
+
|
7
|
+
Initialize with a block of code:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
source_worker = Shifty::Worker.new { "hulk" }
|
11
|
+
|
12
|
+
source_worker.shift #=> "hulk"
|
13
|
+
```
|
14
|
+
If you supply a worker with another worker as its supply, then you
|
15
|
+
can give it a task which accepts a value:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
relay_worker = Shifty::Worker.new { |name| name.upcase }
|
19
|
+
relay_worker.supply = source_worker
|
20
|
+
|
21
|
+
relay_worker.shift #=> "HULK"
|
22
|
+
```
|
23
|
+
|
24
|
+
You can also initialize a worker by passing in a callable object
|
25
|
+
as its task:
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
capitalizer = Proc.new { |name| name.capitalize }
|
29
|
+
relay_worker = Shifty::Worker.new(task: capitalizer, supply: source_worker)
|
30
|
+
|
31
|
+
relay_worker.shift #=> 'Hulk'
|
32
|
+
```
|
33
|
+
|
34
|
+
A worker also has an accessor for its @task:
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
doofusizer = Proc.new { |name| name.gsub(/u/, 'oo') }
|
38
|
+
relay_worker.task = doofusizer
|
39
|
+
|
40
|
+
relay_worker.shift #=> 'hoolk'
|
41
|
+
```
|
42
|
+
|
43
|
+
And finally, you can provide a task by directly overriding the
|
44
|
+
worker's #task instance method:
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
def relay_worker.task(name)
|
48
|
+
name.to_sym
|
49
|
+
end
|
50
|
+
|
51
|
+
relay_worker.shift #=> :hulk
|
52
|
+
```
|
53
|
+
|
54
|
+
Even workers without a task have a task; all workers actually come
|
55
|
+
with a default task which simply passes on the received value unchanged:
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
useless_worker = Shifty::Worker.new(supply: source_worker)
|
59
|
+
|
60
|
+
useless_worker.shift #=> 'hulk'
|
61
|
+
```
|
62
|
+
|
63
|
+
## The pipeline DSL
|
64
|
+
|
65
|
+
You can stitch your workers together using the vertical pipe ("|") like so:
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
pipeline = source_worker | relay_worker | another worker
|
69
|
+
```
|
70
|
+
|
71
|
+
...and then just call on that pipeline (it's actually the last worker in the
|
72
|
+
chain):
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
while next_value = pipeline.shift do
|
76
|
+
do_something_with next_value
|
77
|
+
# etc.
|
78
|
+
end
|
79
|
+
```
|
80
|
+
|
data/lib/shifty.rb
ADDED
data/lib/shifty/dsl.rb
ADDED
@@ -0,0 +1,180 @@
|
|
1
|
+
module Shifty
|
2
|
+
class WorkerInitializationError < StandardError; end
|
3
|
+
|
4
|
+
module DSL
|
5
|
+
def source_worker(argument=nil, &block)
|
6
|
+
ensure_correct_arity_for!(argument, block)
|
7
|
+
|
8
|
+
series = series_from(argument)
|
9
|
+
callable = setup_callable_for(block, series)
|
10
|
+
|
11
|
+
return Worker.new(&callable) if series.nil?
|
12
|
+
|
13
|
+
Worker.new do
|
14
|
+
series.each(&callable)
|
15
|
+
|
16
|
+
while true do
|
17
|
+
handoff nil
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
def relay_worker(&block)
|
24
|
+
ensure_regular_arity(block)
|
25
|
+
|
26
|
+
Worker.new do |value|
|
27
|
+
value && block.call(value)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def side_worker(mode=:normal, &block)
|
32
|
+
ensure_regular_arity(block)
|
33
|
+
|
34
|
+
Worker.new do |value|
|
35
|
+
value.tap do |v|
|
36
|
+
used_value = mode == :hardened ?
|
37
|
+
Marshal.load(Marshal.dump(v)) : v
|
38
|
+
|
39
|
+
v && block.call(used_value)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def filter_worker(argument=nil, &block)
|
45
|
+
if (block && argument.respond_to?(:call))
|
46
|
+
throw_with 'You cannot supply two callables'
|
47
|
+
end
|
48
|
+
callable = argument.respond_to?(:call) ? argument : block
|
49
|
+
ensure_callable(callable)
|
50
|
+
|
51
|
+
Worker.new do |value, supply|
|
52
|
+
while value && !callable.call(value) do
|
53
|
+
value = supply.shift
|
54
|
+
end
|
55
|
+
value
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
class BatchContext < OpenStruct
|
60
|
+
def batch_complete?(value, collection)
|
61
|
+
value.nil? ||
|
62
|
+
!! batch_full.call(value, collection)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def batch_worker(options = {gathering: 1}, &block)
|
67
|
+
ensure_regular_arity(block) if block
|
68
|
+
batch_full = block ||
|
69
|
+
Proc.new { |_, batch| batch.size >= options[:gathering] }
|
70
|
+
|
71
|
+
batch_context = BatchContext.new({ batch_full: batch_full })
|
72
|
+
|
73
|
+
Worker.new(context: batch_context) do |value, supply, context|
|
74
|
+
if value
|
75
|
+
context.collection = [value]
|
76
|
+
until context.batch_complete?(
|
77
|
+
context.collection.last,
|
78
|
+
context.collection
|
79
|
+
)
|
80
|
+
context.collection << supply.shift
|
81
|
+
end
|
82
|
+
context.collection.compact
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def splitter_worker(&block)
|
88
|
+
ensure_regular_arity(block)
|
89
|
+
|
90
|
+
Worker.new do |value|
|
91
|
+
if value.nil?
|
92
|
+
value
|
93
|
+
else
|
94
|
+
parts = [block.call(value)].flatten
|
95
|
+
while parts.size > 1 do
|
96
|
+
handoff parts.shift
|
97
|
+
end
|
98
|
+
parts.shift
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def trailing_worker(trail_length=2)
|
104
|
+
trail = []
|
105
|
+
Worker.new do |value, supply|
|
106
|
+
if value
|
107
|
+
trail.unshift value
|
108
|
+
if trail.size >= trail_length
|
109
|
+
trail.pop
|
110
|
+
end
|
111
|
+
while trail.size < trail_length
|
112
|
+
trail.unshift supply.shift
|
113
|
+
end
|
114
|
+
|
115
|
+
trail
|
116
|
+
else
|
117
|
+
value
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def handoff(something)
|
123
|
+
Fiber.yield something
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
def throw_with(*msg)
|
129
|
+
raise WorkerInitializationError.new([msg].flatten.join(' '))
|
130
|
+
end
|
131
|
+
|
132
|
+
def ensure_callable(callable)
|
133
|
+
unless callable && callable.respond_to?(:call)
|
134
|
+
throw_with 'You must supply a callable'
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def ensure_regular_arity(block)
|
139
|
+
if block.arity != 1
|
140
|
+
throw_with \
|
141
|
+
"Worker must accept exactly one argument (arity == 1)"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# only valid for #source_worker
|
146
|
+
def ensure_correct_arity_for!(argument, block)
|
147
|
+
return unless block
|
148
|
+
if argument
|
149
|
+
ensure_regular_arity(block)
|
150
|
+
else
|
151
|
+
if block.arity > 0
|
152
|
+
throw_with \
|
153
|
+
'Source worker cannot accept any arguments (arity == 0)'
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def series_from(series)
|
159
|
+
return if series.nil?
|
160
|
+
case
|
161
|
+
when series.respond_to?(:to_a)
|
162
|
+
series.to_a
|
163
|
+
when series.respond_to?(:scan)
|
164
|
+
series.scan(/./)
|
165
|
+
else
|
166
|
+
[series]
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def setup_callable_for(block, series)
|
171
|
+
return block unless series
|
172
|
+
if block
|
173
|
+
return Proc.new { |value| handoff block.call(value) }
|
174
|
+
else
|
175
|
+
return Proc.new { |value| handoff value }
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
end
|
180
|
+
end
|
data/lib/shifty/gang.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'shifty/roster'
|
2
|
+
|
3
|
+
module Shifty
|
4
|
+
class Gang
|
5
|
+
attr_accessor :workers
|
6
|
+
|
7
|
+
def initialize(workers=[])
|
8
|
+
link(workers + [])
|
9
|
+
end
|
10
|
+
|
11
|
+
def roster
|
12
|
+
workers
|
13
|
+
end
|
14
|
+
|
15
|
+
def shift
|
16
|
+
workers.last.shift
|
17
|
+
end
|
18
|
+
|
19
|
+
def ready_to_work?
|
20
|
+
workers.first.ready_to_work?
|
21
|
+
end
|
22
|
+
|
23
|
+
def supply
|
24
|
+
workers.first.supply
|
25
|
+
end
|
26
|
+
|
27
|
+
def supply=(source_queue)
|
28
|
+
workers.first.supply = source_queue
|
29
|
+
end
|
30
|
+
|
31
|
+
def supplies(subscribing_party)
|
32
|
+
subscribing_party.supply = self
|
33
|
+
end
|
34
|
+
alias_method :"|", :supplies
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def link(workers)
|
39
|
+
@workers = [workers.shift]
|
40
|
+
while worker = workers.shift do
|
41
|
+
Roster[self] << worker
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Shifty
|
2
|
+
module Roster
|
3
|
+
class << self
|
4
|
+
def [](gang)
|
5
|
+
RosterizedGang.new(gang)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
class RosterizedGang
|
11
|
+
attr_reader :gang
|
12
|
+
|
13
|
+
def initialize(gang)
|
14
|
+
@gang = gang
|
15
|
+
end
|
16
|
+
|
17
|
+
def workers
|
18
|
+
gang.workers
|
19
|
+
end
|
20
|
+
|
21
|
+
def push(worker)
|
22
|
+
if worker
|
23
|
+
worker.supply = workers.last
|
24
|
+
workers << worker
|
25
|
+
end
|
26
|
+
end
|
27
|
+
alias_method :"<<", :push
|
28
|
+
|
29
|
+
def pop
|
30
|
+
gang.workers.pop.tap do |popped|
|
31
|
+
popped.supply = nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def shift
|
36
|
+
workers.shift.tap do
|
37
|
+
workers.first.supply = nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def unshift(worker)
|
42
|
+
workers.first.supply = worker
|
43
|
+
workers.unshift worker
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
|
3
|
+
module Shifty
|
4
|
+
class Worker
|
5
|
+
attr_reader :supply
|
6
|
+
|
7
|
+
def initialize(p={}, &block)
|
8
|
+
@supply = p[:supply]
|
9
|
+
@task = block || p[:task]
|
10
|
+
@context = p[:context] || OpenStruct.new
|
11
|
+
# don't define default task here
|
12
|
+
# because we want to allow for
|
13
|
+
# an initialized worker to have
|
14
|
+
# a task injected, including
|
15
|
+
# method-based tasks.
|
16
|
+
end
|
17
|
+
|
18
|
+
def shift
|
19
|
+
ensure_ready_to_work!
|
20
|
+
workflow.resume
|
21
|
+
end
|
22
|
+
|
23
|
+
def ready_to_work?
|
24
|
+
@task && (supply || !task_accepts_a_value?)
|
25
|
+
end
|
26
|
+
|
27
|
+
def supplies(subscribing_party)
|
28
|
+
subscribing_party.supply = self
|
29
|
+
subscribing_party
|
30
|
+
end
|
31
|
+
alias_method :"|", :supplies
|
32
|
+
|
33
|
+
def supply=(supplier)
|
34
|
+
raise WorkerError.new("Worker is a source, and cannot accept a supply") unless suppliable?
|
35
|
+
@supply = supplier
|
36
|
+
end
|
37
|
+
|
38
|
+
def suppliable?
|
39
|
+
@task && @task.arity > 0
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def ensure_ready_to_work!
|
45
|
+
@task ||= default_task
|
46
|
+
|
47
|
+
unless ready_to_work?
|
48
|
+
raise "This worker's task expects to receive a value from a supplier, but has no supply."
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def workflow
|
53
|
+
@my_little_machine ||= Fiber.new do
|
54
|
+
loop do
|
55
|
+
value = supply && supply.shift
|
56
|
+
Fiber.yield @task.call(value, supply, @context)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def default_task
|
62
|
+
Proc.new { |value| value }
|
63
|
+
end
|
64
|
+
|
65
|
+
def task_accepts_a_value?
|
66
|
+
@task.arity > 0
|
67
|
+
end
|
68
|
+
|
69
|
+
def task_method_exists?
|
70
|
+
self.methods.include? :task
|
71
|
+
end
|
72
|
+
|
73
|
+
def task_method_accepts_a_value?
|
74
|
+
self.method(:task).arity > 0
|
75
|
+
end
|
76
|
+
|
77
|
+
end
|
78
|
+
|
79
|
+
class WorkerError < StandardError; end
|
80
|
+
end
|
data/shifty.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require 'shifty/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'shifty'
|
7
|
+
spec.version = Shifty::VERSION
|
8
|
+
spec.authors = ['Joel Helbling']
|
9
|
+
spec.email = ['joel@joelhelbling.com']
|
10
|
+
|
11
|
+
spec.summary = %q{A functional framework aimed at extremely low coupling}
|
12
|
+
spec.description = %q{Shifty provides tools for coordinating simple workers which consume a supplying queue and emit corresponding work products, valuing pure functions, carefully isolated side effects, and extremely low coupling.}
|
13
|
+
spec.homepage = 'https://github.com/joelhelbling/shifty'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
17
|
+
f.match(%r{^(test|spec|features)/})
|
18
|
+
end
|
19
|
+
spec.bindir = "exe"
|
20
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
21
|
+
spec.require_paths = ["lib"]
|
22
|
+
|
23
|
+
spec.add_development_dependency 'bundler', '~> 1.16'
|
24
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
25
|
+
spec.add_development_dependency 'rspec', '~> 3.1'
|
26
|
+
spec.add_development_dependency 'rspec-given', '~> 3.8'
|
27
|
+
spec.add_development_dependency 'pry'
|
28
|
+
spec.add_development_dependency 'simplecov'
|
29
|
+
end
|
metadata
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: shifty
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Joel Helbling
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-04-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.16'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.16'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.1'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.1'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec-given
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.8'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.8'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: pry
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: simplecov
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description: Shifty provides tools for coordinating simple workers which consume a
|
98
|
+
supplying queue and emit corresponding work products, valuing pure functions, carefully
|
99
|
+
isolated side effects, and extremely low coupling.
|
100
|
+
email:
|
101
|
+
- joel@joelhelbling.com
|
102
|
+
executables: []
|
103
|
+
extensions: []
|
104
|
+
extra_rdoc_files: []
|
105
|
+
files:
|
106
|
+
- ".gitignore"
|
107
|
+
- ".rspec"
|
108
|
+
- ".travis.yml"
|
109
|
+
- Gemfile
|
110
|
+
- LICENSE.txt
|
111
|
+
- README.md
|
112
|
+
- Rakefile
|
113
|
+
- bin/console
|
114
|
+
- bin/setup
|
115
|
+
- docs/shifty/worker.md
|
116
|
+
- lib/shifty.rb
|
117
|
+
- lib/shifty/dsl.rb
|
118
|
+
- lib/shifty/gang.rb
|
119
|
+
- lib/shifty/roster.rb
|
120
|
+
- lib/shifty/version.rb
|
121
|
+
- lib/shifty/worker.rb
|
122
|
+
- pkg/.gitkeep
|
123
|
+
- shifty.gemspec
|
124
|
+
homepage: https://github.com/joelhelbling/shifty
|
125
|
+
licenses:
|
126
|
+
- MIT
|
127
|
+
metadata: {}
|
128
|
+
post_install_message:
|
129
|
+
rdoc_options: []
|
130
|
+
require_paths:
|
131
|
+
- lib
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - ">="
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
138
|
+
requirements:
|
139
|
+
- - ">="
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
requirements: []
|
143
|
+
rubyforge_project:
|
144
|
+
rubygems_version: 2.7.3
|
145
|
+
signing_key:
|
146
|
+
specification_version: 4
|
147
|
+
summary: A functional framework aimed at extremely low coupling
|
148
|
+
test_files: []
|