structure 3.3.0 → 3.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69bf1e759bbf993b28fb70235866a6c36774b92a57ee47764458d24bb062a55b
4
- data.tar.gz: 3e9a55913e14fb630ece6c4fff62df1d8bfff69d61efd1034a406cf404423f96
3
+ metadata.gz: 69015a521f4a1a35438cd69d0d195e8f0fa276156d64371c8a6437030b14caf0
4
+ data.tar.gz: d4e5c263a7c92ae2bfbdbe5213b933c55ea1a8f0c9a8d6583302f8c032bb69a9
5
5
  SHA512:
6
- metadata.gz: c8b75ee56372f7af3ffd9398474ce25bcffed641314a77293a735cc5e5269f68ee7ba645ed44456eea5c84d1c2b551d0181be35519a61b553466ceda85287f8a
7
- data.tar.gz: 3f736db422a6abe5dae0b59d37194d8f2242c2970a506f40efe04b379f00b7cbc14dd2a3437023d0a33bd0d80376f6418c1421659d446c546d56ccbb1352abf5
6
+ metadata.gz: 1e8ace0197fb9b9cd4811cc1eb7774fe98eff67df140400b567a0a636b9bdabcf2e7858586a7b13f96fbc95e101e2eef74518d9940b1f7c4dab168fec119fb3e
7
+ data.tar.gz: 9d7fa653f6b4aeb60b10bfca4a940677bee89c6803a8e50d893e9dc0ce687306691bbb6f92f4af0a59c89245c3583e8b6d64f477d27b44b71f0f1b7fc359bb26
@@ -33,16 +33,13 @@ module Structure
33
33
  # Money.new(value["amount"], value["currency"])
34
34
  # end
35
35
  def attribute(name, type = nil, from: nil, default: nil, &block)
36
- # Always store in mappings - use attribute name as default source
37
- @mappings[name] = from || name.to_s
38
- @defaults[name] = default unless default.nil?
36
+ mappings[name] = from || name.to_s
37
+ defaults[name] = default unless default.nil?
39
38
 
40
39
  if type && block
41
40
  raise ArgumentError, "Cannot specify both type and block for :#{name}"
42
- elsif block
43
- @types[name] = block
44
- elsif type
45
- @types[name] = Types.coerce(type)
41
+ else
42
+ types[name] = type || block
46
43
  end
47
44
  end
48
45
 
@@ -63,13 +60,16 @@ module Structure
63
60
  @mappings.keys
64
61
  end
65
62
 
63
+ def coercions
64
+ @types.transform_values { |type| Types.coerce(type) }
65
+ end
66
+
66
67
  def predicate_methods
67
- @types.filter_map do |name, type_lambda|
68
- if Types.boolean?(type_lambda) && !name.to_s.end_with?("?")
69
- predicate_name = "#{name}?"
70
- [predicate_name.to_sym, name]
68
+ @types.filter_map do |name, type|
69
+ if type == :boolean
70
+ ["#{name}?".to_sym, name] unless name.to_s.end_with?("?")
71
71
  end
72
- end.to_h
72
+ end.compact.to_h
73
73
  end
74
74
  end
75
75
  end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Structure
7
+ module RBS
8
+ class << self
9
+ def emit(klass)
10
+ return unless klass < Data
11
+
12
+ class_name = klass.name
13
+ return unless class_name
14
+
15
+ # @type var meta: Hash[Symbol, untyped]
16
+ meta = klass.respond_to?(:__structure_meta__) ? klass.__structure_meta__ : {}
17
+
18
+ emit_rbs_content(
19
+ class_name: class_name,
20
+ attributes: meta.fetch(:attributes, klass.members),
21
+ types: meta.fetch(:types, {}), # steep:ignore
22
+ has_structure_modules: meta.any?,
23
+ )
24
+ end
25
+
26
+ def write(klass, dir: "sig")
27
+ rbs_content = emit(klass)
28
+ return unless rbs_content
29
+
30
+ # User::Address -> user/address.rbs
31
+ path_segments = klass.name.split("::").map(&:downcase)
32
+ filename = "#{path_segments.pop}.rbs"
33
+
34
+ # full path
35
+ dir_path = Pathname.new(dir)
36
+ dir_path = dir_path.join(*path_segments) unless path_segments.empty?
37
+ FileUtils.mkdir_p(dir_path)
38
+
39
+ file_path = dir_path.join(filename).to_s
40
+ File.write(file_path, rbs_content)
41
+
42
+ file_path
43
+ end
44
+
45
+ private
46
+
47
+ def emit_rbs_content(class_name:, attributes:, types:, has_structure_modules:)
48
+ # @type var lines: Array[String]
49
+ lines = []
50
+ lines << "class #{class_name} < Data"
51
+
52
+ unless attributes.empty?
53
+ # map types to rbs
54
+ rbs_types = attributes.map do |attr|
55
+ type = types.fetch(attr, nil)
56
+ rbs_type = map_type_to_rbs(type, class_name)
57
+
58
+ [attr, rbs_type != "untyped" ? "#{rbs_type}?" : rbs_type]
59
+ end.to_h
60
+
61
+ keyword_params = attributes.map { |attr| "#{attr}: #{rbs_types[attr]}" }.join(", ")
62
+ positional_params = attributes.map { |attr| rbs_types[attr] }.join(", ")
63
+
64
+ lines << " def self.new: (#{keyword_params}) -> instance"
65
+ lines << " | (#{positional_params}) -> instance"
66
+ lines << ""
67
+
68
+ needs_parse_data = types.any? do |_attr, type|
69
+ type == :self || type == [:self] || (type.is_a?(Array) && type.first == :array)
70
+ end
71
+
72
+ if needs_parse_data
73
+ lines << " type parse_data = {"
74
+ attributes.each do |attr|
75
+ type = types.fetch(attr, nil)
76
+ parse_type = parse_data_type(type, class_name)
77
+ lines << " ?#{attr}: #{parse_type},"
78
+ end
79
+ lines[-1] = lines[-1].chomp(",")
80
+ lines << " }"
81
+ lines << ""
82
+ lines << " def self.parse: (?parse_data data) -> instance"
83
+ lines << " | (?Hash[String, untyped] data) -> instance"
84
+ else
85
+ # For structures without special types, just use Hash
86
+ lines << " def self.parse: (?(Hash[String | Symbol, untyped]), **untyped) -> instance"
87
+ end
88
+ lines << ""
89
+
90
+ attributes.each do |attr|
91
+ lines << " attr_reader #{attr}: #{rbs_types[attr]}"
92
+ end
93
+ lines << ""
94
+
95
+ types.each do |attr, type|
96
+ if type == :boolean && !attr.to_s.end_with?("?")
97
+ lines << " def #{attr}?: () -> bool"
98
+ end
99
+ end
100
+
101
+ hash_type = attributes.map { |attr| "#{attr}: #{rbs_types[attr]}" }.join(", ")
102
+ lines << " def to_h: () -> { #{hash_type} }"
103
+ end
104
+
105
+ lines << "end"
106
+ lines.join("\n")
107
+ end
108
+
109
+ def parse_data_type(type, class_name)
110
+ case type
111
+ when [:self]
112
+ "Array[#{class_name} | parse_data]"
113
+ when Array
114
+ if type.first == :array && type.last == :self
115
+ "Array[#{class_name} | parse_data]"
116
+ elsif type.first == :array
117
+ # For [:array, SomeType] format, use Array[untyped] since we coerce
118
+ "Array[untyped]"
119
+ elsif type.size == 1 && type.first == :self
120
+ # [:self] is handled above, this shouldn't happen
121
+ "Array[#{class_name} | parse_data]"
122
+ elsif type.size == 1
123
+ # Regular array type like [String], [Integer], etc.
124
+ # Use Array[untyped] since we coerce values
125
+ "Array[untyped]"
126
+ else
127
+ "untyped"
128
+ end
129
+ when :self
130
+ "#{class_name} | parse_data"
131
+ else
132
+ "untyped"
133
+ end
134
+ end
135
+
136
+ def map_type_to_rbs(type, class_name)
137
+ case type
138
+ when Class
139
+ type.name || "untyped"
140
+ when :boolean
141
+ "bool"
142
+ when :self
143
+ class_name || "untyped"
144
+ when Array
145
+ if type.size == 2 && type.first == :array
146
+ element_type = map_type_to_rbs(type.last, class_name)
147
+ "Array[#{element_type}]"
148
+ elsif type.size == 1
149
+ # Single element array means array of that type
150
+ element_type = map_type_to_rbs(type.first, class_name)
151
+ "Array[#{element_type}]"
152
+ else
153
+ "untyped"
154
+ end
155
+ else
156
+ "untyped"
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -3,81 +3,88 @@
3
3
  module Structure
4
4
  # Type coercion methods for converting values to specific types
5
5
  module Types
6
- extend self
6
+ class << self
7
+ # Rails-style boolean truthy values
8
+ # Reference: https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html
9
+ BOOLEAN_TRUTHY = [true, 1, "1", "t", "T", "true", "TRUE", "on", "ON"].freeze
10
+ private_constant :BOOLEAN_TRUTHY
7
11
 
8
- # Rails-style boolean truthy values
9
- # Reference: https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html
10
- BOOLEAN_TRUTHY = [true, 1, "1", "t", "T", "true", "TRUE", "on", "ON"].freeze
11
- private_constant :BOOLEAN_TRUTHY
12
-
13
- def boolean?(type)
14
- type == boolean
15
- end
16
-
17
- # Main factory method for creating type coercers
18
- #
19
- # @param type [Class, Symbol, Array] Type specification
20
- # @return [Proc, Object] Coercion proc or the type itself if no coercion available
21
- #
22
- # @example Boolean type
23
- # coerce(:boolean) # => boolean proc
24
- #
25
- # @example Kernel types
26
- # coerce(Integer) # => proc that calls Kernel.Integer
27
- #
28
- # @example Parseable types
29
- # coerce(Date) # => proc that calls Date.parse
30
- #
31
- # @example Array types
32
- # coerce([String]) # => proc that coerces array elements to String
33
- def coerce(type)
34
- case type
35
- when :boolean
36
- boolean
37
- when :self
38
- self_referential
39
- when Array
40
- if type.length == 1
41
- array(type.first)
42
- else
43
- type
44
- end
45
- else
46
- # Handle Class, Module, and any other types
47
- if type.respond_to?(:parse)
48
- parseable(type)
49
- elsif type.respond_to?(:name) && type.name && Kernel.respond_to?(type.name)
50
- kernel(type)
12
+ # Main factory method for creating type coercers
13
+ #
14
+ # @param type [Class, Symbol, Array] Type specification
15
+ # @return [Proc, Object] Coercion proc or the type itself if no coercion available
16
+ #
17
+ # @example Boolean type
18
+ # coerce(:boolean) # => boolean proc
19
+ #
20
+ # @example Kernel types
21
+ # coerce(Integer) # => proc that calls Kernel.Integer
22
+ #
23
+ # @example Parseable types
24
+ # coerce(Date) # => proc that calls Date.parse
25
+ #
26
+ # @example Array types
27
+ # coerce([String]) # => proc that coerces array elements to String
28
+ def coerce(type)
29
+ case type
30
+ when :boolean
31
+ boolean
32
+ when :self
33
+ self_referential
34
+ when Array
35
+ if type.length == 1
36
+ array(type.first)
37
+ else
38
+ type
39
+ end
51
40
  else
52
- type
41
+ if type.respond_to?(:parse)
42
+ parseable(type)
43
+ elsif type.respond_to?(:name) && type.name && Kernel.respond_to?(type.name)
44
+ kernel(type)
45
+ else
46
+ type
47
+ end
53
48
  end
54
49
  end
55
- end
56
50
 
57
- private
51
+ private
58
52
 
59
- def boolean
60
- @boolean ||= ->(val) { BOOLEAN_TRUTHY.include?(val) }
61
- end
53
+ def boolean
54
+ @boolean ||= ->(val) { BOOLEAN_TRUTHY.include?(val) }
55
+ end
62
56
 
63
- def self_referential
64
- proc { |val| parse(val) }
65
- end
57
+ def self_referential
58
+ proc { |val| parse(val) }
59
+ end
66
60
 
67
- def kernel(type)
68
- ->(val) { Kernel.send(type.name, val) }
69
- end
61
+ def kernel(type)
62
+ ->(val) { Kernel.send(type.name, val) }
63
+ end
70
64
 
71
- def parseable(type)
72
- ->(val) { type.parse(val) }
73
- end
65
+ def parseable(type)
66
+ ->(val) { type.parse(val) }
67
+ end
74
68
 
75
- def array(element_type)
76
- if element_type == :self
77
- proc { |array| array.map { |element| parse(element) } }
78
- else
79
- element_coercer = coerce(element_type)
80
- ->(array) { array.map { |element| element_coercer.call(element) } }
69
+ def array(element_type)
70
+ if element_type == :self
71
+ proc do |value|
72
+ unless value.respond_to?(:map)
73
+ raise TypeError, "can't convert #{value.class} into Array"
74
+ end
75
+
76
+ value.map { |element| parse(element) }
77
+ end
78
+ else
79
+ element_coercer = coerce(element_type)
80
+ lambda do |value|
81
+ unless value.respond_to?(:map)
82
+ raise TypeError, "can't convert #{value.class} into Array"
83
+ end
84
+
85
+ value.map { |element| element_coercer.call(element) }
86
+ end
87
+ end
81
88
  end
82
89
  end
83
90
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Structure
4
- VERSION = "3.3.0"
4
+ VERSION = "3.5.0"
5
5
  end
data/lib/structure.rb CHANGED
@@ -23,78 +23,96 @@ module Structure
23
23
  builder = Builder.new
24
24
  builder.instance_eval(&block) if block
25
25
 
26
- data_class = Data.define(*builder.attributes)
26
+ # @type var klass: untyped
27
+ klass = Data.define(*builder.attributes)
27
28
 
28
- # Generate predicate methods
29
- builder.predicate_methods.each do |predicate_name, attribute_name|
30
- data_class.define_method(predicate_name) do
31
- send(attribute_name)
32
- end
33
- end
29
+ # capture metadata and attach to class
30
+ meta = {
31
+ attributes: builder.attributes.freeze,
32
+ types: builder.types.freeze,
33
+ defaults: builder.defaults.freeze,
34
+ }.freeze
35
+ klass.instance_variable_set(:@__structure_meta__, meta)
36
+ klass.singleton_class.attr_reader(:__structure_meta__)
34
37
 
35
- # Capture builder data in closure for parse method
38
+ # capture locals for method generation
36
39
  mappings = builder.mappings
37
- types = builder.types
38
- defaults = builder.defaults
39
- attributes = builder.attributes
40
- after_parse_callback = builder.after_parse_callback
41
-
42
- # Override to_h to recursively convert nested objects with to_h
43
- data_class.define_method(:to_h) do
44
- result = {}
45
- self.class.members.each do |member|
46
- value = send(member)
47
- result[member] = case value
48
- when Array
49
- value.map { |item| item.respond_to?(:to_h) && item ? item.to_h : item }
50
- when Hash
51
- value
52
- when ->(v) { v.respond_to?(:to_h) && v }
53
- value.to_h
54
- else
55
- value
56
- end
40
+ coercions = builder.coercions
41
+ predicates = builder.predicate_methods
42
+ after = builder.after_parse_callback
43
+
44
+ # Define predicate methods
45
+ predicates.each do |pred, attr|
46
+ klass.define_method(pred) { public_send(attr) }
47
+ end
48
+
49
+ # recursive to_h
50
+ klass.define_method(:to_h) do
51
+ # @type var h: Hash[Symbol, untyped]
52
+ h = {}
53
+ klass.members.each do |m|
54
+ v = public_send(m)
55
+ h[m] =
56
+ case v
57
+ when Array then v.map { |x| x.respond_to?(:to_h) && x ? x.to_h : x }
58
+ when ->(x) { x.respond_to?(:to_h) && x } then v.to_h
59
+ else v
60
+ end
57
61
  end
58
- result
62
+ h
59
63
  end
60
64
 
61
- data_class.define_singleton_method(:parse) do |data = {}, **kwargs|
62
- # Merge kwargs into data - kwargs take priority as overrides
63
- # Convert kwargs symbol keys to strings to match source_key lookups
65
+ # parse accepts JSON-ish hashes + kwargs override
66
+ klass.define_singleton_method(:parse) do |data = {}, **kwargs|
67
+ return data if data.is_a?(self)
68
+
69
+ unless data.respond_to?(:merge!)
70
+ raise TypeError, "can't convert #{data.class} into #{self}"
71
+ end
72
+
73
+ # @type var kwargs: Hash[Symbol, untyped]
64
74
  string_kwargs = kwargs.transform_keys(&:to_s)
65
- data = data.merge(string_kwargs)
75
+ data.merge!(string_kwargs)
76
+ # @type self: singleton(Data) & _StructuredDataClass
77
+ # @type var final: Hash[Symbol, untyped]
78
+ final = {}
66
79
 
67
- final_kwargs = {}
68
- attributes.each do |attr|
69
- source_key = mappings[attr]
70
- value = if data.key?(source_key)
71
- data[source_key]
72
- elsif data.key?(source_key.to_sym)
73
- data[source_key.to_sym]
74
- elsif defaults.key?(attr)
75
- defaults[attr]
76
- end
80
+ # TODO: `__structure_meta__` exists but seems not to return the types it defines, so going untyped for now
81
+ #
82
+ # @type var meta: untyped
83
+ meta = __structure_meta__
77
84
 
78
- # Apply type coercion or transformation
79
- if types[attr] && !value.nil?
80
- type_or_proc = types[attr]
81
- # Use instance_exec for non-lambda procs (self-referential types)
82
- value = if type_or_proc.is_a?(Proc) && !type_or_proc.lambda?
83
- instance_exec(value, &type_or_proc)
84
- else
85
- type_or_proc.call(value)
85
+ attributes = meta.fetch(:attributes)
86
+ defaults = meta.fetch(:defaults)
87
+
88
+ attributes.each do |attr|
89
+ source = mappings[attr] || attr.to_s
90
+ value =
91
+ if data.key?(source) then data[source]
92
+ elsif data.key?(source.to_sym) then data[source.to_sym]
93
+ elsif defaults.key?(attr) then defaults[attr]
86
94
  end
95
+
96
+ coercion = coercions[attr]
97
+ if coercion && !value.nil?
98
+ # self-referential types need class context to call parse
99
+ value =
100
+ if coercion.is_a?(Proc) && !coercion.lambda?
101
+ instance_exec(value, &coercion) # steep:ignore
102
+ else
103
+ coercion.call(value)
104
+ end
87
105
  end
88
106
 
89
- final_kwargs[attr] = value
107
+ final[attr] = value
90
108
  end
91
109
 
92
- instance = new(**final_kwargs)
93
- after_parse_callback&.call(instance)
94
- instance
110
+ obj = new(**final)
111
+ after&.call(obj)
112
+ obj
95
113
  end
96
114
 
97
- data_class
115
+ klass
98
116
  end
99
117
  end
100
118
  end
@@ -0,0 +1,21 @@
1
+ module Structure
2
+ class Builder
3
+ @mappings: Hash[Symbol, String]
4
+ @types: Hash[Symbol, untyped]
5
+ @defaults: Hash[Symbol, untyped]
6
+ @after_parse_callback: Proc?
7
+
8
+ def attribute: (Symbol name, untyped type, ?from: String?, ?default: untyped) ?{ (untyped) -> untyped } -> void
9
+ | (Symbol name, ?from: String, ?default: untyped) ?{ (untyped) -> untyped } -> void
10
+
11
+ def after_parse: () { (Data) -> void } -> void
12
+
13
+ def attributes: () -> Array[Symbol]
14
+ def mappings: () -> Hash[Symbol, String]
15
+ def types: () -> Hash[Symbol, untyped]
16
+ def defaults: () -> Hash[Symbol, untyped]
17
+ def coercions: () -> Hash[Symbol, Proc]
18
+ def predicate_methods: () -> Hash[Symbol, Symbol]
19
+ def after_parse_callback: () -> (Proc | nil)
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ module Structure
2
+ module RBS
3
+ def self.emit: (untyped klass) -> String?
4
+ def self.write: (untyped klass, ?dir: String) -> String?
5
+
6
+ private def self.emit_rbs_content: (class_name: String, attributes: Array[Symbol], types: Hash[Symbol, untyped], has_structure_modules: bool) -> String
7
+ private def self.parse_data_type: (untyped type, String class_name) -> String
8
+ private def self.map_type_to_rbs: (untyped type, String class_name) -> String
9
+ end
10
+ end
@@ -0,0 +1,16 @@
1
+ module Structure
2
+ module Types
3
+ BOOLEAN_TRUTHY: Array[untyped]
4
+
5
+ self.@boolean: Proc
6
+
7
+ def self.coerce: (untyped type) -> untyped
8
+
9
+ private def self.boolean: () -> Proc
10
+ private def self.self_referential: () -> Proc
11
+ private def self.array: (untyped type) -> Proc
12
+ private def self.parseable: (untyped type) -> Proc
13
+ private def self.kernel: (Class type) -> Proc
14
+ private def self.parse: (untyped val) -> untyped
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ module Structure
2
+ VERSION: String
3
+ end
data/sig/structure.rbs ADDED
@@ -0,0 +1,12 @@
1
+ module Structure
2
+ interface _StructuredDataClass
3
+ def __structure_meta__: () -> {
4
+ attributes: Array[Symbol],
5
+ types: Hash[Symbol, untyped],
6
+ defaults: Hash[Symbol, untyped]
7
+ }
8
+ def parse: (?Hash[String | Symbol, untyped] data, **untyped kwargs) -> instance
9
+ end
10
+
11
+ def self.new: () ?{ (Structure::Builder) [self: Structure::Builder] -> void } -> untyped
12
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: structure
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hakan Ensari
@@ -17,8 +17,14 @@ extra_rdoc_files: []
17
17
  files:
18
18
  - lib/structure.rb
19
19
  - lib/structure/builder.rb
20
+ - lib/structure/rbs.rb
20
21
  - lib/structure/types.rb
21
22
  - lib/structure/version.rb
23
+ - sig/structure.rbs
24
+ - sig/structure/builder.rbs
25
+ - sig/structure/rbs.rbs
26
+ - sig/structure/types.rbs
27
+ - sig/structure/version.rbs
22
28
  licenses:
23
29
  - MIT
24
30
  metadata: