cattri 0.2.1 → 0.2.3

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: ba04c1c408b5ffe93591ba80633bc3b7d7950d0e651e4adf738e30fe4ba795ea
4
+ data.tar.gz: a4d5f788238eee82419df14b8977b018ed8b7d91169f7719171186c26e9dde7d
5
5
  SHA512:
6
- metadata.gz: 9dbe7c5e9a7a8f4edcc160dc6952f42e80fd0ee5c602b65113fda8059663634c9c3d7d418044a7584626e8c9294240ad78be77a32b5dcabfa94d8498d2f76178
7
- data.tar.gz: 301099ebc23bc730ada63468003fe1c9cd462e0040cbc8bc194d4a015d03a59c2b777482a4a142d5c215d93bc4083b7d2f5e33dec172e97ba343b5c48085f1a5
6
+ metadata.gz: 1a923822a64d948f87bc16c14511ab2b9a6d278f3b3a9264ee4ab57515644f3f42dedaee7db4bd3b6d5a7d294daf866a00dd99b0d4d72b29faade58f6facc0e2
7
+ data.tar.gz: 26cc1589b802f4916b55b43312d567e7e68138a83e16780c6c5a49405b16e45a6325bfd5d5e39b7b80bbe5bdf53bbadd84b606765c156cf53183be6a49e251f9
data/CHANGELOG.md CHANGED
@@ -1,8 +1,50 @@
1
+ ## [0.2.3] - 2025-12-14
2
+
3
+ ### Added
4
+ - New specs covering write-only exposure visibility for both instance and class attributes, ensuring readers are properly private/protected while writers remain public.
5
+ - Strengthened Context storage resolution tests to assert descriptive errors when instance receivers are missing.
6
+
7
+ ## [0.2.2] - 2025-05-04
8
+
9
+ No breaking changes – the public DSL (cattri, final_cattri) remains identical to v0.2.0.
10
+
11
+ ### Removed
12
+ - Removed `Cattri::AttributeRegistry#copy_attributes_to` and all associated logic. Subclass attribute propagation
13
+ is now handled entirely via runtime resolution in `Context#storage_receiver_for`, eliminating the need for eager
14
+ copying of attribute values or metadata.
15
+ - Removed `Cattri::Inheritance` and its use of `.inherited` hooks for subclass propagation, since all relevant state
16
+ is now derived dynamically.
17
+
18
+ ### Changed
19
+ - Replaced static subclass propagation with dynamic storage resolution using `Context#storage_receiver_for`,
20
+ which now determines the correct backing object (instance, class, or singleton class) for all reads and writes.
21
+ - `AttributeCompiler.define_accessor` now uses `storage_receiver_for` to set final class-level attribute values
22
+ during compilation.
23
+ - Final class-level attributes (`final: true, scope: :class`) are now evaluated and written directly to the correct
24
+ storage receiver without relying on prior duplication logic.
25
+
26
+ ### Added
27
+ - Proper support for defining attributes directly on a module’s singleton_class, ensuring values are stored and
28
+ accessed consistently across runtime and functional tests.
29
+ ```ruby
30
+ module MyModule
31
+ class << self
32
+ include Cattri
33
+
34
+ cattri :version, "0.1.0", final: true, scope: :class
35
+ end
36
+ end
37
+ ```
38
+ - Regression tests ensuring:
39
+ - Subclass attribute shadowing works for `final: true, scope: :class`.
40
+ - Instance and class attribute isolation behaves as expected across inheritance chains.
41
+ - No mutation of final attributes once defined.
42
+ - Test coverage for `Context#storage_receiver` for and `Context#resolve_class_storage_for` across all branching logic.
43
+
1
44
  ## [0.2.1] - 2025-05-01
2
45
 
3
46
  - Fixed an issue where only `final: true` instance variables defined on the current/class had their values applied.
4
47
  - Now walks the ancestor tree to ensure all attributes get set.
5
-
6
48
  ```ruby
7
49
  module Options
8
50
  include Cattri
@@ -105,7 +105,7 @@ module Cattri
105
105
  def readonly?
106
106
  return false if @options.expose == :none
107
107
 
108
- @options.expose == :read || final?
108
+ @options.expose == :read
109
109
  end
110
110
 
111
111
  # @return [Boolean] whether the attribute is marked final (write-once)
@@ -26,13 +26,14 @@ 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
33
34
 
34
35
  define_accessor!(attribute, context)
35
- define_writer!(attribute, context)
36
+ define_writer!(attribute, context) if attribute.writable?
36
37
  define_predicate!(attribute, context) if attribute.with_predicate?
37
38
  end
38
39
 
@@ -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
@@ -20,25 +20,29 @@ module Cattri
20
20
  # Validates and normalizes the `expose` configuration.
21
21
  #
22
22
  # @param expose [Symbol, String] one of: :read, :write, :read_write, :none
23
+ # @param attribute_name [Symbol, nil] optional attribute name for error context
23
24
  # @return [Symbol]
24
25
  # @raise [Cattri::AttributeError] if the value is invalid
25
- def validate_expose!(expose)
26
+ def validate_expose!(expose, attribute_name: nil)
26
27
  expose = expose.to_sym
27
28
  return expose if EXPOSE_OPTIONS.include?(expose) # steep:ignore
28
29
 
29
- raise Cattri::AttributeError, "Invalid expose option `#{expose.inspect}` for :#{name}"
30
+ detail = attribute_name ? " for :#{attribute_name}" : ""
31
+ raise Cattri::AttributeError, "Invalid expose option `#{expose.inspect}`#{detail}"
30
32
  end
31
33
 
32
34
  # Validates and normalizes method visibility.
33
35
  #
34
36
  # @param visibility [Symbol, String] one of: :public, :protected, :private
37
+ # @param attribute_name [Symbol, nil] optional attribute name for error context
35
38
  # @return [Symbol]
36
39
  # @raise [Cattri::AttributeError] if the value is invalid
37
- def validate_visibility!(visibility)
40
+ def validate_visibility!(visibility, attribute_name: nil)
38
41
  visibility = visibility.to_sym
39
42
  return visibility if VISIBILITIES.include?(visibility) # steep:ignore
40
43
 
41
- raise Cattri::AttributeError, "Invalid visibility `#{visibility.inspect}` for :#{name}"
44
+ detail = attribute_name ? " for :#{attribute_name}" : ""
45
+ raise Cattri::AttributeError, "Invalid visibility `#{visibility.inspect}`#{detail}"
42
46
  end
43
47
  end
44
48
 
@@ -88,8 +92,8 @@ module Cattri
88
92
  @predicate = predicate
89
93
  @default = normalize_default(default)
90
94
  @transformer = normalize_transformer(transformer)
91
- @expose = self.class.validate_expose!(expose)
92
- @visibility = self.class.validate_visibility!(visibility)
95
+ @expose = self.class.validate_expose!(expose, attribute_name: @name)
96
+ @visibility = self.class.validate_visibility!(visibility, attribute_name: @name)
93
97
 
94
98
  freeze
95
99
  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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Cattri
4
6
  # Provides a read-only interface for inspecting attributes defined via the Cattri DSL.
5
7
  #
@@ -62,7 +64,9 @@ module Cattri
62
64
  #
63
65
  # @return [Hash{Symbol => Set<Symbol>}]
64
66
  def attribute_methods
65
- context.defined_methods # steep:ignore
67
+ attribute_registry.defined_attributes(with_ancestors: true).transform_values do |attribute| # steep:ignore
68
+ Set.new(attribute.allowed_methods)
69
+ end
66
70
  end
67
71
 
68
72
  # Returns the original class or module where the given attribute was defined.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cattri
4
4
  # :nocov:
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.3"
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.
@@ -35,14 +35,14 @@ module Cattri
35
35
  # @param expose [Symbol, String] one of: :read, :write, :read_write, :none
36
36
  # @return [Symbol]
37
37
  # @raise [Cattri::AttributeError] if the value is invalid
38
- def self.validate_expose!: (expose_types | identifier expose) -> untyped
38
+ def self.validate_expose!: (expose_types | identifier expose, ?attribute_name: ::Symbol?) -> untyped
39
39
 
40
40
  # Validates and normalizes method visibility.
41
41
  #
42
42
  # @param visibility [Symbol, String] one of: :public, :protected, :private
43
43
  # @return [Symbol]
44
44
  # @raise [Cattri::AttributeError] if the value is invalid
45
- def self.validate_visibility!: (visibility_types | identifier visibility) -> untyped
45
+ def self.validate_visibility!: (visibility_types | identifier visibility, ?attribute_name: ::Symbol?) -> untyped
46
46
 
47
47
  # Valid method visibility levels.
48
48
  VISIBILITIES: ::Array[visibility_types]
@@ -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
 
@@ -67,6 +67,26 @@ RSpec.describe Cattri::AttributeCompiler do
67
67
  expect(instance.enabled?).to eq(true)
68
68
  end
69
69
  end
70
+
71
+ context "when expose is :read" do
72
+ let(:attribute) do
73
+ Cattri::Attribute.new(
74
+ :visible,
75
+ defined_in: dummy_class,
76
+ default: -> { "shown" },
77
+ expose: :read
78
+ )
79
+ end
80
+
81
+ it "does not define a writer" do
82
+ described_class.define_accessor(attribute, context)
83
+ instance = dummy_class.new
84
+
85
+ expect(instance.visible).to eq("shown")
86
+ expect(instance.respond_to?(:visible=)).to be(false)
87
+ expect(instance.respond_to?(:visible=, true)).to be(false)
88
+ end
89
+ end
70
90
  end
71
91
 
72
92
  describe ".define_accessor!" do
@@ -134,46 +154,16 @@ RSpec.describe Cattri::AttributeCompiler do
134
154
  describe ".memoize_default_value" do
135
155
  let(:instance) { dummy_class.new }
136
156
 
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)
157
+ it "memoizes the evaluated default" do
158
+ attribute = Cattri::Attribute.new(
159
+ :foo,
160
+ defined_in: dummy_class,
161
+ default: -> { "bar" },
162
+ expose: :read_write
163
+ )
174
164
 
175
- expect(result).to eq("preset")
176
- end
165
+ expect(described_class.send(:memoize_default_value, instance, attribute)).to eq("bar")
166
+ expect(instance.cattri_variable_get(:foo)).to eq("bar")
177
167
  end
178
168
  end
179
169
  end
@@ -144,6 +144,18 @@ RSpec.describe Cattri::AttributeOptions do
144
144
  expect(instance.instance_variable_get(:@visibility)).to eq(:private)
145
145
  end
146
146
  end
147
+
148
+ it "raises a helpful error when expose is invalid" do
149
+ expect do
150
+ described_class.new(name, expose: :bogus)
151
+ end.to raise_error(Cattri::AttributeError, /:bogus.*for :my_attribute/)
152
+ end
153
+
154
+ it "raises a helpful error when visibility is invalid" do
155
+ expect do
156
+ described_class.new(name, visibility: :bogus)
157
+ end.to raise_error(Cattri::AttributeError, /:bogus.*for :my_attribute/)
158
+ end
147
159
  end
148
160
 
149
161
  describe "#[]" do
@@ -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
@@ -112,8 +112,8 @@ RSpec.describe Cattri::Attribute do
112
112
  describe "#writable?" do
113
113
  [
114
114
  [true, :read, false],
115
- [true, :write, false],
116
- [true, :read_write, false],
115
+ [true, :write, true],
116
+ [true, :read_write, true],
117
117
  [true, :none, false],
118
118
  [false, :read, false],
119
119
  [false, :write, true],
@@ -134,8 +134,8 @@ RSpec.describe Cattri::Attribute do
134
134
  describe "#readonly?" do
135
135
  [
136
136
  [true, :read, true],
137
- [true, :write, true],
138
- [true, :read_write, true],
137
+ [true, :write, false],
138
+ [true, :read_write, false],
139
139
  [true, :none, false],
140
140
  [false, :read, true],
141
141
  [false, :write, false],
@@ -6,6 +6,7 @@ RSpec.describe Cattri::ContextRegistry do
6
6
  let(:klass) do
7
7
  Class.new do
8
8
  include Cattri::ContextRegistry
9
+
9
10
  public :context, :attribute_registry
10
11
  end
11
12
  end
@@ -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,64 @@ 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 a descriptive Cattri::Error" do
235
+ expect { context.storage_receiver_for(instance_attribute, nil) }
236
+ .to raise_error(Cattri::Error,
237
+ /Missing runtime instance for instance-level attribute :#{instance_attribute.name}/)
238
+ end
239
+ end
240
+ end
241
+
184
242
  describe "#__cattri_defined_methods" do
185
243
  it "returns a Hash" do
186
244
  expect(context.send(:__cattri_defined_methods)).to be_a(Hash)
@@ -201,26 +259,85 @@ RSpec.describe Cattri::Context do
201
259
  end
202
260
  end
203
261
 
204
- describe "#target_for" do
205
- subject(:result) { context.send(:target_for, attribute) }
262
+ describe "#resolve_class_storage_for" do
263
+ def define_attribute(name, final:, defined_in:, scope: :class)
264
+ Cattri::Attribute.new(
265
+ name,
266
+ final: final,
267
+ defined_in: defined_in,
268
+ scope: scope,
269
+ default: -> { "value" },
270
+ visibility: :public
271
+ )
272
+ end
273
+
274
+ context "when attribute is final" do
275
+ it "returns defined_in if already a singleton_class" do
276
+ mod = Module.new
277
+ singleton = mod.singleton_class
278
+ attribute = define_attribute(:final_attr, final: true, defined_in: singleton)
279
+
280
+ expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(singleton)
281
+ end
206
282
 
207
- context "when a class-level attribute is provided" do
208
- let(:attribute) { class_attribute }
283
+ it "returns singleton_class of defined_in if not already a singleton" do
284
+ klass = Class.new
285
+ attribute = define_attribute(:final_attr, final: true, defined_in: klass)
209
286
 
210
- it "returns the singleton_class" do
211
- expect(result).to eq(context_target.singleton_class)
287
+ expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(klass.singleton_class)
212
288
  end
213
289
  end
214
290
 
215
- context "when a instance-level attribute is provided" do
216
- let(:attribute) { instance_attribute }
291
+ context "when attribute is not final" do
292
+ it "returns singleton_class of instance if instance is provided" do
293
+ instance = Object.new
294
+ attribute = define_attribute(:nonfinal_attr, final: false, defined_in: context_target)
217
295
 
218
- it "returns the context's target" do
219
- expect(result).to eq(context_target)
296
+ expect(context.send(:resolve_class_storage_for, attribute, instance)).to eq(instance.singleton_class)
297
+ end
298
+
299
+ it "returns singleton_class of defined_in if no instance is provided" do
300
+ attribute = define_attribute(:nonfinal_attr, final: false, defined_in: context_target)
301
+
302
+ expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(context_target.singleton_class)
220
303
  end
221
304
  end
222
305
  end
223
306
 
307
+ describe "#install_internal_store!" do
308
+ let(:context) { Cattri::Context.new(Object.new) }
309
+
310
+ it "does nothing if receiver already responds to cattri_variables" do
311
+ receiver = Class.new do
312
+ def self.cattri_variables; end
313
+ end
314
+
315
+ expect { context.send(:install_internal_store!, receiver) }
316
+ .not_to(change { receiver.ancestors })
317
+ end
318
+
319
+ it "extends singleton class if receiver is a singleton class" do
320
+ base = Class.new
321
+ singleton = base.singleton_class
322
+
323
+ expect(singleton.ancestors).not_to include(Cattri::InternalStore)
324
+
325
+ context.send(:install_internal_store!, singleton)
326
+
327
+ expect(singleton.ancestors).to include(Cattri::InternalStore)
328
+ end
329
+
330
+ it "includes module if receiver is a regular class or module" do
331
+ klass = Class.new
332
+
333
+ expect(klass.ancestors).not_to include(Cattri::InternalStore)
334
+
335
+ context.send(:install_internal_store!, klass)
336
+
337
+ expect(klass.ancestors).to include(Cattri::InternalStore)
338
+ end
339
+ end
340
+
224
341
  describe "#define_method!" do
225
342
  let(:target) { context.send(:target_for, class_attribute) }
226
343
  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
@@ -6,6 +6,7 @@ RSpec.describe Cattri::Introspection do
6
6
  let(:klass) do
7
7
  Class.new do
8
8
  include Cattri
9
+
9
10
  cattri :foo, 123
10
11
  end
11
12
  end
@@ -13,6 +14,7 @@ RSpec.describe Cattri::Introspection do
13
14
  let(:subclass) do
14
15
  Class.new(klass) do
15
16
  include Cattri::Introspection
17
+
16
18
  cattri :bar, "bar"
17
19
  end
18
20
  end
@@ -75,6 +77,12 @@ RSpec.describe Cattri::Introspection do
75
77
  expect(methods.keys).to include(:bar)
76
78
  expect(methods[:bar]).to include(:bar)
77
79
  end
80
+
81
+ it "includes methods for inherited attributes" do
82
+ methods = subclass.attribute_methods
83
+ expect(methods.keys).to include(:foo)
84
+ expect(methods[:foo]).to include(:foo)
85
+ end
78
86
  end
79
87
 
80
88
  describe ".attribute_source" do
data/spec/cattri_spec.rb CHANGED
@@ -35,6 +35,7 @@ RSpec.describe Cattri do
35
35
  let(:introspective_class) do
36
36
  Class.new do
37
37
  include Cattri
38
+
38
39
  cattri :id, "foo"
39
40
 
40
41
  with_cattri_introspection
@@ -51,4 +52,190 @@ RSpec.describe Cattri do
51
52
  expect(introspective_class.attribute_methods).to include(:id)
52
53
  end
53
54
  end
55
+
56
+ describe "regression tests" do
57
+ it "allows one-time assignment to final instance attribute" do
58
+ klass = Class.new do
59
+ include Cattri
60
+
61
+ cattri :value, final: true
62
+
63
+ def initialize(value)
64
+ self.value = value
65
+ end
66
+ end
67
+
68
+ instance = klass.new("first")
69
+ expect(instance.value).to eq("first")
70
+
71
+ expect { instance.value = "second" }.to raise_error(Cattri::AttributeError)
72
+ end
73
+
74
+ it "prevents any assignment to final class attribute" do
75
+ klass = Class.new do
76
+ include Cattri
77
+
78
+ cattri :value, -> { "init" }, final: true, scope: :class
79
+ end
80
+
81
+ expect(klass.value).to eq("init")
82
+ expect { klass.value = "fail" }.to raise_error(Cattri::AttributeError)
83
+ end
84
+
85
+ it "allows shadowing parent class attributes" do
86
+ parent = Class.new do
87
+ include Cattri
88
+
89
+ cattri :enabled, true, final: true, scope: :class
90
+ end
91
+
92
+ child = Class.new do
93
+ include Cattri
94
+
95
+ cattri :enabled, false, final: true, scope: :class
96
+ end
97
+
98
+ expect(parent.enabled).to be(true)
99
+ expect(child.enabled).to be(false)
100
+ end
101
+
102
+ it "defines predicate method for instance attribute" do
103
+ klass = Class.new do
104
+ include Cattri
105
+
106
+ cattri :flag, false, predicate: true
107
+ end
108
+
109
+ instance = klass.new
110
+ expect(instance.flag?).to eq(false)
111
+
112
+ instance.flag = true
113
+ expect(instance.flag?).to eq(true)
114
+ end
115
+
116
+ it "evaluates and stores default value lazily" do
117
+ klass = Class.new do
118
+ include Cattri
119
+
120
+ cattri :computed, -> { "value" }
121
+ end
122
+
123
+ instance = klass.new
124
+
125
+ expect(instance.cattri_variable_defined?(:computed)).to eq(false)
126
+ expect(instance.computed).to eq("value")
127
+ expect(instance.cattri_variable_defined?(:computed)).to eq(true)
128
+ end
129
+
130
+ it "isolates instance and class attributes correctly" do
131
+ klass = Class.new do
132
+ include Cattri
133
+
134
+ cattri :config, scope: :class
135
+ cattri :state, scope: :instance
136
+ end
137
+
138
+ klass.config = "shared"
139
+ a = klass.new
140
+ b = klass.new
141
+ a.state = "one"
142
+ b.state = "two"
143
+
144
+ expect(klass.config).to eq("shared")
145
+ expect(a.state).to eq("one")
146
+ expect(b.state).to eq("two")
147
+ end
148
+
149
+ it "allows for class attributes set on a module's singleton_class" do
150
+ mod = Module.new do
151
+ class << self
152
+ include Cattri
153
+
154
+ cattri :version, "0.1.0", final: true, scope: :class
155
+ end
156
+ end
157
+
158
+ expect(mod.version).to eq("0.1.0"), "Expected module singleton_class to retain class-level attribute value"
159
+ end
160
+
161
+ it "isolates class attributes across subclasses" do
162
+ parent = Class.new do
163
+ include Cattri
164
+
165
+ cattri :level, "parent", scope: :class
166
+ end
167
+
168
+ child = Class.new(parent)
169
+ expect(child.level).to eq("parent")
170
+
171
+ child.level = "child"
172
+
173
+ expect(parent.level).to eq("parent")
174
+ expect(child.level).to eq("child")
175
+ end
176
+
177
+ it "applies custom coercion via block during assignment" do
178
+ klass = Class.new do
179
+ include Cattri
180
+
181
+ cattri :age do |value|
182
+ Integer(value)
183
+ end
184
+ end
185
+
186
+ instance = klass.new
187
+ instance.age = "42"
188
+
189
+ expect(instance.age).to eq(42)
190
+ end
191
+
192
+ it "allows inherited initialize to set final attribute once" do
193
+ base = Class.new do
194
+ include Cattri
195
+
196
+ cattri :token, final: true
197
+
198
+ def initialize(token)
199
+ self.token = token
200
+ end
201
+ end
202
+
203
+ subclass = Class.new(base)
204
+ obj = subclass.new("abc123")
205
+
206
+ expect(obj.token).to eq("abc123")
207
+ expect { obj.token = "fail" }.to raise_error(Cattri::AttributeError)
208
+ end
209
+ end
210
+
211
+ describe "visibility with write-only exposure" do
212
+ let(:klass) do
213
+ Class.new do
214
+ include Cattri
215
+
216
+ cattri :token, expose: :write
217
+ cattri :level, :low, expose: :write, scope: :class
218
+ end
219
+ end
220
+
221
+ it "keeps the reader private while writer stays public on instances" do
222
+ instance = klass.new
223
+
224
+ expect(klass.public_instance_methods(false)).to include(:token=)
225
+ expect(klass.private_instance_methods(false)).to include(:token)
226
+ expect { instance.token }.to raise_error(NoMethodError)
227
+
228
+ instance.token = "set"
229
+ expect(instance.send(:token)).to eq("set")
230
+ end
231
+
232
+ it "marks class-level readers as protected while keeping the writer public" do
233
+ expect(klass.singleton_class.public_instance_methods(false)).to include(:level=)
234
+ expect(klass.singleton_class.protected_instance_methods(false)).to include(:level)
235
+ expect { klass.level }.to raise_error(NoMethodError)
236
+
237
+ klass.level = :high
238
+ expect(klass.send(:level)).to eq(:high)
239
+ end
240
+ end
54
241
  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.3
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-12-14 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