easyop 0.1.3 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c77d9aa5925ea30b3de85fc74618f355ee3498223f79d6184cda21e95574b8d
4
- data.tar.gz: 8bd775d8fa095a50b46aafeedec78f2046d54005b6316a1f22ae02b5657c361c
3
+ metadata.gz: 2b880af1b623158d3acfac130198bd96ce8c268195fd073554283738a880a45a
4
+ data.tar.gz: 0c4f5554b456ac252b13734d0964155e2a80909e81946e28f89c529cc60f9081
5
5
  SHA512:
6
- metadata.gz: a01be26e27050c1569f85ee610043ca42268c75054355c4faf230b65599354d9a824b07955f4f13cda25672681d1c934bbeaec15d89be2cfd8ad55681dd29511
7
- data.tar.gz: 41d8d5bf9ca01e02146764678c343eac787c2d5445670caa9e8c1a0f0b60ff26b57906f2fb4abd16b7e59d05b05fe65a1400cff4458934df0110bf1af10f5d3a
6
+ metadata.gz: c408d6d42a357ba1a6df4da21e13a7b0498d36fa4252e86c68aff289e8843d94d056c430f654f023e6d801ee650fc1bc4c400cb6d058a6b209129e1fbda644fd
7
+ data.tar.gz: e89087c84c8fbc77abe96b6713a144dd34b78c2cfd35066f28a14e37516f0e7ae8b0f0cbbcfe0ca9eb9de646c7890552b64d7d2848c9976ff6f51637531a4a8a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.4] — 2026-04-14
11
+
12
+ ### Added
13
+
14
+ - **Flow-tracing forwarding in `Easyop::Flow`** — `CallBehavior#call` now automatically sets the `__recording_parent_*` ctx keys before running steps, so every child operation's log entry carries the flow class as its `parent_operation_name` and `parent_reference_id`. This works even when Recording is not installed on the flow class itself (bare `include Easyop::Flow`). When Recording IS installed (recommended: inherit from ApplicationOperation and add `transactional false`), the flow appears in the log as the root entry and RunWrapper handles the ctx setup — no double-setup occurs.
15
+
16
+ ```ruby
17
+ # Bare flow — Recording on steps only; flow itself is not recorded but
18
+ # steps correctly show parent_operation_name: "Flows::Checkout"
19
+ class Flows::Checkout
20
+ include Easyop::Flow
21
+ flow Orders::CreateOrder, Orders::ProcessPayment
22
+ end
23
+
24
+ # Recommended — flow is recorded as root, steps as children:
25
+ class Flows::Checkout < ApplicationOperation
26
+ include Easyop::Flow
27
+ transactional false # steps manage their own transactions
28
+ flow Orders::CreateOrder, Orders::ProcessPayment
29
+ end
30
+ ```
31
+
32
+ Result in operation_logs:
33
+ ```
34
+ Flows::Checkout root=aaa ref=bbb parent=nil
35
+ Orders::CreateOrder root=aaa ref=ccc parent=Flows::Checkout/bbb
36
+ Orders::ProcessPayment root=aaa ref=ddd parent=Flows::Checkout/bbb
37
+ ```
38
+
39
+ - **`record_result` DSL for `Easyop::Plugins::Recording`** — selectively persist ctx output data into a new optional `result_data :text` column (stored as JSON). Supports three forms:
40
+
41
+ ```ruby
42
+ # Attrs form — one or more ctx keys
43
+ record_result attrs: :invoice_id
44
+ record_result attrs: [:invoice_id, :total]
45
+
46
+ # Block form — custom extraction
47
+ record_result { |ctx| { total: ctx.total, items: ctx.items.count } }
48
+
49
+ # Symbol form — delegates to a private instance method
50
+ record_result :build_result
51
+ ```
52
+
53
+ Plugin-level default (inherited by all subclasses):
54
+ ```ruby
55
+ plugin Easyop::Plugins::Recording, model: OperationLog, record_result: { attrs: :metadata }
56
+ ```
57
+
58
+ Class-level `record_result` overrides the plugin-level default. Missing ctx keys produce `nil` (no error). ActiveRecord objects are serialized as `{ id:, class: }`. Serialization errors are swallowed. The `result_data` column is silently skipped when absent from the model table — fully backward-compatible.
59
+
60
+ ### Fixed
61
+
62
+ - **`Easyop::Schema` type-mismatch warning suppressed when `$VERBOSE` is `nil`** — `warn` is a no-op in Ruby when `$VERBOSE` is `nil` (e.g. when running under certain test setups or with `-W0`). Changed to `$stderr.puts` so the `[EasyOp]` type-mismatch message always reaches stderr regardless of Ruby's verbosity flag.
63
+
64
+ - **`Easyop::Schema` spec `strict_types` contamination across examples** — Setting `strict_types = true` inside an example without a corresponding teardown left the global config dirty for later examples. Added `after(:each) { Easyop.reset_config! }` at the top-level `RSpec.describe` so every example begins with a clean configuration.
65
+
66
+ - **`Easyop::Plugins::Instrumentation` flaky duration assertion** — The `:duration` payload is computed as `(elapsed_ms).round(2)`, which rounds to `0.0` for operations completing in under 0.005 ms. Relaxed the spec assertion from `be > 0` to `be >= 0` to reflect that a rounded-to-zero duration is a valid measurement, not an error.
67
+
10
68
  ## [0.1.3] — 2026-04-14
11
69
 
12
70
  ### Added
@@ -200,5 +258,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
200
258
  - `examples/easyop_test_app/` — full Rails 8 blog application demonstrating all features in real-world code
201
259
  - `examples/usage.rb` — 13 runnable plain-Ruby examples
202
260
 
203
- [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.2...HEAD
261
+ [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.4...HEAD
262
+ [0.1.4]: https://github.com/pniemczyk/easyop/compare/v0.1.3...v0.1.4
204
263
  [0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
data/README.md CHANGED
@@ -428,6 +428,39 @@ class FullCheckout
428
428
  end
429
429
  ```
430
430
 
431
+ ### Recording plugin integration — full call-tree tracing
432
+
433
+ When step operations have the Recording plugin installed, `Easyop::Flow` automatically forwards the parent-tracing ctx so every step's log entry shows the flow as its `parent_operation_name`. All steps and the flow share the same `root_reference_id`.
434
+
435
+ **Bare flow** (Recording only on steps — flow is NOT recorded itself, but steps carry correct parent info):
436
+
437
+ ```ruby
438
+ class ProcessCheckout
439
+ include Easyop::Flow
440
+ flow ValidateCart, ChargePayment, CreateOrder
441
+ end
442
+ ```
443
+
444
+ **Recommended** — inherit from your recorded base class so the **flow itself appears in operation_logs** as the tree root. Add `transactional false` so step-level transactions aren't shadowed by an outer one:
445
+
446
+ ```ruby
447
+ class ProcessCheckout < ApplicationOperation
448
+ include Easyop::Flow
449
+ transactional false # EasyOp handles rollback; each step owns its transaction
450
+
451
+ flow ValidateCart, ChargePayment, CreateOrder
452
+ end
453
+ ```
454
+
455
+ Result in `operation_logs`:
456
+
457
+ ```
458
+ ProcessCheckout root=aaa ref=bbb parent=nil
459
+ ValidateCart root=aaa ref=ccc parent=ProcessCheckout/bbb
460
+ ChargePayment root=aaa ref=ddd parent=ProcessCheckout/bbb
461
+ CreateOrder root=aaa ref=eee parent=ProcessCheckout/bbb
462
+ ```
463
+
431
464
  ---
432
465
 
433
466
  ## `prepare` — Pre-registered Callbacks
@@ -613,6 +646,7 @@ end
613
646
  |---|---|---|
614
647
  | `model:` | required | ActiveRecord class to write logs into |
615
648
  | `record_params:` | `true` | Set `false` to skip serializing ctx params |
649
+ | `record_result:` | `nil` | Plugin-level default for result capture (Hash/Proc/Symbol — see below) |
616
650
 
617
651
  **Required model columns:**
618
652
 
@@ -627,8 +661,91 @@ create_table :operation_logs do |t|
627
661
  end
628
662
  ```
629
663
 
664
+ **Optional flow-tracing columns:**
665
+
666
+ Add these columns to reconstruct the full call tree when nested flows run. They are populated automatically when present — missing columns are silently skipped (backward-compatible):
667
+
668
+ ```ruby
669
+ add_column :operation_logs, :root_reference_id, :string
670
+ add_column :operation_logs, :reference_id, :string
671
+ add_column :operation_logs, :parent_operation_name, :string
672
+ add_column :operation_logs, :parent_reference_id, :string
673
+
674
+ add_index :operation_logs, :root_reference_id
675
+ add_index :operation_logs, :reference_id, unique: true
676
+ add_index :operation_logs, :parent_reference_id
677
+ ```
678
+
679
+ All operations triggered by a single top-level call share the same `root_reference_id`. The `parent_operation_name` and `parent_reference_id` columns link each operation to its direct caller. `Easyop::Flow` automatically forwards these ctx keys to child steps — see the [Flow section](#flow--composing-operations) for how to make the flow itself appear as the tree root. Example (flow with nested steps):
680
+
681
+ ```
682
+ FullCheckout root=aaa ref=bbb parent=nil
683
+ AuthAndValidate root=aaa ref=ccc parent=FullCheckout/bbb
684
+ AuthUser root=aaa ref=ddd parent=AuthAndValidate/ccc
685
+ ProcessPayment root=aaa ref=eee parent=FullCheckout/bbb
686
+ ```
687
+
688
+ Useful model helpers:
689
+
690
+ ```ruby
691
+ scope :for_tree, ->(id) { where(root_reference_id: id).order(:performed_at) }
692
+ def root?; parent_reference_id.nil?; end
693
+
694
+ # Fetch the entire execution tree for one top-level call:
695
+ root_log = OperationLog.find_by(operation_name: "FullCheckout", parent_reference_id: nil)
696
+ OperationLog.for_tree(root_log.root_reference_id)
697
+ ```
698
+
630
699
  The plugin automatically scrubs these keys from `params_data` before persisting: `:password`, `:password_confirmation`, `:token`, `:secret`, `:api_key`. ActiveRecord objects are serialized as `{ id:, class: }` rather than their full representation.
631
700
 
701
+ **`record_result` DSL — capture output data:**
702
+
703
+ Add an optional `result_data :text` column to persist selected ctx values after the operation runs:
704
+
705
+ ```ruby
706
+ add_column :operation_logs, :result_data, :text # stored as JSON
707
+ ```
708
+
709
+ Then declare what to record using the `record_result` DSL (three forms):
710
+
711
+ ```ruby
712
+ # Attrs form — one or more ctx keys
713
+ class PlaceOrder < ApplicationOperation
714
+ record_result attrs: :order_id
715
+ end
716
+
717
+ class ProcessPayment < ApplicationOperation
718
+ record_result attrs: [:charge_id, :amount_cents]
719
+ end
720
+
721
+ # Block form — custom extraction
722
+ class GenerateReport < ApplicationOperation
723
+ record_result { |ctx| { rows: ctx.rows.count, format: ctx.format } }
724
+ end
725
+
726
+ # Symbol form — delegates to a private instance method
727
+ class BuildInvoice < ApplicationOperation
728
+ record_result :build_result
729
+
730
+ private
731
+
732
+ def build_result
733
+ { invoice_id: ctx.invoice.id, total: ctx.total }
734
+ end
735
+ end
736
+ ```
737
+
738
+ Set a plugin-level default inherited by all subclasses:
739
+
740
+ ```ruby
741
+ plugin Easyop::Plugins::Recording, model: OperationLog,
742
+ record_result: { attrs: :metadata }
743
+ # or: record_result: ->(ctx) { { id: ctx.record_id } }
744
+ # or: record_result: :build_result
745
+ ```
746
+
747
+ Class-level `record_result` overrides the plugin-level default. Missing ctx keys produce `nil` — no error. The `result_data` column is silently skipped when absent from the model table — fully backward-compatible.
748
+
632
749
  **Opt out per class:**
633
750
 
634
751
  ```ruby
data/lib/easyop/flow.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require 'securerandom'
2
+
1
3
  module Easyop
2
4
  # Compose a sequence of operations that share a single ctx.
3
5
  #
@@ -11,6 +13,27 @@ module Easyop
11
13
  # flow ValidateCart, ChargeCard, CreateOrder, NotifyUser
12
14
  # end
13
15
  #
16
+ # ## Recording plugin integration (flow tracing)
17
+ #
18
+ # When steps have the Recording plugin installed, `CallBehavior#call` forwards
19
+ # the parent ctx keys so every step log entry shows the flow as its parent:
20
+ #
21
+ # # Bare flow — flow itself is not recorded but steps see it as parent:
22
+ # class ProcessOrder
23
+ # include Easyop::Flow
24
+ # flow ValidateCart, ChargeCard
25
+ # end
26
+ #
27
+ # For full tree reconstruction (flow appears in operation_logs as the root
28
+ # entry) inherit from your recorded base class and opt out of the transaction
29
+ # so step-level transactions are not shadowed by an outer one:
30
+ #
31
+ # class ProcessOrder < ApplicationOperation
32
+ # include Easyop::Flow
33
+ # transactional false # EasyOp handles rollback; steps own their transactions
34
+ # flow ValidateCart, ChargeCard
35
+ # end
36
+ #
14
37
  # result = ProcessOrder.call(user: user, cart: cart)
15
38
  # result.on_success { |ctx| redirect_to order_path(ctx.order) }
16
39
  # result.on_failure { |ctx| flash[:alert] = ctx.error }
@@ -31,6 +54,25 @@ module Easyop
31
54
  # otherwise place Operation earlier in the ancestor chain than Flow itself).
32
55
  module CallBehavior
33
56
  def call
57
+ # ── Flow-tracing forwarding for the Recording plugin ──────────────────
58
+ # When Recording is NOT installed on this flow class (i.e. the flow does
59
+ # not inherit from a base operation that has Recording), set the
60
+ # __recording_parent_* ctx keys manually so every step operation knows
61
+ # this flow is its parent. When Recording IS installed on the flow (its
62
+ # RunWrapper runs before `call` is reached), it has already set up the
63
+ # parent context correctly — we detect that via _recording_enabled? and
64
+ # skip to avoid a conflict. If Recording is not used at all, these ctx
65
+ # keys are unused and ignored.
66
+ _flow_tracing = self.class.name &&
67
+ !self.class.respond_to?(:_recording_enabled?)
68
+ if _flow_tracing
69
+ ctx[:__recording_root_reference_id] ||= SecureRandom.uuid
70
+ _prev_parent_name = ctx[:__recording_parent_operation_name]
71
+ _prev_parent_id = ctx[:__recording_parent_reference_id]
72
+ ctx[:__recording_parent_operation_name] = self.class.name
73
+ ctx[:__recording_parent_reference_id] = SecureRandom.uuid
74
+ end
75
+
34
76
  pending_guard = nil
35
77
 
36
78
  self.class._flow_steps.each do |step|
@@ -56,6 +98,12 @@ module Easyop
56
98
  rescue Ctx::Failure
57
99
  ctx.rollback!
58
100
  raise
101
+ ensure
102
+ # Restore parent context so any caller above this flow sees the right parent.
103
+ if _flow_tracing
104
+ ctx[:__recording_parent_operation_name] = _prev_parent_name
105
+ ctx[:__recording_parent_reference_id] = _prev_parent_id
106
+ end
59
107
  end
60
108
  end
61
109
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module Easyop
4
6
  module Plugins
5
7
  # Records operation executions to a database model.
@@ -18,6 +20,15 @@ module Easyop
18
20
  # duration_ms :float
19
21
  # performed_at :datetime, null: false
20
22
  #
23
+ # Optional flow-tracing columns:
24
+ # root_reference_id :string # shared across entire execution tree
25
+ # reference_id :string # unique to this operation execution
26
+ # parent_operation_name :string # class name of the direct parent
27
+ # parent_reference_id :string # reference_id of the direct parent
28
+ #
29
+ # Optional result column:
30
+ # result_data :text # stored as JSON — selected ctx keys after call
31
+ #
21
32
  # Opt out per operation class:
22
33
  # class MyOp < ApplicationOperation
23
34
  # recording false
@@ -26,15 +37,24 @@ module Easyop
26
37
  # Options:
27
38
  # model: (required) ActiveRecord class
28
39
  # record_params: true pass false to skip params serialization
40
+ # record_result: nil configure result capture at plugin level (Hash/Proc/Symbol)
29
41
  module Recording
30
42
  # Sensitive keys scrubbed from params_data before persisting.
31
43
  SCRUBBED_KEYS = %i[password password_confirmation token secret api_key].freeze
32
44
 
33
- def self.install(base, model:, record_params: true, **_options)
45
+ # Internal ctx keys used for flow tracing — excluded from params_data.
46
+ INTERNAL_CTX_KEYS = %i[
47
+ __recording_root_reference_id
48
+ __recording_parent_operation_name
49
+ __recording_parent_reference_id
50
+ ].freeze
51
+
52
+ def self.install(base, model:, record_params: true, record_result: nil, **_options)
34
53
  base.extend(ClassMethods)
35
54
  base.prepend(RunWrapper)
36
- base.instance_variable_set(:@_recording_model, model)
37
- base.instance_variable_set(:@_recording_record_params, record_params)
55
+ base.instance_variable_set(:@_recording_model, model)
56
+ base.instance_variable_set(:@_recording_record_params, record_params)
57
+ base.instance_variable_set(:@_recording_record_result, record_result)
38
58
  end
39
59
 
40
60
  module ClassMethods
@@ -62,6 +82,33 @@ module Easyop
62
82
  true
63
83
  end
64
84
  end
85
+
86
+ # DSL for capturing result data after the operation runs.
87
+ # Three forms:
88
+ # record_result attrs: :key # one or more ctx keys
89
+ # record_result { |ctx| { k: ctx.k } } # block
90
+ # record_result :build_result # private instance method name
91
+ def record_result(value = nil, attrs: nil, &block)
92
+ @_recording_record_result = if block
93
+ block
94
+ elsif attrs
95
+ { attrs: attrs }
96
+ elsif value.is_a?(Symbol)
97
+ value
98
+ else
99
+ value
100
+ end
101
+ end
102
+
103
+ def _recording_record_result_config
104
+ if instance_variable_defined?(:@_recording_record_result)
105
+ @_recording_record_result
106
+ elsif superclass.respond_to?(:_recording_record_result_config)
107
+ superclass._recording_record_result_config
108
+ else
109
+ nil
110
+ end
111
+ end
65
112
  end
66
113
 
67
114
  module RunWrapper
@@ -70,6 +117,23 @@ module Easyop
70
117
  return super unless (model = self.class._recording_model)
71
118
  return super unless self.class.name # skip anonymous classes
72
119
 
120
+ # -- Flow tracing --
121
+ # Each operation gets its own reference_id. The root_reference_id is
122
+ # shared across the entire execution tree via ctx (set once, inherited).
123
+ reference_id = SecureRandom.uuid
124
+ root_reference_id = ctx[:__recording_root_reference_id] ||= SecureRandom.uuid
125
+
126
+ # Read current parent context — these become THIS operation's parent fields.
127
+ parent_operation_name = ctx[:__recording_parent_operation_name]
128
+ parent_reference_id = ctx[:__recording_parent_reference_id]
129
+
130
+ # Set THIS operation as the parent for any children that run inside super.
131
+ # Save the previous values so we can restore them after (for siblings).
132
+ prev_parent_name = parent_operation_name
133
+ prev_parent_id = parent_reference_id
134
+ ctx[:__recording_parent_operation_name] = self.class.name
135
+ ctx[:__recording_parent_reference_id] = reference_id
136
+
73
137
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
138
  super
75
139
  ensure
@@ -78,22 +142,37 @@ module Easyop
78
142
  # branch the tap block would be skipped and failures inside flows would
79
143
  # never be persisted.
80
144
  if start
145
+ # Restore parent context so sibling steps see the correct parent.
146
+ ctx[:__recording_parent_operation_name] = prev_parent_name
147
+ ctx[:__recording_parent_reference_id] = prev_parent_id
148
+
81
149
  ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
82
- _recording_persist!(ctx, model, ms)
150
+ _recording_persist!(ctx, model, ms,
151
+ root_reference_id: root_reference_id,
152
+ reference_id: reference_id,
153
+ parent_operation_name: parent_operation_name,
154
+ parent_reference_id: parent_reference_id)
83
155
  end
84
156
  end
85
157
 
86
158
  private
87
159
 
88
- def _recording_persist!(ctx, model, duration_ms)
160
+ def _recording_persist!(ctx, model, duration_ms,
161
+ root_reference_id: nil, reference_id: nil,
162
+ parent_operation_name: nil, parent_reference_id: nil)
89
163
  attrs = {
90
- operation_name: self.class.name,
91
- success: ctx.success?,
92
- error_message: ctx.error,
93
- performed_at: Time.current,
94
- duration_ms: duration_ms
164
+ operation_name: self.class.name,
165
+ success: ctx.success?,
166
+ error_message: ctx.error,
167
+ performed_at: Time.current,
168
+ duration_ms: duration_ms,
169
+ root_reference_id: root_reference_id,
170
+ reference_id: reference_id,
171
+ parent_operation_name: parent_operation_name,
172
+ parent_reference_id: parent_reference_id
95
173
  }
96
174
  attrs[:params_data] = _recording_safe_params(ctx) if self.class._recording_record_params?
175
+ attrs[:result_data] = _recording_safe_result(ctx) if self.class._recording_record_result_config
97
176
 
98
177
  # Only write columns the model actually has
99
178
  safe = attrs.select { |k, _| model.column_names.include?(k.to_s) }
@@ -104,13 +183,35 @@ module Easyop
104
183
 
105
184
  def _recording_safe_params(ctx)
106
185
  ctx.to_h
107
- .except(*SCRUBBED_KEYS)
186
+ .except(*SCRUBBED_KEYS, *INTERNAL_CTX_KEYS)
108
187
  .transform_values { |v| v.is_a?(ActiveRecord::Base) ? { id: v.id, class: v.class.name } : v }
109
188
  .to_json
110
189
  rescue
111
190
  nil
112
191
  end
113
192
 
193
+ def _recording_safe_result(ctx)
194
+ config = self.class._recording_record_result_config
195
+ return nil unless config
196
+
197
+ raw = case config
198
+ when Hash
199
+ keys = Array(config[:attrs])
200
+ keys.each_with_object({}) { |k, h| h[k] = ctx[k] }
201
+ when Proc
202
+ config.call(ctx)
203
+ when Symbol
204
+ send(config)
205
+ end
206
+
207
+ return nil unless raw.is_a?(Hash)
208
+
209
+ raw.transform_values { |v| v.is_a?(ActiveRecord::Base) ? { id: v.id, class: v.class.name } : v }
210
+ .to_json
211
+ rescue
212
+ nil
213
+ end
214
+
114
215
  def _recording_warn(err)
115
216
  return unless defined?(Rails) && Rails.respond_to?(:logger)
116
217
  Rails.logger.warn "[EasyOp::Recording] Failed to record #{self.class.name}: #{err.message}"
data/lib/easyop/schema.rb CHANGED
@@ -161,7 +161,7 @@ module Easyop
161
161
  if Easyop.config.strict_types
162
162
  ctx.fail!(error: msg, errors: ctx.errors.merge(field.name => msg))
163
163
  else
164
- warn "[Easyop] #{msg}"
164
+ $stderr.puts "[Easyop] #{msg}"
165
165
  end
166
166
  end
167
167
  end
@@ -1,3 +1,3 @@
1
1
  module Easyop
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.4"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easyop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pawel Niemczyk