cattri 0.2.1 → 0.2.2

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: 6570d6da579ae6a15c865884a895a1ef0923eafd610d2a97a474d4a888af5778
4
- data.tar.gz: 6f01dcc2490c4c4ef159d5fcf88c87ab50204c293ce403e87440b87130add491
3
+ metadata.gz: f432670c63ac3fc653b105988804c219c7f73e0023c7fc1e11b2f56a16a9ac66
4
+ data.tar.gz: 4302d8ffafd8c3f0e321aea61ec5c06e42ec7c5178130768c967b25ed0bcdf43
5
5
  SHA512:
6
- metadata.gz: 9dbe7c5e9a7a8f4edcc160dc6952f42e80fd0ee5c602b65113fda8059663634c9c3d7d418044a7584626e8c9294240ad78be77a32b5dcabfa94d8498d2f76178
7
- data.tar.gz: 301099ebc23bc730ada63468003fe1c9cd462e0040cbc8bc194d4a015d03a59c2b777482a4a142d5c215d93bc4083b7d2f5e33dec172e97ba343b5c48085f1a5
6
+ metadata.gz: '08c0a0719c6699e42a40c92081700a0466ae78f3eda2ff08e34c75f76006c9a9bf915b65235893ee72ff28a0402c553b2dbca4ea596591a33cf1acd28a6fa975'
7
+ data.tar.gz: 99c6caeb8fd09b5c0a3de773efec82f0f63648d73ba1f2e7597351a17bea6450b7b05ec8ca952cf0f2e0e9abc7a974738807d4b67048b8ae5190cc85ef043427
data/CHANGELOG.md CHANGED
@@ -1,8 +1,44 @@
1
+ ## [0.2.2] - 2025-05-04
2
+
3
+ No breaking changes – the public DSL (cattri, final_cattri) remains identical to v0.2.0.
4
+
5
+ ### Removed
6
+ - Removed `Cattri::AttributeRegistry#copy_attributes_to` and all associated logic. Subclass attribute propagation
7
+ is now handled entirely via runtime resolution in `Context#storage_receiver_for`, eliminating the need for eager
8
+ copying of attribute values or metadata.
9
+ - Removed `Cattri::Inheritance` and its use of `.inherited` hooks for subclass propagation, since all relevant state
10
+ is now derived dynamically.
11
+
12
+ ### Changed
13
+ - Replaced static subclass propagation with dynamic storage resolution using `Context#storage_receiver_for`,
14
+ which now determines the correct backing object (instance, class, or singleton class) for all reads and writes.
15
+ - `AttributeCompiler.define_accessor` now uses `storage_receiver_for` to set final class-level attribute values
16
+ during compilation.
17
+ - Final class-level attributes (`final: true, scope: :class`) are now evaluated and written directly to the correct
18
+ storage receiver without relying on prior duplication logic.
19
+
20
+ ### Added
21
+ - Proper support for defining attributes directly on a module’s singleton_class, ensuring values are stored and
22
+ accessed consistently across runtime and functional tests.
23
+ ```ruby
24
+ module MyModule
25
+ class << self
26
+ include Cattri
27
+
28
+ cattri :version, "0.1.0", final: true, scope: :class
29
+ end
30
+ end
31
+ ```
32
+ - Regression tests ensuring:
33
+ - Subclass attribute shadowing works for `final: true, scope: :class`.
34
+ - Instance and class attribute isolation behaves as expected across inheritance chains.
35
+ - No mutation of final attributes once defined.
36
+ - Test coverage for `Context#storage_receiver` for and `Context#resolve_class_storage_for` across all branching logic.
37
+
1
38
  ## [0.2.1] - 2025-05-01
2
39
 
3
40
  - Fixed an issue where only `final: true` instance variables defined on the current/class had their values applied.
4
41
  - Now walks the ancestor tree to ensure all attributes get set.
5
-
6
42
  ```ruby
7
43
  module Options
8
44
  include Cattri
@@ -26,7 +26,8 @@ module Cattri
26
26
  def define_accessor(attribute, context)
27
27
  if attribute.class_attribute? && attribute.final?
28
28
  value = attribute.evaluate_default
29
- context.target.cattri_variable_set(attribute.ivar, value) # steep:ignore
29
+ context.storage_receiver_for(attribute) # steep:ignore
30
+ .cattri_variable_set(attribute.ivar, value, final: attribute.final?) # steep:ignore
30
31
  end
31
32
 
32
33
  return if attribute.expose == :none
@@ -48,12 +49,13 @@ module Cattri
48
49
  # @return [void]
49
50
  def define_accessor!(attribute, context)
50
51
  context.define_method(attribute) do |*args, **kwargs|
52
+ receiver = context.storage_receiver_for(attribute, self)
51
53
  readonly_call = args.empty? && kwargs.empty?
52
- return AttributeCompiler.send(:memoize_default_value, self, attribute) if readonly_call
54
+ return AttributeCompiler.send(:memoize_default_value, receiver, attribute) if readonly_call
53
55
 
54
56
  attribute.validate_assignment!
55
57
  value = attribute.process_assignment(*args, **kwargs)
56
- cattri_variable_set(attribute.ivar, value) # steep:ignore
58
+ receiver.cattri_variable_set(attribute.ivar, value) # steep:ignore
57
59
  end
58
60
  end
59
61
 
@@ -64,8 +66,10 @@ module Cattri
64
66
  # @return [void]
65
67
  def define_writer!(attribute, context)
66
68
  context.define_method(attribute, name: :"#{attribute.name}=") do |value|
69
+ receiver = context.storage_receiver_for(attribute, self)
70
+
67
71
  coerced_value = attribute.process_assignment(value)
68
- cattri_variable_set(attribute.ivar, coerced_value, final: attribute.final?) # steep:ignore
72
+ receiver.cattri_variable_set(attribute.ivar, coerced_value, final: attribute.final?) # steep:ignore
69
73
  end
70
74
  end
71
75
 
@@ -82,19 +86,10 @@ module Cattri
82
86
 
83
87
  # Returns the default value for the attribute, memoizing it in the backing store.
84
88
  #
85
- # For `final` attributes, raises unless explicitly initialized.
86
- #
87
89
  # @param receiver [Object] the instance or class receiving the value
88
90
  # @param attribute [Cattri::Attribute]
89
91
  # @return [Object] the stored or evaluated default
90
- # @raise [Cattri::AttributeError] if final attribute is unset or evaluation fails
91
92
  def memoize_default_value(receiver, attribute)
92
- if attribute.final?
93
- return receiver.cattri_variable_get(attribute.ivar) if receiver.cattri_variable_defined?(attribute.ivar)
94
-
95
- raise Cattri::AttributeError, "Final attribute :#{attribute.name} cannot be written to"
96
- end
97
-
98
93
  receiver.cattri_variable_memoize(attribute.ivar, final: attribute.final?) do
99
94
  attribute.evaluate_default
100
95
  end
@@ -87,23 +87,6 @@ module Cattri
87
87
  attribute.allowed_methods
88
88
  end
89
89
 
90
- # Copies registered attributes from this context to another,
91
- # preserving definitions and assigning values for `final: true, scope: :class`.
92
- #
93
- # @param target_context [Cattri::Context]
94
- # @return [void]
95
- def copy_attributes_to(target_context)
96
- registered_attributes.each_value do |attribute|
97
- next unless attribute.class_attribute? && attribute.final?
98
-
99
- target_registry = target_context.target.send(:attribute_registry)
100
- target_registry.send(:register_attribute, attribute)
101
-
102
- value = context.target.cattri_variable_get(attribute.ivar) # steep:ignore
103
- target_context.target.cattri_variable_set(attribute.ivar, value) # steep:ignore
104
- end
105
- end
106
-
107
90
  private
108
91
 
109
92
  # Validates that no attribute with the same name is already registered.
@@ -22,7 +22,9 @@ module Cattri
22
22
  # @return [Module]
23
23
  attr_reader :target
24
24
 
25
- # @param target [Module, Class]
25
+ # Initializes the context wrapper.
26
+ #
27
+ # @param target [Module, Class] the receiver where attributes are defined
26
28
  def initialize(target)
27
29
  @target = target
28
30
  end
@@ -102,6 +104,44 @@ module Cattri
102
104
  __cattri_defined_methods[attribute.name].include?(normalized_name)
103
105
  end
104
106
 
107
+ # Resolves and returns the module or class where methods and storage should be defined
108
+ # for the given attribute. Ensures the internal store is included on the resolved target.
109
+ #
110
+ # - For class-level attributes, this returns the singleton class unless it's already a singleton.
111
+ # - For instance-level attributes, this returns the class/module directly.
112
+ #
113
+ # @param attribute [Cattri::Attribute]
114
+ # @return [Module]
115
+ def target_for(attribute)
116
+ return @target if attribute.class_attribute? && singleton_class?(@target)
117
+
118
+ attribute.class_attribute? ? @target.singleton_class : @target
119
+ end
120
+
121
+ # Determines the object (class/module or runtime instance) that should hold
122
+ # the backing storage for the given attribute.
123
+ #
124
+ # - For class-level attributes, uses the singleton class of `defined_in` or the module itself
125
+ # - For instance-level attributes, uses the provided instance
126
+ #
127
+ # @param attribute [Cattri::Attribute]
128
+ # @param instance [Object, nil] the runtime instance, if needed for instance-level access
129
+ # @return [Object] the receiver for attribute value storage
130
+ # @raise [Cattri::Error] if instance is required but missing
131
+ def storage_receiver_for(attribute, instance = nil)
132
+ receiver =
133
+ if attribute.class_attribute?
134
+ resolve_class_storage_for(attribute, instance)
135
+ elsif instance
136
+ instance
137
+ else
138
+ raise Cattri::Error, "Missing runtime instance for instance-level attribute :#{attribute.name}"
139
+ end
140
+
141
+ install_internal_store!(receiver)
142
+ receiver
143
+ end
144
+
105
145
  private
106
146
 
107
147
  # Internal tracking of explicitly defined methods per attribute.
@@ -111,12 +151,42 @@ module Cattri
111
151
  @__cattri_defined_methods ||= Hash.new { |h, k| h[k] = Set.new }
112
152
  end
113
153
 
114
- # Determines whether to define the method on the instance or singleton.
154
+ # Determines whether the object is a singleton_class or not.
155
+ #
156
+ # @param obj [Object]
157
+ # @return [Boolean]
158
+ def singleton_class?(obj)
159
+ obj.singleton_class? # Ruby 3.2+
160
+ rescue NoMethodError
161
+ obj.inspect.start_with?("#<Class:")
162
+ end
163
+
164
+ # Resolves the proper class-level storage receiver for the attribute.
115
165
  #
116
166
  # @param attribute [Cattri::Attribute]
117
- # @return [Module]
118
- def target_for(attribute)
119
- attribute.class_attribute? ? @target.singleton_class : @target
167
+ # @param instance [Object, nil]
168
+ # @return [Object]
169
+ def resolve_class_storage_for(attribute, instance)
170
+ if attribute.final?
171
+ singleton_class?(attribute.defined_in) ? attribute.defined_in : attribute.defined_in.singleton_class
172
+ else
173
+ attribute_scope = instance || attribute.defined_in
174
+ attribute_scope.singleton_class
175
+ end
176
+ end
177
+
178
+ # Installs the internal store on the receiver if not present.
179
+ #
180
+ # @param receiver [Object]
181
+ # @return [void]
182
+ def install_internal_store!(receiver)
183
+ return if receiver.respond_to?(:cattri_variables)
184
+
185
+ if singleton_class?(receiver)
186
+ receiver.extend(Cattri::InternalStore)
187
+ else
188
+ receiver.include(Cattri::InternalStore) # steep:ignore
189
+ end
120
190
  end
121
191
 
122
192
  # Defines the method and applies its access visibility.
@@ -157,11 +227,9 @@ module Cattri
157
227
  # - Returns `:private` for instance-level attributes
158
228
  # - Otherwise, returns the explicitly declared visibility (`attribute.visibility`)
159
229
  #
160
- # This ensures that internal-only attributes remain inaccessible outside their scope,
161
- # while still being usable by subclasses if class-level.
162
- #
163
230
  # @param attribute [Cattri::Attribute]
164
- # @return [Symbol]
231
+ # @param name [Symbol]
232
+ # @return [Symbol] one of `:public`, `:protected`, or `:private`
165
233
  def effective_visibility(attribute, name)
166
234
  return :protected if attribute.class_attribute? && internal_method?(attribute, name)
167
235
  return :private if !attribute.class_attribute? && internal_method?(attribute, name)
@@ -172,14 +240,9 @@ module Cattri
172
240
  # Determines whether the given method name (accessor or writer)
173
241
  # should be treated as internal-only based on the attribute's `expose` configuration.
174
242
  #
175
- # This is used when resolving method visibility (e.g., private vs protected).
176
- #
177
- # - Writer methods (`:attr=`) are considered internal if the attribute lacks public read access.
178
- # - Reader methods (`:attr`) are considered internal if the attribute lacks public write access.
179
- #
180
- # @param attribute [Cattri::Attribute] the attribute definition
181
- # @param name [Symbol, String] the method name being defined
182
- # @return [Boolean] true if the method should be scoped for internal use only
243
+ # @param attribute [Cattri::Attribute]
244
+ # @param name [Symbol, String]
245
+ # @return [Boolean]
183
246
  def internal_method?(attribute, name)
184
247
  return attribute.internal_writer? if name.to_s.end_with?("=")
185
248
 
@@ -30,7 +30,8 @@ module Cattri
30
30
  #
31
31
  # @return [Cattri::Context] the context used for method definition and visibility tracking
32
32
  def context
33
- @context ||= Context.new(self) # steep:ignore
33
+ base = instance_variable_get(:@__cattri_base_target) || self
34
+ @context ||= Context.new(base) # steep:ignore
34
35
  end
35
36
  end
36
37
  end
@@ -31,7 +31,7 @@ module Cattri
31
31
  next if cattri_variable_defined?(attribute.ivar) # steep:ignore
32
32
  next unless attribute.final?
33
33
 
34
- cattri_variable_set(attribute.ivar, attribute.evaluate_default) # steep:ignore
34
+ cattri_variable_set(attribute.ivar, attribute.evaluate_default, final: true) # steep:ignore
35
35
  end
36
36
  end
37
37
  end
@@ -13,6 +13,17 @@ module Cattri
13
13
  #
14
14
  # It supports enforcement of `final` semantics and tracks explicit assignments.
15
15
  module InternalStore
16
+ # Returns the list of attribute keys stored by Cattri on this object.
17
+ #
18
+ # Mimics Ruby's `#instance_variables`, but only includes attributes defined
19
+ # via `cattri` and omits the leading `@` from names. All keys are returned as
20
+ # frozen symbols (e.g., `:enabled` instead of `:@enabled`).
21
+ #
22
+ # @return [Array<Symbol>] the list of internally tracked attribute keys
23
+ def cattri_variables
24
+ __cattri_store.keys.freeze
25
+ end
26
+
16
27
  # Checks whether the internal store contains a value for the given key.
17
28
  #
18
29
  # @param key [String, Symbol] the attribute name or instance variable
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cattri
4
4
  # :nocov:
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.2"
6
6
  # :nocov:
7
7
  end
@@ -3,8 +3,8 @@
3
3
  module Cattri
4
4
  # Cattri::Visibility tracks the current method visibility context (`public`, `protected`, `private`)
5
5
  # when defining methods dynamically. It mimics Ruby's native visibility behavior so that
6
- # `cattr` and `iattr` definitions can automatically infer the intended access level
7
- # based on the current context in the source file.
6
+ # `cattri` definitions can automatically infer the intended access level based on the current context
7
+ # in the source file.
8
8
  #
9
9
  # This module is intended to be extended by classes that include or extend Cattri.
10
10
  #
@@ -13,7 +13,7 @@ module Cattri
13
13
  # include Cattri
14
14
  #
15
15
  # private
16
- # cattr :sensitive_data
16
+ # cattri :sensitive_data
17
17
  # end
18
18
  #
19
19
  # # => :sensitive_data will be defined as a private method
data/lib/cattri.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require_relative "cattri/attribute"
4
4
  require_relative "cattri/context_registry"
5
5
  require_relative "cattri/dsl"
6
- require_relative "cattri/inheritance"
7
6
  require_relative "cattri/initializer_patch"
8
7
  require_relative "cattri/internal_store"
9
8
  require_relative "cattri/introspection"
@@ -33,14 +32,16 @@ module Cattri
33
32
  [base, base.singleton_class].each do |mod|
34
33
  mod.include(Cattri::InternalStore)
35
34
  mod.include(Cattri::ContextRegistry)
35
+ mod.instance_variable_set(:@__cattri_base_target, base)
36
36
  end
37
37
 
38
+ base.extend(Cattri::InternalStore)
39
+ base.singleton_class.extend(Cattri::InternalStore)
40
+
38
41
  base.prepend(Cattri::InitializerPatch)
39
42
  base.extend(Cattri::Visibility)
40
43
  base.extend(Cattri::Dsl)
41
44
  base.extend(ClassMethods)
42
-
43
- Cattri::Inheritance.install(base)
44
45
  end
45
46
 
46
47
  # Provides opt-in class-level introspection support.
@@ -68,6 +68,24 @@ module Cattri
68
68
  # @return [Boolean]
69
69
  def method_defined?: (Attribute attribute, ?name: identifier?) -> bool
70
70
 
71
+ # Determines whether to define the method on the instance or singleton.
72
+ #
73
+ # @param attribute [Cattri::Attribute]
74
+ # @return [Module]
75
+ def target_for: (Attribute attribute) -> ::Module
76
+
77
+ # Determines the object (class/module or runtime instance) that should hold
78
+ # the backing storage for the given attribute.
79
+ #
80
+ # - For class-level attributes, uses the singleton class of `defined_in` or the module itself
81
+ # - For instance-level attributes, uses the provided instance
82
+ #
83
+ # @param attribute [Cattri::Attribute]
84
+ # @param instance [Object, nil] the runtime instance, if needed for instance-level access
85
+ # @return [Object] the receiver for attribute value storage
86
+ # @raise [Cattri::Error] if instance is required but missing
87
+ def storage_receiver_for: (Attribute attribute, ::Object? instance) -> ::Object
88
+
71
89
  private
72
90
 
73
91
  # Internal tracking of explicitly defined methods per attribute.
@@ -75,11 +93,24 @@ module Cattri
75
93
  # @return [Hash{Symbol => Set<Symbol>}]
76
94
  def __cattri_defined_methods: () -> ::Hash[::Symbol, ::Set[::Symbol]]
77
95
 
78
- # Determines whether to define the method on the instance or singleton.
96
+ # Determines whether the object is a singleton_class or not.
97
+ #
98
+ # @param obj [Object]
99
+ # @return [Boolean]
100
+ def singleton_class?: (untyped obj) -> bool
101
+
102
+ # Resolves the proper class-level storage receiver for the attribute.
79
103
  #
80
104
  # @param attribute [Cattri::Attribute]
81
- # @return [Module]
82
- def target_for: (Attribute attribute) -> ::Module
105
+ # @param instance [Object, nil]
106
+ # @return [Object]
107
+ def resolve_class_storage_for: (Attribute attribute, ::Object? instance) -> ::Object
108
+
109
+ # Installs the internal store on the receiver if not present.
110
+ #
111
+ # @param receiver [Object]
112
+ # @return [void]
113
+ def install_internal_store!: (::Object receiver) -> void
83
114
 
84
115
  # Defines the method and applies its access visibility.
85
116
  #
@@ -13,6 +13,15 @@ module Cattri
13
13
 
14
14
  @__cattri_set_variables: ::Set[::Symbol]
15
15
 
16
+ # Returns the list of attribute keys stored by Cattri on this object.
17
+ #
18
+ # Mimics Ruby's `#instance_variables`, but only includes attributes defined
19
+ # via `cattri` and omits the leading `@` from names. All keys are returned as
20
+ # frozen symbols (e.g., `:enabled` instead of `:@enabled`).
21
+ #
22
+ # @return [Array<Symbol>] the list of internally tracked attribute keys
23
+ def cattri_variables: () -> ::Array[::Symbol]
24
+
16
25
  # Checks whether the internal store contains a value for the given key.
17
26
  #
18
27
  # @param key [String, Symbol] the attribute name or instance variable
@@ -26,7 +26,7 @@ RSpec.describe Cattri::AttributeCompiler do
26
26
 
27
27
  it "eagerly sets the default value on the target class" do
28
28
  described_class.define_accessor(attribute, context)
29
- expect(dummy_class.cattri_variable_get(:count)).to eq(100)
29
+ expect(dummy_class.count).to eq(100)
30
30
  end
31
31
  end
32
32
 
@@ -134,46 +134,16 @@ RSpec.describe Cattri::AttributeCompiler do
134
134
  describe ".memoize_default_value" do
135
135
  let(:instance) { dummy_class.new }
136
136
 
137
- context "non-final attribute" do
138
- let(:attribute) do
139
- Cattri::Attribute.new(
140
- :foo,
141
- defined_in: dummy_class,
142
- default: -> { "bar" },
143
- expose: :read_write
144
- )
145
- end
146
-
147
- it "stores and returns the evaluated default" do
148
- result = described_class.send(:memoize_default_value, instance, attribute)
149
- expect(result).to eq("bar")
150
- expect(instance.cattri_variable_get(:foo)).to eq("bar")
151
- end
152
- end
153
-
154
- context "final attribute" do
155
- let(:attribute) do
156
- Cattri::Attribute.new(
157
- :immutable,
158
- defined_in: dummy_class,
159
- final: true,
160
- default: -> { "locked" },
161
- expose: :read
162
- )
163
- end
164
-
165
- it "raises if value is not already set" do
166
- expect do
167
- described_class.send(:memoize_default_value, instance, attribute)
168
- end.to raise_error(Cattri::AttributeError, /Final attribute :immutable cannot be written to/)
169
- end
170
-
171
- it "returns value if already set" do
172
- instance.cattri_variable_set(:immutable, "preset", final: true)
173
- result = described_class.send(:memoize_default_value, instance, attribute)
137
+ it "memoizes the evaluated default" do
138
+ attribute = Cattri::Attribute.new(
139
+ :foo,
140
+ defined_in: dummy_class,
141
+ default: -> { "bar" },
142
+ expose: :read_write
143
+ )
174
144
 
175
- expect(result).to eq("preset")
176
- end
145
+ expect(described_class.send(:memoize_default_value, instance, attribute)).to eq("bar")
146
+ expect(instance.cattri_variable_get(:foo)).to eq("bar")
177
147
  end
178
148
  end
179
149
  end
@@ -117,43 +117,6 @@ RSpec.describe Cattri::AttributeRegistry do
117
117
  end
118
118
  end
119
119
 
120
- describe "#copy_attributes_to" do
121
- let(:target_klass) do
122
- Class.new do
123
- include Cattri
124
- end
125
- end
126
-
127
- let!(:target_registry) { target_klass.send(:attribute_registry) }
128
- let(:target_context) { Cattri::Context.new(target_klass) }
129
-
130
- it "copies final class attributes and sets their values" do
131
- registry.define_attribute(:enabled, "yes", scope: :class, final: true)
132
- registry.context.target.cattri_variable_set(:@enabled, "yes")
133
-
134
- registry.copy_attributes_to(target_context)
135
-
136
- expect(target_klass.cattri_variable_get(:@enabled)).to eq("yes")
137
- expect(target_registry.fetch_attribute(:enabled)).to be_a(Cattri::Attribute)
138
- end
139
-
140
- it "skips non-final or instance-level attributes" do
141
- registry.define_attribute(:skipped1, "val", scope: :instance, final: true)
142
- registry.define_attribute(:skipped2, "val", scope: :class, final: false)
143
-
144
- expect do
145
- registry.copy_attributes_to(target_context)
146
- end.not_to(change { target_klass.send(:attribute_registry).defined_attributes })
147
- end
148
-
149
- it "restores original context after copying" do
150
- original_context = registry.context
151
- registry.copy_attributes_to(target_context)
152
-
153
- expect(registry.context).to equal(original_context)
154
- end
155
- end
156
-
157
120
  describe "#validate_unique!" do
158
121
  context "when no attributes are registered" do
159
122
  it "returns without raising" do
@@ -3,7 +3,7 @@
3
3
  require "spec_helper"
4
4
 
5
5
  RSpec.describe Cattri::Context do
6
- let(:context_target) { Class.new }
6
+ let!(:context_target) { Class.new }
7
7
 
8
8
  def define_attribute(name, value, scope: :instance, visibility: :public)
9
9
  Cattri::Attribute.new(
@@ -181,6 +181,63 @@ RSpec.describe Cattri::Context do
181
181
  end
182
182
  end
183
183
 
184
+ describe "#target_for" do
185
+ subject(:result) { context.target_for(attribute) }
186
+
187
+ context "when a class-level attribute is provided" do
188
+ let(:attribute) { class_attribute }
189
+
190
+ it "returns the singleton_class" do
191
+ expect(result).to eq(context_target.singleton_class)
192
+ end
193
+ end
194
+
195
+ context "when a instance-level attribute is provided" do
196
+ let(:attribute) { instance_attribute }
197
+
198
+ it "returns the context's target" do
199
+ expect(result).to eq(context_target)
200
+ end
201
+ end
202
+
203
+ context "when a class-level attribute is provided and the context is a singleton_class" do
204
+ let(:context_target) { singleton_class }
205
+ let(:attribute) { class_attribute }
206
+
207
+ it "returns the singleton_class" do
208
+ expect(result).to eq(singleton_class)
209
+ end
210
+ end
211
+ end
212
+
213
+ describe "#storage_receiver_for" do
214
+ context "when a class-level attribute is provided" do
215
+ it "resolves to the instance's singleton_class, if instance is provided" do
216
+ receiver = context.storage_receiver_for(class_attribute, self)
217
+ expect(receiver).to eq(singleton_class)
218
+ end
219
+
220
+ it "resolves to the attribute's defined_in.singleton_class when no instance is provided" do
221
+ receiver = context.storage_receiver_for(class_attribute) # no instance passed
222
+ expect(receiver).to eq(context_target.singleton_class)
223
+ end
224
+ end
225
+
226
+ context "when a instance-level attribute is provided" do
227
+ it "returns the instance" do
228
+ receiver = context.storage_receiver_for(instance_attribute, self)
229
+ expect(receiver).to eq(self)
230
+ end
231
+ end
232
+
233
+ context "when a received cannot be resolved" do
234
+ it "raises an error" do
235
+ expect { context.storage_receiver_for(instance_attribute, nil) }
236
+ .to raise_error
237
+ end
238
+ end
239
+ end
240
+
184
241
  describe "#__cattri_defined_methods" do
185
242
  it "returns a Hash" do
186
243
  expect(context.send(:__cattri_defined_methods)).to be_a(Hash)
@@ -201,26 +258,85 @@ RSpec.describe Cattri::Context do
201
258
  end
202
259
  end
203
260
 
204
- describe "#target_for" do
205
- subject(:result) { context.send(:target_for, attribute) }
261
+ describe "#resolve_class_storage_for" do
262
+ def define_attribute(name, final:, defined_in:, scope: :class)
263
+ Cattri::Attribute.new(
264
+ name,
265
+ final: final,
266
+ defined_in: defined_in,
267
+ scope: scope,
268
+ default: -> { "value" },
269
+ visibility: :public
270
+ )
271
+ end
272
+
273
+ context "when attribute is final" do
274
+ it "returns defined_in if already a singleton_class" do
275
+ mod = Module.new
276
+ singleton = mod.singleton_class
277
+ attribute = define_attribute(:final_attr, final: true, defined_in: singleton)
278
+
279
+ expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(singleton)
280
+ end
206
281
 
207
- context "when a class-level attribute is provided" do
208
- let(:attribute) { class_attribute }
282
+ it "returns singleton_class of defined_in if not already a singleton" do
283
+ klass = Class.new
284
+ attribute = define_attribute(:final_attr, final: true, defined_in: klass)
209
285
 
210
- it "returns the singleton_class" do
211
- expect(result).to eq(context_target.singleton_class)
286
+ expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(klass.singleton_class)
212
287
  end
213
288
  end
214
289
 
215
- context "when a instance-level attribute is provided" do
216
- let(:attribute) { instance_attribute }
290
+ context "when attribute is not final" do
291
+ it "returns singleton_class of instance if instance is provided" do
292
+ instance = Object.new
293
+ attribute = define_attribute(:nonfinal_attr, final: false, defined_in: context_target)
217
294
 
218
- it "returns the context's target" do
219
- expect(result).to eq(context_target)
295
+ expect(context.send(:resolve_class_storage_for, attribute, instance)).to eq(instance.singleton_class)
296
+ end
297
+
298
+ it "returns singleton_class of defined_in if no instance is provided" do
299
+ attribute = define_attribute(:nonfinal_attr, final: false, defined_in: context_target)
300
+
301
+ expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(context_target.singleton_class)
220
302
  end
221
303
  end
222
304
  end
223
305
 
306
+ describe "#install_internal_store!" do
307
+ let(:context) { Cattri::Context.new(Object.new) }
308
+
309
+ it "does nothing if receiver already responds to cattri_variables" do
310
+ receiver = Class.new do
311
+ def self.cattri_variables; end
312
+ end
313
+
314
+ expect { context.send(:install_internal_store!, receiver) }
315
+ .not_to(change { receiver.ancestors })
316
+ end
317
+
318
+ it "extends singleton class if receiver is a singleton class" do
319
+ base = Class.new
320
+ singleton = base.singleton_class
321
+
322
+ expect(singleton.ancestors).not_to include(Cattri::InternalStore)
323
+
324
+ context.send(:install_internal_store!, singleton)
325
+
326
+ expect(singleton.ancestors).to include(Cattri::InternalStore)
327
+ end
328
+
329
+ it "includes module if receiver is a regular class or module" do
330
+ klass = Class.new
331
+
332
+ expect(klass.ancestors).not_to include(Cattri::InternalStore)
333
+
334
+ context.send(:install_internal_store!, klass)
335
+
336
+ expect(klass.ancestors).to include(Cattri::InternalStore)
337
+ end
338
+ end
339
+
224
340
  describe "#define_method!" do
225
341
  let(:target) { context.send(:target_for, class_attribute) }
226
342
  let(:attribute_name) { class_attribute.name }
@@ -13,6 +13,16 @@ RSpec.describe Cattri::InternalStore do
13
13
 
14
14
  subject(:instance) { klass.new }
15
15
 
16
+ describe "#cattri_variables" do
17
+ it "returns a list of all cattri variables" do
18
+ instance.cattri_variable_set(:foo, 123)
19
+ instance.cattri_variable_set(:bar, 123)
20
+ instance.cattri_variable_set(:@baz, 123)
21
+
22
+ expect(instance.cattri_variables).to match_array(%i[foo bar baz])
23
+ end
24
+ end
25
+
16
26
  describe "#cattri_variable_defined?" do
17
27
  it "returns false when variable is not set" do
18
28
  expect(instance.cattri_variable_defined?(:foo)).to be false
data/spec/cattri_spec.rb CHANGED
@@ -51,4 +51,148 @@ RSpec.describe Cattri do
51
51
  expect(introspective_class.attribute_methods).to include(:id)
52
52
  end
53
53
  end
54
+
55
+ describe "regression tests" do
56
+ it "allows one-time assignment to final instance attribute" do
57
+ klass = Class.new do
58
+ include Cattri
59
+ cattri :value, final: true
60
+
61
+ def initialize(value)
62
+ self.value = value
63
+ end
64
+ end
65
+
66
+ instance = klass.new("first")
67
+ expect(instance.value).to eq("first")
68
+
69
+ expect { instance.value = "second" }.to raise_error(Cattri::AttributeError)
70
+ end
71
+
72
+ it "prevents any assignment to final class attribute" do
73
+ klass = Class.new do
74
+ include Cattri
75
+ cattri :value, -> { "init" }, final: true, scope: :class
76
+ end
77
+
78
+ expect(klass.value).to eq("init")
79
+ expect { klass.value = "fail" }.to raise_error(Cattri::AttributeError)
80
+ end
81
+
82
+ it "allows shadowing parent class attributes" do
83
+ parent = Class.new do
84
+ include Cattri
85
+ cattri :enabled, true, final: true, scope: :class
86
+ end
87
+
88
+ child = Class.new do
89
+ include Cattri
90
+ cattri :enabled, false, final: true, scope: :class
91
+ end
92
+
93
+ expect(parent.enabled).to be(true)
94
+ expect(child.enabled).to be(false)
95
+ end
96
+
97
+ it "defines predicate method for instance attribute" do
98
+ klass = Class.new do
99
+ include Cattri
100
+ cattri :flag, false, predicate: true
101
+ end
102
+
103
+ instance = klass.new
104
+ expect(instance.flag?).to eq(false)
105
+
106
+ instance.flag = true
107
+ expect(instance.flag?).to eq(true)
108
+ end
109
+
110
+ it "evaluates and stores default value lazily" do
111
+ klass = Class.new do
112
+ include Cattri
113
+ cattri :computed, -> { "value" }
114
+ end
115
+
116
+ instance = klass.new
117
+
118
+ expect(instance.cattri_variable_defined?(:computed)).to eq(false)
119
+ expect(instance.computed).to eq("value")
120
+ expect(instance.cattri_variable_defined?(:computed)).to eq(true)
121
+ end
122
+
123
+ it "isolates instance and class attributes correctly" do
124
+ klass = Class.new do
125
+ include Cattri
126
+ cattri :config, scope: :class
127
+ cattri :state, scope: :instance
128
+ end
129
+
130
+ klass.config = "shared"
131
+ a = klass.new
132
+ b = klass.new
133
+ a.state = "one"
134
+ b.state = "two"
135
+
136
+ expect(klass.config).to eq("shared")
137
+ expect(a.state).to eq("one")
138
+ expect(b.state).to eq("two")
139
+ end
140
+
141
+ it "allows for class attributes set on a module's singleton_class" do
142
+ mod = Module.new do
143
+ class << self
144
+ include Cattri
145
+ cattri :version, "0.1.0", final: true, scope: :class
146
+ end
147
+ end
148
+
149
+ expect(mod.version).to eq("0.1.0"), "Expected module singleton_class to retain class-level attribute value"
150
+ end
151
+
152
+ it "isolates class attributes across subclasses" do
153
+ parent = Class.new do
154
+ include Cattri
155
+ cattri :level, "parent", scope: :class
156
+ end
157
+
158
+ child = Class.new(parent)
159
+ expect(child.level).to eq("parent")
160
+
161
+ child.level = "child"
162
+
163
+ expect(parent.level).to eq("parent")
164
+ expect(child.level).to eq("child")
165
+ end
166
+
167
+ it "applies custom coercion via block during assignment" do
168
+ klass = Class.new do
169
+ include Cattri
170
+ cattri :age do |value|
171
+ Integer(value)
172
+ end
173
+ end
174
+
175
+ instance = klass.new
176
+ instance.age = "42"
177
+
178
+ expect(instance.age).to eq(42)
179
+ end
180
+
181
+ it "allows inherited initialize to set final attribute once" do
182
+ base = Class.new do
183
+ include Cattri
184
+ cattri :token, final: true
185
+
186
+ def initialize(token)
187
+ self.token = token
188
+ end
189
+ end
190
+
191
+ subclass = Class.new(base)
192
+ obj = subclass.new("abc123")
193
+
194
+ expect(obj.token).to eq("abc123")
195
+ expect { obj.token = "fail" }.to raise_error(Cattri::AttributeError)
196
+ end
197
+ end
54
198
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cattri
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Lucas
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-02 00:00:00.000000000 Z
11
+ date: 2025-05-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: debride
@@ -154,7 +154,6 @@ files:
154
154
  - lib/cattri/deferred_attributes.rb
155
155
  - lib/cattri/dsl.rb
156
156
  - lib/cattri/error.rb
157
- - lib/cattri/inheritance.rb
158
157
  - lib/cattri/initializer_patch.rb
159
158
  - lib/cattri/internal_store.rb
160
159
  - lib/cattri/introspection.rb
@@ -170,7 +169,6 @@ files:
170
169
  - sig/lib/cattri/deferred_attributes.rbs
171
170
  - sig/lib/cattri/dsl.rbs
172
171
  - sig/lib/cattri/error.rbs
173
- - sig/lib/cattri/inheritance.rbs
174
172
  - sig/lib/cattri/initializer_patch.rbs
175
173
  - sig/lib/cattri/internal_store.rbs
176
174
  - sig/lib/cattri/introspection.rbs
@@ -185,7 +183,6 @@ files:
185
183
  - spec/cattri/deferred_attrributes_spec.rb
186
184
  - spec/cattri/dsl_spec.rb
187
185
  - spec/cattri/error_spec.rb
188
- - spec/cattri/inheritance_spec.rb
189
186
  - spec/cattri/initializer_patch_spec.rb
190
187
  - spec/cattri/internal_store_spec.rb
191
188
  - spec/cattri/introspection_spec.rb
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cattri
4
- # Handles subclassing behavior for classes that use Cattri.
5
- #
6
- # This module installs a custom `.inherited` hook on the target class's singleton,
7
- # ensuring that attribute definitions and values are deep-copied to the subclass.
8
- #
9
- # The hook preserves any existing `.inherited` behavior defined on the class,
10
- # calling it before applying attribute propagation.
11
- module Inheritance
12
- # Installs an `inherited` hook on the given class.
13
- #
14
- # When the class is subclassed, Cattri will copy over attribute metadata and values
15
- # using the subclass’s context. This ensures subclass safety and definition isolation.
16
- #
17
- # Any pre-existing `.inherited` method is preserved and invoked first.
18
- #
19
- # @param base [Class] the class to install the hook on
20
- # @return [void]
21
- def self.install(base)
22
- singleton = base.singleton_class
23
- existing = singleton.instance_method(:inherited) rescue nil # rubocop:disable Style/RescueModifier
24
-
25
- singleton.define_method(:inherited) do |subclass|
26
- # :nocov:
27
- existing.bind(self).call(subclass) # steep:ignore
28
- # :nocov:
29
-
30
- context = Cattri::Context.new(subclass)
31
- attribute_registry.send(:copy_attributes_to, context) # steep:ignore
32
- end
33
- end
34
- end
35
- end
@@ -1,21 +0,0 @@
1
- module Cattri
2
- # Handles subclassing behavior for classes that use Cattri.
3
- #
4
- # This module installs a custom `.inherited` hook on the target class's singleton,
5
- # ensuring that attribute definitions and values are deep-copied to the subclass.
6
- #
7
- # The hook preserves any existing `.inherited` behavior defined on the class,
8
- # calling it before applying attribute propagation.
9
- module Inheritance
10
- # Installs an `inherited` hook on the given class.
11
- #
12
- # When the class is subclassed, Cattri will copy over attribute metadata and values
13
- # using the subclass’s context. This ensures subclass safety and definition isolation.
14
- #
15
- # Any pre-existing `.inherited` method is preserved and invoked first.
16
- #
17
- # @param base [Class] the class to install the hook on
18
- # @return [void]
19
- def self.install: (::Module base) -> void
20
- end
21
- end
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
-
5
- RSpec.describe Cattri::Inheritance do
6
- let(:base_class) do
7
- Class.new.tap do |klass|
8
- klass.include(Cattri::ContextRegistry)
9
- klass.singleton_class.include(Cattri::ContextRegistry)
10
-
11
- klass.attr_reader :inherited_called_with
12
-
13
- def klass.inherited(subclass)
14
- @inherited_called_with = subclass
15
- end
16
-
17
- def klass.inherited_called_with # rubocop:disable Style/TrivialAccessors
18
- @inherited_called_with
19
- end
20
- end
21
- end
22
-
23
- before do
24
- allow(Cattri::Context).to receive(:new).and_call_original
25
- allow_any_instance_of(Cattri::AttributeRegistry).to receive(:copy_attributes_to)
26
- end
27
-
28
- describe ".install" do
29
- it "preserves and calls existing inherited method" do
30
- Cattri::Inheritance.install(base_class)
31
-
32
- subclass = Class.new(base_class) # triggers inherited hook
33
-
34
- expect(base_class.inherited_called_with).to eq(subclass)
35
- end
36
-
37
- it "initializes a Cattri::Context for the subclass" do
38
- Cattri::Inheritance.install(base_class)
39
-
40
- expect(Cattri::Context).to receive(:new) do |actual_subclass|
41
- expect(actual_subclass.superclass).to eq(base_class)
42
- end
43
-
44
- Class.new(base_class)
45
- end
46
-
47
- it "invokes copy_attributes_to with the subclass context" do
48
- context = instance_double(Cattri::Context)
49
- allow(Cattri::Context).to receive(:new).and_return(context)
50
-
51
- registry = instance_double(Cattri::AttributeRegistry)
52
- allow(base_class).to receive(:attribute_registry).and_return(registry)
53
-
54
- expect(registry).to receive(:copy_attributes_to).with(context)
55
-
56
- Cattri::Inheritance.install(base_class)
57
- Class.new(base_class)
58
- end
59
- end
60
- end