Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [0.1.13] - Unreleased

### Added

- Registry class to manage Cycle classes.
- Use of Concurrent::Set for thread safety.

## [0.1.12] - 2025-09-05

### Added
Expand Down
41 changes: 20 additions & 21 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,56 +3,54 @@ PATH
specs:
sof-cycle (0.1.13)
activesupport (>= 6.0)
concurrent-ruby (~> 1.0)
forwardable

GEM
remote: https://rubygems.org/
specs:
activesupport (7.1.5.2)
activesupport (8.0.2.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
base64 (0.3.0)
benchmark (0.4.1)
bigdecimal (3.2.3)
cgi (0.5.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
date (3.4.1)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
diff-lcs (1.6.2)
docile (1.4.0)
docile (1.4.1)
drb (2.2.3)
erb (4.0.4)
cgi (>= 0.3.3)
erb (5.0.2)
forwardable (1.3.3)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
io-console (0.8.0)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.12.2)
json (2.13.2)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
minitest (5.25.5)
mutex_m (0.3.0)
parallel (1.27.0)
parser (3.3.8.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
pp (0.6.2)
Expand All @@ -65,27 +63,27 @@ GEM
racc (1.8.1)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.1)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
regexp_parser (2.10.0)
regexp_parser (2.11.2)
reissue (0.4.1)
rake
reline (0.6.1)
reline (0.6.2)
io-console (~> 0.5)
rspec (3.13.1)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.4)
rspec-core (3.13.5)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.4)
rspec-support (3.13.5)
rubocop (1.75.8)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
Expand All @@ -97,20 +95,20 @@ GEM
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.45.0)
rubocop-ast (1.46.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.25.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (1.13.0)
securerandom (0.3.2)
securerandom (0.4.1)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
standard (1.50.0)
language_server-protocol (~> 3.17.0.2)
Expand All @@ -127,9 +125,10 @@ GEM
stringio (3.1.7)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4)
unicode-display_width (3.1.5)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)

PLATFORMS
arm64-darwin-23
Expand Down
53 changes: 29 additions & 24 deletions lib/sof/cycle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "forwardable"
require_relative "parser"
require_relative "cycle_registry"

module SOF
class Cycle
Expand All @@ -25,7 +26,7 @@ def dump(cycle_or_string)
# Return a Cycle object from a hash
def load(hash)
symbolized_hash = hash.symbolize_keys
cycle_class = class_for_kind(symbolized_hash[:kind])
cycle_class = registry.handling(symbolized_hash[:kind])

unless cycle_class.valid_periods.empty?
cycle_class.validate_period(
Expand All @@ -46,7 +47,7 @@ def notation(hash)
volume_notation = "V#{hash.fetch(:volume) { 1 }}"
return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only

cycle_class = class_for_kind(hash[:kind].to_sym)
cycle_class = registry.handling(hash[:kind].to_sym)
[
volume_notation,
cycle_class.notation_id,
Expand All @@ -69,24 +70,33 @@ def for(notation)
raise InvalidInput, "'#{notation}' is not a valid input"
end

cycle = Cycle.cycle_handlers.find do |klass|
parser.parses?(klass.notation_id)
end.new(notation, parser:)
cycle_class = registry.cycle_classes.find do |klass|
klass.respond_to?(:notation_id) && parser.parses?(klass.notation_id)
end

raise InvalidKind, "No cycle class found for notation '#{notation}'" unless cycle_class

# Validate period if applicable
if cycle_class.respond_to?(:valid_periods) && !cycle_class.valid_periods.empty? && parser.period_key
cycle_class.validate_period(parser.period_key)
end

cycle = cycle_class.new(notation, parser:)
return cycle if parser.active?

Cycles::Dormant.new(cycle, parser:)
end

def registry = CycleRegistry.instance

# Return the appropriate class for the give notation id
#
# @param notation [String] notation id matching the kind of Cycle class
# @example
# class_for_notation_id('L')
#
def class_for_notation_id(notation_id)
Cycle.cycle_handlers.find do |klass|
klass.notation_id == notation_id
end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
registry.handling(notation_id)
end

# Return the class handling the kind
Expand All @@ -95,9 +105,7 @@ def class_for_notation_id(notation_id)
# @example
# class_for_kind(:lookback)
def class_for_kind(sym)
Cycle.cycle_handlers.find do |klass|
klass.handles?(sym)
end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
registry.handling(sym)
end

# Return a legend explaining all notation components
Expand Down Expand Up @@ -132,6 +140,15 @@ def volume_only? = @volume_only

def recurring? = raise "#{name} must implement #{__method__}"

def inherited(subclass)
registry.register(subclass)
end

def handles?(kind)
return false if kind.nil?
@kind == kind.to_sym
end

# Raises an error if the given period isn't in the list of valid periods.
#
# @param period [String] period matching the class valid periods
Expand All @@ -143,23 +160,11 @@ def validate_period(period)
ERR
end

def handles?(sym)
kind.to_s == sym.to_s
end

def cycle_handlers
@cycle_handlers ||= Set.new
end

def inherited(klass)
Cycle.cycle_handlers << klass
end

private

def build_kind_legend
legend = {}
Cycle.cycle_handlers.each do |handler|
registry.cycle_classes.each do |handler|
# Skip volume_only since it doesn't have a notation_id
next if handler.instance_variable_get(:@volume_only)

Expand Down
20 changes: 20 additions & 0 deletions lib/sof/cycle_registry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
require "singleton"
require "concurrent/set"

module SOF
class CycleRegistry
include Singleton

def register(cycle_class)
cycle_classes << cycle_class
end

def cycle_classes
@cycle_classes ||= Concurrent::Set.new
end

def handling(kind)
cycle_classes.find { |klass| klass.respond_to?(:handles?) && klass.handles?(kind) } || raise(Cycle::InvalidKind, "':#{kind}' is not a valid kind of Cycle")
end
end
end
39 changes: 33 additions & 6 deletions lib/sof/time_span.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# frozen_string_literal: true

require "concurrent/set"
require "concurrent/hash"

module SOF
# This class is not intended to be referenced directly.
# This is an internal implementation of Cycle behavior.
Expand Down Expand Up @@ -60,19 +63,21 @@ class DatePeriod
extend Forwardable
class << self
def for(count, period_notation)
@cached_periods ||= {}
@cached_periods[period_notation] ||= {}
@cached_periods ||= Concurrent::Hash.new
@cached_periods[period_notation] ||= Concurrent::Hash.new
@cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count)
@cached_periods[period_notation][count]
end

def for_notation(notation)
return NullPeriod if notation.nil? || notation.to_s.empty?

DatePeriod.types.find do |klass|
klass.code == notation.to_s.upcase
end
end || raise(InvalidPeriod, "'#{notation}' is not a valid period")
end

def types = @types ||= Set.new
def types = @types ||= Concurrent::Set.new

def inherited(klass)
DatePeriod.types << klass
Expand All @@ -92,12 +97,12 @@ def initialize(count)
attr_reader :count

def end_date(date)
@end_date ||= {}
@end_date ||= Concurrent::Hash.new
@end_date[date] ||= date + duration
end

def begin_date(date)
@begin_date ||= {}
@begin_date ||= Concurrent::Hash.new
@begin_date[date] ||= date - duration
end

Expand All @@ -111,6 +116,28 @@ def humanized_period
"#{period}s"
end

class NullPeriod < self
@period = nil
@code = nil
@interval = nil

def initialize(count = nil)
@count = nil
end

def duration = 0

def end_date(date) = date

def begin_date(date) = date

def end_of_period(date) = nil

def beginning_of_period(date) = nil

def humanized_period = ""
end

class Year < self
@period = :year
@code = "Y"
Expand Down
1 change: 1 addition & 0 deletions sof-cycle.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ Gem::Specification.new do |spec|

spec.add_dependency "forwardable"
spec.add_dependency "activesupport", ">= 6.0"
spec.add_dependency "concurrent-ruby", "~> 1.0"
end
Loading