sorbet_typed-props 0.1.1

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,131 @@
1
+ # SorbetTyped::Props
2
+
3
+ An extension of sorbets native props syntax, to make it usable and fully typed in any class. Mainly provides a tapioca
4
+ dsl compiler to generate the initializer signature when using props outside of `T::Struct`.
5
+
6
+ You can use the [`T::Struct` `props` and `const` syntax](https://sorbet.org/docs/tstruct) to define attributes on any
7
+ class.
8
+
9
+ This should make it easier to create classes with a set of attributes they should be initialized with. Inspiration was
10
+ the
11
+ [integration of literal properties into phlex](https://www.phlex.fun/miscellaneous/literal-properties.html#literal-properties),
12
+ which doesn't really work with sorbet, if you want to have everything fully typed (and not use two runtime typesystems
13
+ in parallel).
14
+
15
+ ## Installation
16
+
17
+ Install the gem and add to the application's Gemfile by executing:
18
+
19
+ ```bash
20
+ bundle add sorbet_typed-props
21
+ ```
22
+
23
+ If bundler is not being used to manage dependencies, install the gem by executing:
24
+
25
+ ```bash
26
+ gem install sorbet_typed-props
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Include the `SorbetTyped::Props` module in any class you want to have `T::Struct`-like attributes:
32
+
33
+ ```ruby
34
+ class MyClass
35
+ include SorbetTyped::Props
36
+
37
+ prop :my_prop, String
38
+ end
39
+
40
+ my_object = MyClass.new(my_prop: 'foo')
41
+
42
+ my_object.my_prop # => "foo"
43
+ my_object.my_prop = 'bar'
44
+ ```
45
+
46
+ ### Method Visibility
47
+
48
+ If you want attributes in your initializer but not be part of your public class interface, you can use ruby's visibility
49
+ modifiers. Unfortunately I found no better way and did not want to modify sorbet's prop syntax.
50
+
51
+ ```ruby
52
+ class MyClass
53
+ extend T::Sig
54
+ include SorbetTyped::Props
55
+
56
+ prop :my_prop, Integer # reader and writer are public
57
+ const :my_const, String # reader ist public, has no writer
58
+
59
+ prop :prop_with_private_writer, String # reader is public, writer should be private
60
+ private :prop_with_private_writer= # makes the writer private
61
+
62
+ const :my_private_prop, String # reader and writer are private
63
+ private :my_private_prop, :my_private_prop=
64
+
65
+ sig { void }
66
+ def foo
67
+ self.prop_with_private_writer = 'foo'
68
+ end
69
+
70
+ sig { returns(String) }
71
+ def bar
72
+ self.my_private_prop
73
+ end
74
+
75
+ sig { void }
76
+ def baz
77
+ self.my_private_prop = 'baz'
78
+ end
79
+ end
80
+
81
+ my_object = MyClass.new(my_prop: 1, my_const: 'my_const', prop_with_private_writer: 'abc', my_private_prop: 'xyz')
82
+
83
+ my_object.my_prop # => 1
84
+ my_object.my_prop = 2
85
+
86
+ my_object.my_const # => "my_const"
87
+ my_object.my_const = 'abc' # => Setter method `my_const=` does not exist on `MyClass`
88
+
89
+ my_object.prop_with_private_writer # => "abc"
90
+ my_object.prop_with_private_writer = 'foo' # => Non-private call to private method `prop_with_private_writer=` on `MyClass`
91
+ my_object.foo
92
+ my_object.prop_with_private_writer # => "foo"
93
+
94
+ my_object.my_private_prop # => Non-private call to private method `my_private_prop` on `MyClass`
95
+ my_object.bar # => "xyz"
96
+ my_object.my_private_prop = 'baz' # => Non-private call to private method `my_private_prop=` on `MyClass`
97
+ my_object.baz
98
+ my_object.bar # => "baz"
99
+ ```
100
+
101
+ Putting prop-definition after an access modifier without arguments does not work:
102
+
103
+ ```ruby
104
+ class MyClass
105
+ include SorbetTyped::Props
106
+
107
+ private
108
+
109
+ prop :my_prop, String # <= still public
110
+ end
111
+ ```
112
+
113
+ ## Development
114
+
115
+ The project uses [mise-en-place](https://mise.jdx.dev/) as development tool.
116
+
117
+ After checking out the repo, run `mise run setup` to install dependencies. Then, run `mise test` to run the tests. You
118
+ can also run `mise task ls` for a list of available tasks.
119
+
120
+ RSpec is used as test suite. Spec files can and should be placed right beside their associated class files.
121
+
122
+ <!-- WIP: develop and document release workflow -->
123
+
124
+ <!-- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the
125
+ version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version,
126
+ push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). -->
127
+
128
+ ## Contributing
129
+
130
+ Bug reports and pull requests are welcome on GitLab at
131
+ [gitlab.com/sorbet_typed/props](https://gitlab.com/sorbet_typed/props).
@@ -0,0 +1,8 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SorbetTyped # rubocop:disable Style/ClassAndModuleChildren -- Disabled, because this file gets required in gemspec, where zeitwerk is not active
5
+ module Props
6
+ VERSION = '0.1.1'
7
+ end
8
+ end
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require_relative 'props/version'
6
+
7
+ # an abstraction module to be included in any class to make the sorbet props
8
+ # interface usable. It's just a wrapper around T::Props and
9
+ # T::Props::Constructor. It's also the ancestor the custom tapioca compiler
10
+ # looks for when generating initializer type annotation rbis.
11
+ module SorbetTyped::Props
12
+ extend T::Helpers
13
+
14
+ include T::Props
15
+ include T::Props::Constructor
16
+
17
+ mixes_in_class_methods(T::Props::ClassMethods)
18
+ end
@@ -0,0 +1,9 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require_relative 'railway/version'
6
+
7
+ # This class provides a monad-style railway pattern, inspired by dry-monads do-syntax
8
+ class SorbetTyped::Railway # rubocop:todo Lint/EmptyClass
9
+ end
@@ -0,0 +1,16 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ class Typed::Monads::FailFast
5
+ extend T::Generic
6
+
7
+ Elem = type_member(:out)
8
+
9
+ sig { returns(Elem) }
10
+ attr_reader :value
11
+
12
+ sig { params(value: Elem).void }
13
+ def initialize(value)
14
+ @value = value
15
+ end
16
+ end
@@ -0,0 +1,259 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # WIP: do we need https://sorbet.org/docs/exhaustiveness
5
+
6
+ # WIP: move subclasses into own files
7
+
8
+ # implementation of a sorbet-typed and -compatible monad-style short-circuit
9
+ # pattern where you can run multiple steps, use their value OR let them return
10
+ # early.
11
+ #
12
+ # Developed by @kramer and pending to be made open source under
13
+ # https://gitlab.com/richardkramer/sorbet_typed. But for now we want to already
14
+ # use the code, so everything already working is copied here.
15
+ #
16
+ # How to use:
17
+ #
18
+
19
+ # ```ruby
20
+ # sig { returns(SuccessType, SorbetTyped::ShortCircuit::Shorted[ShortedType]))}
21
+ # def do_something
22
+ # return SorbetTyped::ShortCircuit.short(info: shorted_value) if something_wrong?
23
+
24
+ # return SuccessType.new
25
+ # end
26
+ #
27
+ # # this will return either the success type or a shorted result with the
28
+ # # shorted type as `info`.
29
+ # result = SorbetTyped::ShortCircuit[SuccessType, ShortedType].new.run do |circuit_breaker|
30
+ # foo = circuit_breaker.(do_something) # => will break early if do_something returns a circuit breaking result
31
+ # bar = circuit_breaker.(do_something_else(foo)) # => will break early if do_something_else(foo) returns a circuit breaking result
32
+
33
+ # do_another_thing(bar)
34
+ # end
35
+ # ```
36
+ #
37
+ # SuccessType and ShortedType might be anything you want. Just specify the types
38
+ # when initializing the SorbetTyped::ShortCircuit instance.
39
+ module SorbetTyped
40
+ class ShortCircuit
41
+ extend T::Generic
42
+
43
+ # The error thrown and handled within the run block to implement failing fast
44
+ # from any nesting depth
45
+ class CircuitShortedError < StandardError
46
+ extend T::Generic
47
+
48
+ FailureData = type_member(:out)
49
+
50
+ sig { returns(FailureData) }
51
+ attr_reader :failure
52
+
53
+ sig { params(failure: FailureData).void }
54
+ def initialize(failure:)
55
+ @failure = failure
56
+ super()
57
+ end
58
+ end
59
+
60
+ # Signal to fail fast. Info can be anything, e.g. an error object.
61
+ class Shorted
62
+ extend T::Generic
63
+
64
+ Elem = type_member(:out)
65
+
66
+ sig { returns(Elem) }
67
+ attr_reader :info
68
+
69
+ sig { params(info: Elem).void }
70
+ def initialize(info)
71
+ @info = info
72
+ end
73
+ end
74
+
75
+ # Callable implementing the logic to fail fast if the input is a Shorted
76
+ # signal. To be used like Dry::Monads `yield foo` syntax.
77
+ #
78
+ # Injected as parameter into a short circuit block, so you can use it directly.
79
+ class CircuitBreaker
80
+ extend T::Generic
81
+
82
+ Result = type_member
83
+
84
+ sig do
85
+ type_parameters(:U, :E).
86
+ params(result: T.all(T.type_parameter(:U), T.any(T.type_parameter(:E), Shorted[Result]))).
87
+ returns(T.type_parameter(:E))
88
+ end
89
+ def call(result)
90
+ case result
91
+ when Shorted
92
+ raise CircuitShortedError[T.type_parameter(:U)].new(failure: result)
93
+ else
94
+ result
95
+ end
96
+ end
97
+ end
98
+
99
+ SuccessResult = type_member
100
+ ShortedResult = type_member
101
+
102
+ sig do
103
+ type_parameters(:U).
104
+ params(info: T.type_parameter(:U)).
105
+ returns(Shorted[T.type_parameter(:U)])
106
+ end
107
+ def self.short(info:)
108
+ Shorted[T.type_parameter(:U)].new(info)
109
+ end
110
+
111
+ private
112
+
113
+ # Store if the last run failed fast
114
+ sig { returns(T::Boolean) }
115
+ attr_accessor :short_circuited
116
+
117
+ public
118
+
119
+ sig { void }
120
+ def initialize
121
+ @short_circuited = T.let(false, T::Boolean)
122
+ end
123
+
124
+ sig do
125
+ params(
126
+ _block:
127
+ T.proc.
128
+ params(fail_fast: CircuitBreaker[ShortedResult]).
129
+ returns(T.any(SuccessResult, Shorted[ShortedResult]))
130
+ ).
131
+ returns(T.any(SuccessResult, Shorted[ShortedResult]))
132
+ end
133
+ def run(&_block)
134
+ self.short_circuited = false
135
+ yield(CircuitBreaker[ShortedResult].new)
136
+ rescue CircuitShortedError => exception
137
+ self.short_circuited = true
138
+
139
+ # NOTE: sorbet is only able to detect
140
+ # `SorbetTyped::ShortCircuit::CircuitShortedError[T.anything]` in this rescue. But
141
+ # because it must come from `CircuitBreaker[ShortedResult]`, it will also
142
+ # always be `CircuitShortedError[SorbetTyped::ShortCircuit::Shorted[ShortedResult]]`. So we
143
+ # tell that to sorbet.
144
+ #
145
+ # NOTE: if one would manually throw this exception WITH A DIFFERENT failure
146
+ # data type, this would not raise, because generic types get erased at
147
+ # runtime. So just NEVER raise the CircuitShortedError error manually.
148
+ T.cast(exception, CircuitShortedError[SorbetTyped::ShortCircuit::Shorted[ShortedResult]]).failure
149
+ end
150
+
151
+ sig do
152
+ params(
153
+ block:
154
+ T.proc.
155
+ params(fail_fast: CircuitBreaker[ShortedResult]).
156
+ returns(T.any(SuccessResult, SorbetTyped::ShortCircuit::Shorted[ShortedResult]))
157
+ ).
158
+ returns(T.any(SuccessResult, ShortedResult))
159
+ end
160
+ def run_without_signal(&block)
161
+ result = run(&block)
162
+
163
+ case result
164
+ when SorbetTyped::ShortCircuit::Shorted
165
+ # NOTE: sorbet is only able to detect
166
+ # ```
167
+ # T.all(
168
+ # Typed::Monads::Shorted[T.anything],
169
+ # T.any(
170
+ # Typed::Monads::Shorted[Typed::Monads::ShortCircuit::ShortedResult],
171
+ # Typed::Monads::ShortCircuit::SuccessResult
172
+ # )
173
+ # )
174
+ # ```
175
+ # , which leaves no other option
176
+ # than `Typed::Monads::Shorted[ShortedResult]`. So we tell that to the
177
+ # typechecker.
178
+ T.cast(result, SorbetTyped::ShortCircuit::Shorted[ShortedResult]).info
179
+ else
180
+ result
181
+ end
182
+ end
183
+
184
+ sig { returns(T::Boolean) }
185
+ def last_run_short_circuited?
186
+ short_circuited
187
+ end
188
+ end
189
+ end
190
+
191
+ #####################################################################################################################
192
+ #####################################################################################################################
193
+ #####################################################################################################################
194
+
195
+ # An example class using above short circuit pattern. It's fully checked against
196
+ # sorbet. You might comment it in and run `srb tc` to see the `T.reveal_type`
197
+ # results in action.
198
+ #
199
+ # WIP: move to specs
200
+ class SorbetTyped::ShortCircuitExample
201
+ extend T::Sig
202
+
203
+ sig { void }
204
+ def call
205
+ # Success Value might be Integer or String, Failure might be Symbol or Float
206
+ short_circuit = SorbetTyped::ShortCircuit[T.any(Integer, String), T.any(Symbol, Float)].new
207
+
208
+ result = short_circuit.run do |circuit_breaker|
209
+ T.reveal_type(circuit_breaker.(3)) # => Integer; sorbet knows the Input-Type would be the output type if it's not a Shorted signal
210
+
211
+ foo_var = circuit_breaker.(foo)
212
+
213
+ 'bar'
214
+ end
215
+
216
+ short_circuit.last_run_short_circuited?
217
+ # => depends on the `[true, false].sample` below
218
+ #
219
+ # the `foo` call contains a shorted circuit, propagating the Shorted signal outwards and failing this circuit
220
+
221
+ T.reveal_type(result) # => T.any(Integer, String, SorbetTyped::Railway::Shorted[T.any(Symbol, Float)])
222
+
223
+ # the result might be any of the success type or the Shorted signal object
224
+ # with its shorted types. Use case...when logic to operate on the different
225
+ # return types.
226
+
227
+ if result.is_a? SorbetTyped::ShortCircuit::Shorted
228
+ T.reveal_type result # => SorbetTyped::Railway::Shorted[T.any(Symbol, Float)]
229
+ T.reveal_type result.info # => T.any(Symbol, Float); the for the railway defined shorted result types
230
+ puts result.info
231
+
232
+ # handle error
233
+ return
234
+ end
235
+
236
+ T.reveal_type(result)
237
+ # => T.any(Integer, String); sorbet statically knows, this can never be a shorted result, as we handled it before and return early
238
+ end
239
+
240
+ sig do
241
+ returns(T.any(Integer, SorbetTyped::ShortCircuit::Shorted[Symbol]))
242
+ end
243
+ def foo
244
+ SorbetTyped::ShortCircuit[Integer, Symbol].new.run do |circuit_breaker|
245
+ # this sometimes fast fails the short circuit and propagates the signal outwards
246
+ circuit_breaker.call validate # WIP: edge case where wrong typing does not raise errors in typechecker
247
+
248
+ 42
249
+ end
250
+ end
251
+
252
+ sig { returns(T.any(NilClass, SorbetTyped::ShortCircuit::Shorted[Symbol])) }
253
+ def validate
254
+ return SorbetTyped::ShortCircuit.short(info: :foo) if [true, false].sample
255
+ return SorbetTyped::ShortCircuit.short(info: :bar) if [true, false].sample
256
+
257
+ SorbetTyped::ShortCircuit.short(info: :baz) if [true, false].sample
258
+ end
259
+ end
@@ -0,0 +1,8 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SorbetTyped # rubocop:disable Style/ClassAndModuleChildren -- Disabled, because this file gets required in gemspec, where zeitwerk is not active
5
+ class Railway
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ RSpec.describe SorbetTyped::Railway do # rubocop:disable RSpec/SpecFilePathFormat
5
+ let(:version) { SorbetTyped::Railway::VERSION }
6
+
7
+ it 'has a version number' do
8
+ expect(version).not_to be_nil
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ RSpec.describe SorbetTyped::Railway do # rubocop:todo Lint/EmptyBlock, RSpec/EmptyExampleGroup
5
+ end
@@ -0,0 +1,86 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Tapioca # rubocop:disable Style/ClassAndModuleChildren
5
+ module Dsl
6
+ module Compilers
7
+ class SorbetTypedPropsConstructor < Tapioca::Dsl::Compiler
8
+ # convert sorbet prop to rbi parameter
9
+ class PropToParamConverter
10
+ # representation of sorbet prop details
11
+ class Details < T::ImmutableStruct
12
+ # identifiable default value when the respective key was not passed to the struct deserializer
13
+ class ParamValueEnum < T::Enum # rubocop:disable Sorbet/MultipleTEnumValues -- an enum is an easy way to have an identifiable object without having to rely on it's value. Maybe there is another way, but I could not get it to work with a simple class and a constant would be value-bound.
14
+ enums do
15
+ ParamNotPresent = new
16
+ end
17
+ end
18
+
19
+ extend T::Sig
20
+
21
+ const :type_object, T::Types::Simple
22
+ const :_nilable, T::Boolean, default: false
23
+ const :default, Object, default: ParamValueEnum::ParamNotPresent
24
+ const :factory, Object, default: ParamValueEnum::ParamNotPresent
25
+
26
+ sig { returns(T::Boolean) }
27
+ def optional?
28
+ _nilable || default != ParamValueEnum::ParamNotPresent || factory != ParamValueEnum::ParamNotPresent
29
+ end
30
+ end
31
+
32
+ extend T::Sig
33
+ include Tapioca::RBIHelper
34
+
35
+ private
36
+
37
+ sig { returns(Symbol) }
38
+ attr_reader :name
39
+
40
+ sig { returns(Details) }
41
+ attr_reader :details
42
+
43
+ public
44
+
45
+ sig { params(name: Symbol, details: Details).void }
46
+ def initialize(name:, details:)
47
+ @name = name
48
+ @details = details
49
+ end
50
+
51
+ sig { returns(RBI::TypedParam) }
52
+ def create_rbi_parameter
53
+ if optional?
54
+ create_kw_opt_param(parameter_name, type:, default: 'nil')
55
+ else
56
+ create_kw_param(parameter_name, type:)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ sig { returns(T::Boolean) }
63
+ def optional?
64
+ details.optional?
65
+ end
66
+
67
+ sig { returns(String) }
68
+ def type
69
+ details_type = details.type_object.name
70
+
71
+ if optional?
72
+ "T.nilable(#{details_type})"
73
+ else
74
+ details_type.to_s
75
+ end
76
+ end
77
+
78
+ sig { returns(String) }
79
+ def parameter_name
80
+ name.to_s
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,75 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'sorbet_typed_props_constructor/prop_to_param_converter'
5
+
6
+ module Tapioca # rubocop:disable Style/ClassAndModuleChildren
7
+ module Dsl
8
+ module Compilers
9
+ # tapioca compiler generating rbi containing the initializer interface for a class using sorbet SorbetTyped::Props
10
+ class SorbetTypedPropsConstructor < Tapioca::Dsl::Compiler
11
+ extend T::Sig
12
+ extend T::Generic
13
+
14
+ ConstantType = type_member { { fixed: T.class_of(SorbetTyped::Props) } }
15
+
16
+ sig { override.returns(T::Enumerable[Module]) }
17
+ def self.gather_constants
18
+ # Collect all the classes that include SorbetTyped::Props
19
+ all_classes.select { |klass| klass < SorbetTyped::Props }
20
+ end
21
+
22
+ sig { override.void }
23
+ def decorate
24
+ # NOTE: do not generate a new initializer signature, if the class sets
25
+ # its own and doesn't use the prop generated one.
26
+ return if constant.private_instance_methods(false).include?(:initialize)
27
+
28
+ props = constant.props
29
+ return if props.empty?
30
+
31
+ params = params_from_props(props:)
32
+ define_initializer_sig(constant:, params:)
33
+ end
34
+
35
+ private
36
+
37
+ sig { params(props: T.untyped).returns(T::Array[RBI::TypedParam]) }
38
+ def params_from_props(props:)
39
+ props.map do |name, details|
40
+ create_param_from_prop(name:, details:)
41
+ end
42
+ end
43
+
44
+ sig { params(name: Symbol, details: T.untyped).returns(RBI::TypedParam) }
45
+ def create_param_from_prop(name:, details:)
46
+ details_struct =
47
+ Tapioca::Dsl::Compilers::SorbetTypedPropsConstructor::PropToParamConverter::Details.
48
+ from_hash(details.transform_keys(&:to_s))
49
+
50
+ Tapioca::Dsl::Compilers::SorbetTypedPropsConstructor::PropToParamConverter.
51
+ new(
52
+ name:,
53
+ details: details_struct
54
+ ).
55
+ create_rbi_parameter
56
+ end
57
+
58
+ # HACK: `T.class_of(SorbetTyped::Props)` fails when passed one of the
59
+ # anonymous test classes. But active typechecking isn't that relevant
60
+ # here.
61
+ sig { params(constant: T.class_of(SorbetTyped::Props), params: T::Array[RBI::TypedParam]).void.checked(:never) }
62
+ def define_initializer_sig(constant:, params:)
63
+ root.create_path(constant) do |klass|
64
+ klass.create_method(
65
+ 'initialize',
66
+ parameters: params,
67
+ return_type: 'void',
68
+ comments: [RBI::Comment.new('Generated from sorbet SorbetTyped::Props')]
69
+ )
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end