cattri 0.1.0

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.
data/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # Cattri
2
+
3
+ Cattri is a lightweight Ruby DSL for defining class-level and instance-level attributes with optional defaults, coercion, and reset capabilities.
4
+
5
+ It provides fine-grained control over attribute behavior, including:
6
+
7
+ - Class-level attributes (`cattr`) with optional instance accessors
8
+ - Instance-level attributes (`iattr`) with coercion and lazy defaults
9
+ - Optional locking of class attribute definitions to prevent subclass redefinition
10
+ - Simple, expressive DSL for reusable metaprogramming
11
+
12
+ ## โœจ Features
13
+
14
+ - โœ… Define readable/writable class and instance attributes
15
+ - ๐Ÿงฑ Static or callable default values
16
+ - ๐ŸŒ€ Optional coercion logic via blocks
17
+ - ๐Ÿงผ Reset attributes to default
18
+ - ๐Ÿ”’ Lock class attribute definitions in base class
19
+ - ๐Ÿ” Introspect attribute definitions and values (optional)
20
+
21
+ ## ๐Ÿ“ฆ Installation
22
+
23
+ ```bash
24
+ bundle add cattri
25
+ ```
26
+
27
+ Or add to your Gemfile:
28
+
29
+ ```ruby
30
+ gem "cattri"
31
+ ```
32
+
33
+ ## ๐Ÿš€ Usage
34
+
35
+ ### Class & Instance Attributes
36
+
37
+ ```ruby
38
+ class MyConfig
39
+ include Cattri
40
+
41
+ cattr :enabled, default: true
42
+ iattr :name, default: "anonymous"
43
+ end
44
+
45
+ MyConfig.enabled # => true
46
+ MyConfig.new.name # => "anonymous"
47
+ ```
48
+
49
+ ### Class Attributes
50
+
51
+ ```ruby
52
+ class MyConfig
53
+ extend Cattri::ClassAttributes
54
+
55
+ cattr :format, default: :json
56
+ cattr_reader :version, default: "1.0.0"
57
+ cattr :enabled, default: true do |value|
58
+ !!value
59
+ end
60
+ end
61
+
62
+ MyConfig.format # => :json
63
+ MyConfig.format :xml
64
+ MyConfig.format # => :xml
65
+
66
+ MyConfig.version # => "1.0.0"
67
+ ```
68
+
69
+ #### Instance Access
70
+
71
+ ```ruby
72
+ MyConfig.new.format # => :xml
73
+ ```
74
+
75
+ #### Locking Class Attribute Definitions
76
+
77
+ ```ruby
78
+ MyConfig.lock_cattrs!
79
+ ```
80
+
81
+ This prevents redefinition of existing class attributes in subclasses.
82
+
83
+ ### Instance Attributes
84
+
85
+ ```ruby
86
+ class Request
87
+ include Cattri::InstanceAttributes
88
+
89
+ iattr :headers, default: -> { {} }
90
+ iattr_writer :raw_body do |val|
91
+ val.to_s.strip
92
+ end
93
+ end
94
+
95
+ req = Request.new
96
+ req.headers["Content-Type"] = "application/json"
97
+ req.raw_body = " data "
98
+ ```
99
+
100
+ ### Resetting Attributes
101
+
102
+ ```ruby
103
+ MyConfig.reset_cattrs! # Reset all class attributes
104
+ MyConfig.reset_cattr!(:format)
105
+
106
+ req.reset_iattr!(:headers) # Reset a specific instance attribute
107
+ ```
108
+
109
+ ## ๐Ÿ” Introspection
110
+
111
+ If you include the `Cattri::Introspection` module:
112
+
113
+ ```ruby
114
+ class MyConfig
115
+ include Cattri
116
+ include Cattri::Introspection
117
+
118
+ cattr :items, default: []
119
+ end
120
+
121
+ MyConfig.items << :a
122
+ MyConfig.snapshot_class_attributes # => { items: [:a] }
123
+ ```
124
+
125
+ ## ๐Ÿ“š API Overview
126
+
127
+ | Method | Description |
128
+ |----------------------------------|--------------------------------------------|
129
+ | `cattr`, `cattr_reader` | Define class-level attributes |
130
+ | `iattr`, `iattr_reader`, `iattr_writer` | Define instance-level attributes |
131
+ | `reset_cattr!`, `reset_iattr!` | Reset specific attributes |
132
+ | `cattr_definition(:name)` | Get attribute metadata |
133
+ | `lock_cattrs!` | Prevent redefinition in subclasses |
134
+
135
+ ## ๐Ÿงช Testing
136
+
137
+ ```bash
138
+ bundle exec rspec
139
+ ```
140
+
141
+ ## ๐Ÿ’ก Why Cattri?
142
+
143
+ Cattri provides a cleaner alternative to `class_attribute`, `attr_accessor`, and configuration gems like `Dry::Configurable` or `ActiveSupport::Configurable`, without monkey-patching or runtime surprises.
144
+
145
+ ## ๐Ÿ“ License
146
+
147
+ MIT ยฉ [Nathan Lucas](https://github.com/bnlucas). See [LICENSE](LICENSE).
148
+
149
+ ---
150
+
151
+ ## ๐Ÿ™ Credits
152
+
153
+ Created with โค๏ธ by [Nathan Lucas](https://github.com/bnlucas)
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/cattri.gemspec ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/cattri/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "cattri"
7
+ spec.version = Cattri::VERSION
8
+ spec.authors = ["Nathan Lucas"]
9
+ spec.email = ["bnlucas@outlook.com"]
10
+
11
+ spec.summary = "Simple class and instance attribute DSL for Ruby."
12
+ spec.description = "Cattri provides a clean DSL for defining class-level and instance-level attributes " \
13
+ "with optional defaults, coercion, accessors, and inheritance support."
14
+ spec.homepage = "https://github.com/bnlucas/cattri"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 2.7.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/bnlucas/cattri"
20
+ spec.metadata["changelog_uri"] = "https://github.com/bnlucas/cattri/blob/main/CHANGELOG.md"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
27
+ end
28
+ end
29
+
30
+ # Runtime dependencies
31
+ # spec.add_dependency "gem"
32
+
33
+ # Development dependencies
34
+ spec.add_development_dependency "rspec"
35
+ spec.add_development_dependency "rubocop"
36
+ spec.add_development_dependency "simplecov"
37
+ spec.add_development_dependency "simplecov-cobertura"
38
+ spec.add_development_dependency "simplecov-html"
39
+ spec.add_development_dependency "yard"
40
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "helpers"
5
+
6
+ module Cattri
7
+ # Provides a DSL for defining class-level attributes with support for:
8
+ #
9
+ # - Static or dynamic default values
10
+ # - Optional coercion via setter blocks
11
+ # - Optional instance-level readers
12
+ # - Read-only attribute enforcement
13
+ # - Inheritance-safe duplication
14
+ # - Attribute locking to prevent mutation in subclasses
15
+ #
16
+ # This module is designed for advanced metaprogramming needs such as DSL builders,
17
+ # configuration objects, and plugin systems that require reusable and introspectable
18
+ # class-level state.
19
+ #
20
+ # @example
21
+ # class MyClass
22
+ # extend Cattri::ClassAttributes
23
+ #
24
+ # cattr :format, default: :json
25
+ # cattr_reader :version, default: "1.0.0"
26
+ # cattr :enabled, default: true do |value|
27
+ # !!value
28
+ # end
29
+ # end
30
+ #
31
+ # MyClass.format # => :json
32
+ # MyClass.format :xml
33
+ # MyClass.format # => :xml
34
+ # MyClass.version # => "1.0.0"
35
+ #
36
+ # instance = MyClass.new
37
+ # instance.format # => :xml
38
+ module ClassAttributes
39
+ include Cattri::Helpers
40
+
41
+ # Default options applied to all class-level attributes.
42
+ DEFAULT_OPTIONS = { default: nil, readonly: false }.freeze
43
+
44
+ # Defines a class-level attribute with optional default, coercion, and reader access.
45
+ #
46
+ # @param name [Symbol] the attribute name
47
+ # @param options [Hash] additional attribute options
48
+ # @option options [Object, Proc] :default the default value or callable
49
+ # @option options [Boolean] :readonly whether the attribute is read-only
50
+ # @option options [Boolean] :instance_reader whether to define an instance-level reader
51
+ # @yield [*args] Optional setter block for custom coercion
52
+ # @raise [Cattri::Error] if attribute is already defined
53
+ # @return [void]
54
+ def class_attribute(name, **options, &block)
55
+ define_inheritance unless respond_to?(:__cattri_class_attributes)
56
+
57
+ name, definition = define_attribute(name, options, block, DEFAULT_OPTIONS)
58
+ raise Cattri::Error, "Class attribute `#{name}` already defined" if class_attribute_defined?(name)
59
+
60
+ __cattri_class_attributes[name] = definition
61
+ define_accessor(name, definition)
62
+ define_instance_reader(name) if options.fetch(:instance_reader, true)
63
+ end
64
+
65
+ # Defines a read-only class attribute (no writer).
66
+ #
67
+ # @param name [Symbol]
68
+ # @param options [Hash]
69
+ # @return [void]
70
+ def class_attribute_reader(name, **options)
71
+ class_attribute(name, readonly: true, **options)
72
+ end
73
+
74
+ # Returns all defined class-level attribute names.
75
+ #
76
+ # @return [Array<Symbol>]
77
+ def class_attributes
78
+ __cattri_class_attributes.keys
79
+ end
80
+
81
+ # Checks whether a class-level attribute is defined.
82
+ #
83
+ # @param name [Symbol]
84
+ # @return [Boolean]
85
+ def class_attribute_defined?(name)
86
+ __cattri_class_attributes.key?(name.to_sym)
87
+ end
88
+
89
+ # Returns metadata for a given class-level attribute.
90
+ #
91
+ # @param name [Symbol]
92
+ # @return [Hash, nil]
93
+ def class_attribute_definition(name)
94
+ __cattri_class_attributes[name.to_sym]
95
+ end
96
+
97
+ # Resets all defined class attributes to their default values.
98
+ #
99
+ # @return [void]
100
+ def reset_class_attributes!
101
+ reset_attributes!(self, __cattri_class_attributes.values)
102
+ end
103
+
104
+ # Resets a single class attribute to its default value.
105
+ #
106
+ # @param name [Symbol]
107
+ # @return [void]
108
+ def reset_class_attribute!(name)
109
+ definition = __cattri_class_attributes[name]
110
+ return unless definition
111
+
112
+ reset_attributes!(self, [definition])
113
+ end
114
+
115
+ # alias lock_cattrs! lock_class_attributes!
116
+ # alias cattrs_locked? class_attributes_locked?
117
+
118
+ # @!method cattr(name, **options, &block)
119
+ # Alias for {.class_attribute}
120
+ # @see .class_attribute
121
+ alias cattr class_attribute
122
+
123
+ # @!method cattr_accessor(name, **options, &block)
124
+ # Alias for {.class_attribute}
125
+ # @see .class_attribute
126
+ alias cattr_accessor class_attribute
127
+
128
+ # @!method cattr_reader(name, **options)
129
+ # Alias for {.class_attribute_reader}
130
+ # @see .class_attribute_reader
131
+ alias cattr_reader class_attribute_reader
132
+
133
+ # @!method cattrs
134
+ # @return [Array<Symbol>] all defined class attribute names
135
+ # @see .class_attributes
136
+ alias cattrs class_attributes
137
+
138
+ # @!method cattr_defined?(name)
139
+ # @return [Boolean] whether the given attribute has been defined
140
+ # @see .class_attribute_defined?
141
+ alias cattr_defined? class_attribute_defined?
142
+
143
+ # @!method cattr_for(name)
144
+ # @return [Hash, nil] the internal metadata hash for a defined attribute
145
+ # @see .class_attribute_for
146
+ alias cattr_definition class_attribute_definition
147
+
148
+ # @!method reset_cattrs!
149
+ # Resets all class attributes to their default values.
150
+ # @see .reset_class_attributes!
151
+ alias reset_cattrs! reset_class_attributes!
152
+
153
+ # @!method reset_cattr!(name)
154
+ # Resets a specific class attribute to its default value.
155
+ # @see .reset_class_attribute!
156
+ alias reset_cattr! reset_class_attribute!
157
+
158
+ private
159
+
160
+ # Defines class-level inheritance behavior for declared attributes.
161
+ #
162
+ # @return [void]
163
+ def define_inheritance
164
+ unless singleton_class.method_defined?(:__cattri_class_attributes)
165
+ define_singleton_method(:__cattri_class_attributes) { @__cattri_class_attributes ||= {} }
166
+ end
167
+
168
+ define_singleton_method(:inherited) do |subclass|
169
+ super(subclass)
170
+ subclass_attributes = {}
171
+
172
+ __cattri_class_attributes.each do |name, definition|
173
+ apply_attribute!(subclass, subclass_attributes, name, definition)
174
+ end
175
+
176
+ subclass.instance_variable_set(:@__cattri_class_attributes, subclass_attributes)
177
+ end
178
+ end
179
+
180
+ # Defines the primary accessor method on the class.
181
+ #
182
+ # @param name [Symbol]
183
+ # @param definition [Hash]
184
+ # @return [void]
185
+ def define_accessor(name, definition)
186
+ ivar = definition[:ivar]
187
+
188
+ define_singleton_method(name) do |*args, **kwargs|
189
+ readonly = readonly_call?(args, kwargs) || definition[:readonly]
190
+ return apply_readonly(ivar, definition[:default]) if readonly
191
+
192
+ instance_variable_set(ivar, definition[:setter].call(*args, **kwargs))
193
+ end
194
+
195
+ return if definition[:readonly]
196
+
197
+ define_singleton_method("#{name}=") do |value|
198
+ instance_variable_set(ivar, definition[:setter].call(value))
199
+ end
200
+ end
201
+
202
+ # Defines an instance-level reader that delegates to the class-level method.
203
+ #
204
+ # @param name [Symbol]
205
+ # @return [void]
206
+ def define_instance_reader(name)
207
+ define_method(name) { self.class.__send__(name) }
208
+ end
209
+
210
+ # Applies the default value for a read-only call.
211
+ #
212
+ # @param ivar [Symbol]
213
+ # @param default [Proc]
214
+ # @return [Object]
215
+ def apply_readonly(ivar, default)
216
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
217
+
218
+ value = default.call
219
+ instance_variable_set(ivar, value)
220
+ end
221
+
222
+ # Applies inherited attribute definitions to a subclass.
223
+ #
224
+ # @param subclass [Class]
225
+ # @param attributes [Hash]
226
+ # @param name [Symbol]
227
+ # @param definition [Hash]
228
+ # @return [void]
229
+ def apply_attribute!(subclass, attributes, name, definition)
230
+ value = instance_variable_get(definition[:ivar])
231
+ value = value.dup rescue value # rubocop:disable Style/RescueModifier
232
+
233
+ subclass.instance_variable_set(definition[:ivar], value)
234
+ attributes[name] = definition
235
+ end
236
+
237
+ # Determines if the method call should be treated as read-only access.
238
+ #
239
+ # @param args [Array]
240
+ # @param kwargs [Hash]
241
+ # @return [Boolean]
242
+ def readonly_call?(args, kwargs)
243
+ args.empty? && kwargs.empty?
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cattri
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cattri
4
+ # Internal support utilities for Cattri modules.
5
+ #
6
+ # Provides shared logic for safe default handling, attribute definition, and
7
+ # consistent reset behavior across class-level and instance-level attributes.
8
+ #
9
+ # This module is intended for internal use only and is included by both
10
+ # Cattri::ClassAttributes and Cattri::InstanceAttributes.
11
+ module Helpers
12
+ # A list of immutable Ruby types that are safe to reuse directly without duplication.
13
+ #
14
+ # These types are treated as-is when used as default values.
15
+ #
16
+ # @return [Array<Class>]
17
+ SAFE_VALUE_TYPES = [Numeric, Symbol, TrueClass, FalseClass, NilClass].freeze
18
+
19
+ protected
20
+
21
+ # Defines an attribute structure used internally for accessors.
22
+ #
23
+ # Combines the caller's options with normalized default and setter logic,
24
+ # and attaches a consistent `@ivar` key for storage.
25
+ #
26
+ # @param name [Symbol, String] The attribute name
27
+ # @param options [Hash] The caller-provided options (e.g., `:default`, `:readonly`)
28
+ # @param block [Proc, nil] Optional setter block
29
+ # @param defaults [Hash] A set of fallback/default values to merge in
30
+ # @return [Array] Normalized name and attribute definition hash
31
+ def define_attribute(name, options, block, defaults)
32
+ options[:default] = normalize_default(options[:default])
33
+ options[:setter] = block || lambda { |*args, **kwargs|
34
+ return kwargs unless kwargs.empty?
35
+ return args.first if args.length == 1
36
+
37
+ args
38
+ }
39
+
40
+ name = name.to_sym
41
+ [name, defaults.merge(ivar: :"@#{name}", **options)]
42
+ end
43
+
44
+ # Wraps static default values in lambdas to ensure safety.
45
+ #
46
+ # If the value is already callable, it is returned as-is.
47
+ # If the value is immutable, it is wrapped directly.
48
+ # Otherwise, it is wrapped with `.dup` for safe reuse.
49
+ #
50
+ # @param default [Object, Proc, nil] The user-provided default value
51
+ # @return [Proc] A proc that returns a safe default value
52
+ def normalize_default(default)
53
+ return default if default.respond_to?(:call)
54
+ return -> { default } if default.frozen? || SAFE_VALUE_TYPES.any? { |type| default.is_a?(type) }
55
+
56
+ -> { default.dup }
57
+ end
58
+
59
+ # Resets a set of attribute definitions on a target object.
60
+ #
61
+ # Used to restore class or instance attributes to their configured default values.
62
+ #
63
+ # @param target [Object] The object or class whose instance variables will be reset
64
+ # @param attribute_definitions [Enumerable<Hash>] A list of attribute definition hashes
65
+ # @return [void]
66
+ def reset_attributes!(target, attribute_definitions)
67
+ attribute_definitions.each do |definition|
68
+ target.instance_variable_set(
69
+ definition[:ivar],
70
+ definition[:default].call
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end