circulator 2.1.4 → 2.1.6

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: 9126192a7ce18956bc8c27842e52765dac25e9436e322ad259100e5138a5b97f
4
- data.tar.gz: f1e15524d5806a3f0b0455bd006e8ba5e3914b13e046ab0d2acd1dbcb897ea27
3
+ metadata.gz: 128d49edd784a3a0cc8ebc72ee4ca9be581bbea4ab57ff795a7c7435a52e3ddb
4
+ data.tar.gz: ae4a7ca3dd48197e944c95516f862e9f08cbc082ad098d0ef3d589fe351d59c7
5
5
  SHA512:
6
- metadata.gz: 03ad68c5d43c45fddf1b7d5c3f84dd845363b2bf8cc1a881463efbd1b1e43d233e196e965dae157726b2ac0b90066d9ec05087f12339667ffd1e9effe0213e54
7
- data.tar.gz: 7590c58b27a608f4d49f6041c9fed8a4fd5a05526cd5c36eba40a12346ce5a63cd6f9f0a6253c48ec6aa7405e9e4b5ed8121fae276404a1a32f4587118453c2f
6
+ metadata.gz: 5792c5c3fc86dc3a7d75fd619229426a3a8ae8198bee46fe28d7ec54f3d78ca374732c71281177fb53a8e54d5f1ed256af8163cd70420739b4450fb0f5b0589d
7
+ data.tar.gz: f7fc9e7bf3e10331306a3161c7feff64e09b0dd1933f52f054f98076256c2287e40c6ddd7ac7b6c7eee39bb55442ca3f3045179cc889378d87b9c59eb74dc971
data/CHANGELOG.md CHANGED
@@ -5,24 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [2.1.4] - 2025-11-03
9
-
10
- ### Added
11
-
12
- - Symbol-based allow_if support (a96b794)
13
- - Documentation for symbol-based allow_if (d3befc1)
14
- - available_flows method with guard and argument support (122e2ab)
15
- - available_flow? predicate method (122e2ab)
16
- - Documentation for query methods (a75507e)
8
+ ## [2.1.6] - 2025-12-08
17
9
 
18
10
  ### Changed
19
11
 
20
- - Validate symbol allow_if at definition time (392eac5)
12
+ - Dependabot config to follow a 14 day cooldown. (6aabc0a)
21
13
 
22
- ### Removed
23
-
24
- - Redundant respond_to? check (40ff669)
25
-
26
- ### Fixed
14
+ ### Added
27
15
 
28
- - DOT syntax error for states ending with ? (65b503e)
16
+ - Documentation for extension system and Circulator.extension API (be7c6fc)
17
+ - --all flag to automatically discover and generate diagrams for all classes with Circulator flows (f4dbbfc)
18
+ - Automatic eager loading of Rails application classes when using --all (f4dbbfc)
data/README.md CHANGED
@@ -285,10 +285,52 @@ class Payment
285
285
  end
286
286
  ```
287
287
 
288
+ #### Extending Flows
289
+
290
+ You can extend existing flows using `Circulator.extension`:
291
+
292
+ ```ruby
293
+ class Document
294
+ extend Circulator
295
+
296
+ attr_accessor :status
297
+
298
+ flow :status do
299
+ state :draft do
300
+ action :submit, to: :review
301
+ end
302
+
303
+ state :review do
304
+ action :approve, to: :approved
305
+ end
306
+
307
+ state :approved
308
+ end
309
+ end
310
+
311
+ # Add additional states and transitions
312
+ Circulator.extension(:Document, :status) do
313
+ state :review do
314
+ action :reject, to: :rejected
315
+ end
316
+
317
+ state :rejected do
318
+ action :revise, to: :draft
319
+ end
320
+ end
321
+
322
+ doc = Document.new
323
+ doc.status = :review
324
+ doc.status_reject # => :rejected (from extension)
325
+ doc.status_revise # => :draft (from extension)
326
+ ```
327
+
288
328
  ### Generating Diagrams
289
329
 
290
330
  You can generate diagrams for your Circulator models using the `circulator-diagram` executable. By default, it will generate a DOT file. You can also generate a PlantUML file by passing the `-f plantuml` option.
291
331
 
332
+ #### Generate a diagram for a specific model:
333
+
292
334
  ```bash
293
335
  bundle exec circulator-diagram MODEL_NAME
294
336
  ```
@@ -297,6 +339,30 @@ bundle exec circulator-diagram MODEL_NAME
297
339
  bundle exec circulator-diagram MODEL_NAME -f plantuml
298
340
  ```
299
341
 
342
+ #### Generate diagrams for all models with Circulator flows:
343
+
344
+ Use the `--all` option to automatically find and generate diagrams for all classes that have Circulator flows defined:
345
+
346
+ ```bash
347
+ bundle exec circulator-diagram --all
348
+ ```
349
+
350
+ ```bash
351
+ bundle exec circulator-diagram --all -f plantuml
352
+ ```
353
+
354
+ The `--all` option will:
355
+ - Automatically discover all classes with Circulator flows (including classes that inherit from a parent that extends Circulator)
356
+ - Eager load Rails application classes if running in a Rails environment
357
+ - Generate diagrams for each class found
358
+ - Use the same output directory and format options as single-model generation
359
+
360
+ #### Other options:
361
+
362
+ - `-d, --directory DIRECTORY` - Specify output directory (default: `docs`)
363
+ - `-s, --separate` - Generate separate diagram files for each flow attribute
364
+ - `-r, --require FILE` - Require a file before loading models (e.g., `config/environment`)
365
+
300
366
  ## Why Circulator?
301
367
 
302
368
  Circulator distinguishes itself from other Ruby state machine libraries through its simplicity and flexibility:
@@ -8,17 +8,21 @@ require_relative "../lib/circulator/dot"
8
8
  require_relative "../lib/circulator/plantuml"
9
9
 
10
10
  # Parse command-line options
11
- options = {format: "dot", require: nil, directory: "docs", separate: false}
11
+ options = {format: "dot", require: nil, directory: "docs", separate: false, all: false}
12
12
  parser = OptionParser.new do |opts|
13
- opts.banner = "Usage: circulator-diagram MODEL_NAME [options]"
13
+ opts.banner = "Usage: circulator-diagram [MODEL_NAME | --all] [options]"
14
14
  opts.separator ""
15
15
  opts.separator "Generate diagram files for Circulator state machine flows"
16
16
  opts.separator ""
17
17
  opts.separator "Arguments:"
18
- opts.separator " MODEL_NAME Name of the model class with Circulator flows"
18
+ opts.separator " MODEL_NAME Name of the model class with Circulator flows (required unless --all is used)"
19
19
  opts.separator ""
20
20
  opts.separator "Options:"
21
21
 
22
+ opts.on("-a", "--all", "Generate diagrams for all classes with Circulator in their ancestry") do
23
+ options[:all] = true
24
+ end
25
+
22
26
  opts.on("-f", "--format FORMAT", ["dot", "plantuml"], "Output format (dot, plantuml). Default: dot") do |format|
23
27
  options[:format] = format
24
28
  end
@@ -54,15 +58,15 @@ rescue OptionParser::InvalidOption => e
54
58
  exit 1
55
59
  end
56
60
 
57
- # Check for required model name argument
58
- if ARGV.empty?
59
- warn "Error: MODEL_NAME is required"
61
+ # Check for required model name argument or --all flag
62
+ if ARGV.empty? && !options[:all]
63
+ warn "Error: Either MODEL_NAME or --all is required"
60
64
  warn ""
61
65
  warn parser
62
66
  exit 1
63
67
  end
64
68
 
65
- model_name = ARGV[0]
69
+ model_name = ARGV[0] unless options[:all]
66
70
 
67
71
  # Load the application environment
68
72
  # Priority: -r option > config/environment.rb > nothing
@@ -79,22 +83,63 @@ elsif File.exist?("config/environment.rb")
79
83
  require File.expand_path("config/environment.rb")
80
84
  end
81
85
 
82
- # Try to constantize the model name
83
- begin
84
- model_class = Object.const_get(model_name)
85
- rescue NameError
86
- warn "Error: Model '#{model_name}' not found"
87
- warn "Make sure the model is loaded in your environment"
88
- exit 1
86
+ # Find all classes with Circulator in their ancestry
87
+ def find_circulator_classes
88
+ classes = []
89
+
90
+ # Try to force-load classes that might not be loaded yet
91
+ # This is especially important for Rails apps with lazy loading
92
+ if defined?(Rails) && Rails.application
93
+ begin
94
+ Rails.application.eager_load!
95
+ rescue => e
96
+ warn "Warning: Could not eager load Rails application: #{e.message}"
97
+ end
98
+ end
99
+
100
+ ObjectSpace.each_object(Class) do |klass|
101
+ # Skip anonymous classes and classes without names
102
+ next if klass.name.nil? || klass.name.empty?
103
+
104
+ # Check if the class responds to :flows (set up when Circulator is extended)
105
+ next unless klass.respond_to?(:flows)
106
+
107
+ begin
108
+ flows = klass.flows
109
+ next if flows.nil? || flows.empty?
110
+ rescue => e
111
+ # Skip classes where flows method raises an error
112
+ warn "Warning: Could not get flows for #{klass.name}: #{e.class} - #{e.message}"
113
+ next
114
+ end
115
+
116
+ classes << klass
117
+ rescue => e
118
+ # Skip any class that causes an error during inspection
119
+ if ENV["DEBUG"]
120
+ warn "Warning: Error inspecting class #{begin
121
+ klass.name
122
+ rescue
123
+ "unknown"
124
+ end}: #{e.class} - #{e.message}"
125
+ end
126
+ next
127
+ end
128
+
129
+ classes.sort_by(&:name)
89
130
  end
90
131
 
91
- # Generate diagram file(s)
92
- begin
93
- generator = case options[:format]
94
- when "plantuml"
95
- Circulator::PlantUml.new(model_class)
96
- else
97
- Circulator::Dot.new(model_class)
132
+ # Generate diagram for a single model class
133
+ def generate_diagram_for_class(model_class, options)
134
+ begin
135
+ generator = case options[:format]
136
+ when "plantuml"
137
+ Circulator::PlantUml.new(model_class)
138
+ else
139
+ Circulator::Dot.new(model_class)
140
+ end
141
+ rescue => e
142
+ raise "Failed to initialize diagram generator for #{model_class.name}: #{e.class} - #{e.message}"
98
143
  end
99
144
 
100
145
  # Determine base output filename and extension
@@ -163,6 +208,59 @@ begin
163
208
  puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], base_name)}.png"
164
209
  end
165
210
  end
211
+ end
212
+
213
+ # Collect classes to generate diagrams for
214
+ begin
215
+ if options[:all]
216
+ # Find all classes with Circulator in their ancestry
217
+ begin
218
+ classes_to_process = find_circulator_classes
219
+ rescue => e
220
+ warn "Error finding Circulator classes: #{e.class} - #{e.message}"
221
+ warn e.backtrace.first(10).join("\n")
222
+ exit 1
223
+ end
224
+
225
+ if classes_to_process.empty?
226
+ warn "No classes with Circulator flows found"
227
+ exit 1
228
+ end
229
+
230
+ puts "Found #{classes_to_process.size} class(es) with Circulator flows:"
231
+ classes_to_process.each do |klass|
232
+ puts " - #{klass.name}"
233
+ end
234
+ puts ""
235
+ else
236
+ # Try to constantize the model name
237
+ begin
238
+ model_class = Object.const_get(model_name)
239
+ rescue NameError
240
+ warn "Error: Model '#{model_name}' not found"
241
+ warn "Make sure the model is loaded in your environment"
242
+ exit 1
243
+ end
244
+
245
+ classes_to_process = [model_class]
246
+ end
247
+
248
+ # Generate diagrams for all classes in the array
249
+ classes_to_process.each do |model_class|
250
+ if classes_to_process.size > 1
251
+ puts "Generating diagram for #{model_class.name}..."
252
+ end
253
+
254
+ begin
255
+ generate_diagram_for_class(model_class, options)
256
+ rescue => e
257
+ warn "Error generating diagram for #{model_class.name}: #{e.class} - #{e.message}"
258
+ warn e.backtrace.first(5).join("\n") if ENV["DEBUG"]
259
+ next
260
+ end
261
+
262
+ puts "" if classes_to_process.size > 1
263
+ end
166
264
 
167
265
  exit 0
168
266
  rescue ArgumentError => e
@@ -2,13 +2,19 @@
2
2
 
3
3
  module Circulator
4
4
  class Flow
5
- def initialize(klass, attribute_name, states = Set.new, &block)
5
+ def initialize(klass, attribute_name, states = Set.new, extension: false, flows_proc: Circulator.default_flow_proc, &block)
6
6
  @klass = klass
7
7
  @attribute_name = attribute_name
8
8
  @states = states
9
9
  @no_action = ->(attribute_name, action) { raise "No action found for the current state of #{attribute_name} (#{send(attribute_name)}): #{action}" }
10
- @transition_map = {}
11
- instance_eval(&block)
10
+ @flows_proc = flows_proc
11
+ @transition_map = flows_proc.call
12
+
13
+ # Execute the main flow block
14
+ instance_eval(&block) if block
15
+
16
+ # Apply any registered extensions (unless explicitly disabled)
17
+ apply_extensions unless extension
12
18
  end
13
19
  attr_reader :transition_map
14
20
 
@@ -28,7 +34,7 @@ module Circulator
28
34
  validate_allow_if(allow_if)
29
35
  end
30
36
 
31
- @transition_map[name] ||= {}
37
+ @transition_map[name] ||= @flows_proc.call
32
38
  selected_state = (from == :__not_specified__) ? @current_state : from
33
39
 
34
40
  # Handle nil case specially - convert to [nil] instead of []
@@ -48,8 +54,10 @@ module Circulator
48
54
  @states.add(to_state)
49
55
  end
50
56
 
51
- @transition_map[name][from_state] = {to:, block:}
52
- @transition_map[name][from_state][:allow_if] = allow_if if allow_if
57
+ # Build transition data hash with all keys at once
58
+ transition_data = {to:, block:}
59
+ transition_data[:allow_if] = allow_if if allow_if
60
+ @transition_map[name][from_state] = transition_data
53
61
  end
54
62
  end
55
63
 
@@ -132,5 +140,33 @@ module Circulator
132
140
  raise ArgumentError, "allow_if references invalid states #{invalid_states.inspect} for :#{attribute_name}. Valid states: #{referenced_states.to_a.inspect}"
133
141
  end
134
142
  end
143
+
144
+ def apply_extensions
145
+ # Look up extensions for this class and attribute
146
+ class_name = if @klass.is_a?(Class)
147
+ @klass.name || @klass.to_s
148
+ else
149
+ Circulator.model_key(@klass)
150
+ end
151
+ key = "#{class_name}:#{@attribute_name}"
152
+ extensions = Circulator.extensions[key]
153
+
154
+ # Apply each extension by creating a new Flow and merging its transition_map
155
+ extensions.each do |extension_block|
156
+ extension_flow = Flow.new(@klass, @attribute_name, @states, extension: true, flows_proc: @flows_proc, &extension_block)
157
+ extension_flow.transition_map.each do |action, transitions|
158
+ @transition_map[action] = if @transition_map[action]
159
+ @transition_map[action].merge(transitions)
160
+ else
161
+ transitions
162
+ end
163
+ end
164
+
165
+ # Merge states from extension
166
+ extension_flow.instance_variable_get(:@states).each do |state|
167
+ @states.add(state)
168
+ end
169
+ end
170
+ end
135
171
  end
136
172
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.4"
4
+ VERSION = "2.1.6"
5
5
  end
data/lib/circulator.rb CHANGED
@@ -2,6 +2,32 @@ require "circulator/version"
2
2
  require "circulator/flow"
3
3
 
4
4
  module Circulator
5
+ # Global registry for extensions
6
+ @extensions = Hash.new { |h, k| h[k] = [] }
7
+
8
+ @default_flow_proc = ::Hash.method(:new)
9
+ class << self
10
+ attr_reader :extensions
11
+ attr_reader :default_flow_proc
12
+
13
+ # Register an extension for a specific class and attribute
14
+ #
15
+ # Example:
16
+ #
17
+ # Circulator.extension(:Document, :status) do
18
+ # state :pending do
19
+ # action :send_to_legal, to: :legal_review
20
+ # end
21
+ # end
22
+ #
23
+ # Extensions are automatically applied when the class defines its flow
24
+ def extension(class_name, attribute_name, &block)
25
+ raise ArgumentError, "Block required for extension" unless block_given?
26
+
27
+ key = "#{class_name}:#{attribute_name}"
28
+ @extensions[key] << block
29
+ end
30
+ end
5
31
  # Declare a flow for an attribute.
6
32
  #
7
33
  # Specify the attribute to be used for states and actions.
@@ -125,11 +151,12 @@ module Circulator
125
151
  # test_object.flow(:unknown, :status, "signal")
126
152
  # # Will raise an UnhandledSignalError
127
153
  #
128
- def flow(attribute_name, model: to_s, &block)
129
- @flows ||= {}
154
+ def flow(attribute_name, model: to_s, flows_proc: Circulator.default_flow_proc, &block)
155
+ @flows ||= flows_proc.call
130
156
  model_key = Circulator.model_key(model)
131
- @flows[model_key] ||= {}
132
- @flows[model_key][attribute_name] = Flow.new(self, attribute_name, &block)
157
+ @flows[model_key] ||= flows_proc.call
158
+ # Pass the flows_proc to Flow so it can create transition_maps of the same type
159
+ @flows[model_key][attribute_name] = Flow.new(self, attribute_name, flows_proc:, &block)
133
160
 
134
161
  flow_module = ancestors.find { |ancestor|
135
162
  ancestor.name.to_s =~ /FlowMethods/
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: circulator
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.4
4
+ version: 2.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay