value_semantics 3.2.1 → 3.6.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f122fa566277c213c7eb2a31b38af94d4287b1688d91da91bd5eea8044a041ba
4
- data.tar.gz: 371bc15f0cc7449042b19d5064e4a28f76ec1d6c0aae2c64622adf3252621045
3
+ metadata.gz: 2b067417b01eb9706289a6bfa78e45b8eb38f2e08ca02e3d0c8818d34be3dee8
4
+ data.tar.gz: 526e22dc311d0639e0c48e204c967d42e339bd31c0fd3d3f86005c850f44d9f5
5
5
  SHA512:
6
- metadata.gz: a7ebe03be2de318e43027cc4d27ed737111654a5d74c0646f62691076e0f51938eb55966a3f04105f4a4c86d242a0134ca3f5fafa27089aedd4c51f2fb45abd9
7
- data.tar.gz: ac4e7a9f938e0bd9193d4260a8c211db6a05554463b6312a6409499a8d22c7a34b281093cf2c487676fefd58915b21027a47c99a75619fb2b80c457672ced57e
6
+ metadata.gz: 19acb4f70ab18d16f321c1396b8ea38acaca41ec7f3c998394b4c6862ae1a05d8b91bfe788d2904047d128dc297b1a4ec8745481ae7ffd1892f3c6a052c6d6dd
7
+ data.tar.gz: b2432643ae8b58dbf008a8dad4e39db5c3560c91f2c4a110f420f915d6c2a4e5a6244d66737530ef2d68a2a2d22136fa003923739d6214379913c03faba5796f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,88 @@ Notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.6.1] - 2021-06-20
9
+ ### Fixed
10
+ - Attribute reader methods can be made `protected` or `private` now,
11
+ where previously that would cause various instance methods to raise
12
+ `NoMethodError`.
13
+
14
+ ## [3.6.0] - 2020-09-01
15
+ ### Added
16
+ - `RangeOf` built-in validator, for validating `Range` objects
17
+ - `HashCoercer` built-in coercer for homogeneous `Hash` objects
18
+ ### Changed
19
+ - Optimised speed of value object initialization. It is now roughly 3x
20
+ slower than that of a hand-written class, which is 2-3x faster than
21
+ the previous version.
22
+
23
+ - Optimised memory allocation in object initialization. The happy path
24
+ (no exceptions raised) only allocates a single array object, under
25
+ normal circumstances. Extra allocations are likely caused by custom
26
+ validators, coercers, and default generators.
27
+
28
+ - Exceptions raised when initialising a value object are now
29
+ aggregated. Instead of telling you the problematic attributes one at
30
+ a time, you will get a list of all offending attributes in the
31
+ exception message. This applies to `MissingAttributes`,
32
+ `InvalidValue` and `UnrecognizedAttributes`. These will probably be
33
+ combined into a single exception in v4.0, so you can see all the
34
+ initialization problems at once.
35
+
36
+ - The exceptions `ValueSemantics::MissingAttributes` and
37
+ `ValueSemantics::InvalidValue` are now raised from inside
38
+ `initialize`. They were previously raised from inside of
39
+ `ValueSemantics::Attribute.determine_from!` which is an internal
40
+ implementation detail that is basically gibberish to any developer
41
+ reading it. The stack trace for this exception reads much better.
42
+
43
+ - The exception `ValueSemantics::UnrecognizedAttributes` is now raised
44
+ instead of `ValueSemantics::MissingAttributes` in the situation
45
+ where both exceptions would be raised. This makes it easier to debug
46
+ the problem where you attempt to initialize a value object using a
47
+ hash with string keys instead of symbol keys.
48
+
49
+ - The coercer returned from the `.coercer` class method is now
50
+ smarter. It handles string keys, handles objects that can be
51
+ converted to hashes.
52
+ ### Deprecated
53
+ - `ValueSemantics::Attribute#determine_from!`. This was an internal
54
+ implementation detail, which is no longer used internally. Use the
55
+ `name`, `#coerce`, `#optional?`, `#default_generator` and
56
+ `#validate?` methods directly if you want to extract an attribute
57
+ from a hash.
58
+ - `ValueSemantics::NoDefaultError`. Use `Attribute#optional?` to check
59
+ whether there is a default.
60
+
61
+
62
+
63
+ ## [3.5.0] - 2020-08-17
64
+ ### Added
65
+ - Square bracket attr reader like `person[:name]`
66
+ - `HashOf` built-in validator, similar to `ArrayOf`
67
+ - `.coercer` class method, to help when composing value objects
68
+ - `ArrayCoercer` DSL method, to help when composing value objects
69
+
70
+ ## [3.4.0] - 2020-08-01
71
+ ### Added
72
+ - Value objects can be instantiated from any object that responds to `#to_h`.
73
+ Previously attributes were required to be given as a `Hash`.
74
+
75
+ - Added monkey patching for super-convenient attribute definitions. This is
76
+ **not** available by default, and needs to be explicitly enabled with
77
+ `ValueSemantics.monkey_patch!` or `require 'value_semantics/monkey_patched'`.
78
+
79
+ ### Changed
80
+ - Improved exception messages for easier development experience
81
+
82
+ - Raises `ValueSemantics::InvalidValue` instead of `ArgumentError` when
83
+ attempting to initialize with an invalid value. `ValueSemantics::InvalidValue`
84
+ is a subclass of `ArgumentError`, so this change should be backward
85
+ compatible.
86
+
87
+ ## [3.3.0] - 2020-07-17
88
+ ### Added
89
+ - Added support for pattern matching in Ruby 2.7
8
90
 
9
91
  ## [3.2.1] - 2020-07-11
10
92
  ### Fixed
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/value_semantics.svg)](https://badge.fury.io/rb/value_semantics)
2
2
  [![Build Status](https://travis-ci.org/tomdalling/value_semantics.svg?branch=master)](https://travis-ci.org/tomdalling/value_semantics)
3
- ![Mutation Coverage](https://img.shields.io/badge/mutation%20coverage-100%25-brightgreen.svg)
3
+ ![Mutation Coverage](https://img.shields.io/badge/mutation%20coverage-to%20the%20MAX-brightgreen.svg)
4
4
 
5
5
  ValueSemantics
6
6
  ==============
@@ -15,10 +15,16 @@ These are intended for internal use, as opposed to validating user input like Ac
15
15
  Invalid or missing attributes cause an exception for developers,
16
16
  not an error message intended for application users.
17
17
 
18
- See the [announcement blog post][] for some of the rationale behind the gem, and some [discussion on Reddit].
18
+ See:
19
19
 
20
- [announcement blog post]: https://www.rubypigeon.com/posts/value-semantics-gem-for-making-value-classes/
21
- [discussion on Reddit]: https://www.reddit.com/r/ruby/comments/akz4fs/valuesemanticsa_gem_for_making_value_classes/
20
+ - The [announcement blog post][blog post] for some of the rationale behind the gem
21
+ - [RubyTapas episode #584][rubytapas] for an example usage scenario
22
+ - The [API documentation](https://rubydoc.info/gems/value_semantics)
23
+ - Some [discussion on Reddit][reddit]
24
+
25
+ [blog post]: https://www.rubypigeon.com/posts/value-semantics-gem-for-making-value-classes/
26
+ [rubytapas]: https://www.rubytapas.com/2019/07/09/from-hash-to-value-object/
27
+ [reddit]: https://www.reddit.com/r/ruby/comments/akz4fs/valuesemanticsa_gem_for_making_value_classes/
22
28
 
23
29
 
24
30
  Defining and Creating Value Objects
@@ -44,15 +50,19 @@ Person.new(name: "Tom", birthday: "2020-12-25")
44
50
  #=> #<Person name="Tom" birthday=#<Date: 2020-12-25 ((2459209j,0s,0n),+0s,2299161j)>>
45
51
 
46
52
  Person.new(birthday: Date.today)
47
- #=> #<Person name="Anon Emous" birthday=#<Date: 2018-09-04 ((2458366j,0s,0n),+0s,2299161j)>>
53
+ #=> #<Person name="Anon Emous" birthday=#<Date: 2020-08-30 ((2459092j,0s,0n),+0s,2299161j)>>
48
54
 
49
55
  Person.new(birthday: nil)
50
56
  #=> #<Person name="Anon Emous" birthday=nil>
51
57
  ```
52
58
 
53
- The curly bracket syntax used with `ValueSemantics.for_attributes` is, unfortunately,
54
- mandatory due to Ruby's precedence rules.
55
- The `do`/`end` syntax will not work unless you surround the whole thing with parenthesis.
59
+ Value objects are typically initialized with keyword arguments or a `Hash`, but
60
+ will accept any object that responds to `#to_h`.
61
+
62
+ The curly bracket syntax used with `ValueSemantics.for_attributes` is,
63
+ unfortunately, mandatory due to Ruby's precedence rules. For a shorter
64
+ alternative method that works better with `do`/`end`, see [Convenience (Monkey
65
+ Patch)](#convenience-monkey-patch) below.
56
66
 
57
67
 
58
68
  Using Value Objects
@@ -71,39 +81,69 @@ end
71
81
  tom = Person.new(name: 'Tom')
72
82
 
73
83
 
74
- #
75
84
  # Read-only attributes
76
- #
77
- tom.name #=> "Tom"
78
- tom.age #=> 31
79
-
85
+ tom.name #=> "Tom"
86
+ tom[:name] #=> "Tom"
80
87
 
81
- #
82
88
  # Convert to Hash
83
- #
84
- tom.to_h #=> { :name => "Tom", :age => 31 }
89
+ tom.to_h #=> {:name=>"Tom", :age=>31}
85
90
 
86
-
87
- #
88
91
  # Non-destructive updates
89
- #
90
- old_tom = tom.with(age: 99)
91
-
92
- old_tom #=> #<Person name="Tom" age=99>
93
- tom #=> #<Person name="Tom" age=31> (unchanged)
94
-
92
+ tom.with(age: 99) #=> #<Person name="Tom" age=99>
93
+ tom # (unchanged) #=> #<Person name="Tom" age=31>
95
94
 
96
- #
97
95
  # Equality
98
- #
99
96
  other_tom = Person.new(name: 'Tom', age: 31)
100
-
101
97
  tom == other_tom #=> true
102
98
  tom.eql?(other_tom) #=> true
103
99
  tom.hash == other_tom.hash #=> true
100
+
101
+ # Ruby 2.7+ pattern matching
102
+ case tom
103
+ in name: "Tom", age:
104
+ puts age
105
+ end
106
+ # outputs: 31
104
107
  ```
105
108
 
106
109
 
110
+ Convenience (Monkey Patch)
111
+ --------------------------
112
+
113
+ There is a shorter way to define value attributes:
114
+
115
+ ```ruby
116
+ require 'value_semantics/monkey_patched'
117
+
118
+ class Monkey
119
+ value_semantics do
120
+ name String
121
+ age Integer
122
+ end
123
+ end
124
+ ```
125
+
126
+ **This is disabled by default**, to avoid polluting every class with an extra
127
+ class method.
128
+
129
+ This convenience method can be enabled in two ways:
130
+
131
+ 1. Add a `require:` option to your `Gemfile` like this:
132
+
133
+ ```ruby
134
+ gem 'value_semantics', '~> 3.3', require: 'value_semantics/monkey_patched'
135
+ ```
136
+
137
+ 2. Alternatively, you can call `ValueSemantics.monkey_patch!` somewhere early
138
+ in the boot sequence of your code -- at the top of your script, for example,
139
+ or `config/boot.rb` if it's a Rails project.
140
+
141
+ ```ruby
142
+ require 'value_semantics'
143
+ ValueSemantics.monkey_patch!
144
+ ```
145
+
146
+
107
147
  Defaults
108
148
  --------
109
149
 
@@ -119,7 +159,7 @@ class Cat
119
159
  end
120
160
 
121
161
  Cat.new
122
- #=> #<Cat paws=4 born_at=2018-12-21 18:42:01 +1100>
162
+ #=> #<Cat paws=4 born_at=2020-08-30 22:27:12.237812 +1000>
123
163
  ```
124
164
 
125
165
  The `default` option is a single value.
@@ -148,15 +188,15 @@ class Person
148
188
  }
149
189
  end
150
190
 
151
- Person.new(name: 'Tom', ...) # works
152
- Person.new(name: 5, ...)
153
- #=> ArgumentError:
154
- #=> Value for attribute 'name' is not valid: 5
191
+ Person.new(name: 'Tom', birthday: '2000-01-01') # works
192
+ Person.new(name: 5, birthday: '2000-01-01')
193
+ #=> !!! ValueSemantics::InvalidValue: Some attributes of `Person` are invalid:
194
+ #=* - name: 5
155
195
 
156
- Person.new(birthday: "1970-01-01", ...) # works
157
- Person.new(birthday: "hello", ...)
158
- #=> ArgumentError:
159
- #=> Value for attribute 'birthday' is not valid: "hello"
196
+ Person.new(name: 'Tom', birthday: "1970-01-01") # works
197
+ Person.new(name: 'Tom', birthday: "hello")
198
+ #=> !!! ValueSemantics::InvalidValue: Some attributes of `Person` are invalid:
199
+ #=* - birthday: "hello"
160
200
  ```
161
201
 
162
202
 
@@ -168,13 +208,18 @@ for common situations:
168
208
  ```ruby
169
209
  class LightSwitch
170
210
  include ValueSemantics.for_attributes {
171
-
172
211
  # Bool: only allows `true` or `false`
173
212
  on? Bool()
174
213
 
175
214
  # ArrayOf: validates elements in an array
176
215
  light_ids ArrayOf(Integer)
177
216
 
217
+ # HashOf: validates keys/values of a homogeneous hash
218
+ toggle_stats HashOf(Symbol => Integer)
219
+
220
+ # RangeOf: validates ranges
221
+ levels RangeOf(Integer)
222
+
178
223
  # Either: value must match at least one of a list of validators
179
224
  color Either(Integer, String, nil)
180
225
 
@@ -186,9 +231,12 @@ end
186
231
  LightSwitch.new(
187
232
  on?: true,
188
233
  light_ids: [11, 12, 13],
234
+ toggle_stats: { day: 42, night: 69 },
235
+ levels: (0..10),
189
236
  color: "#FFAABB",
190
237
  wierd_attr: [true, false, true, true],
191
238
  )
239
+ #=> #<LightSwitch on?=true light_ids=[11, 12, 13] toggle_stats={:day=>42, :night=>69} levels=0..10 color="#FFAABB" wierd_attr=[true, false, true, true]>
192
240
  ```
193
241
 
194
242
 
@@ -197,22 +245,26 @@ LightSwitch.new(
197
245
  A custom validator might look something like this:
198
246
 
199
247
  ```ruby
200
- module Odd
248
+ module DottedQuad
201
249
  def self.===(value)
202
- value.odd?
250
+ value.split('.').all? do |part|
251
+ ('0'..'255').cover?(part)
252
+ end
203
253
  end
204
254
  end
205
255
 
206
- class Person
256
+ class Server
207
257
  include ValueSemantics.for_attributes {
208
- age Odd
258
+ address DottedQuad
209
259
  }
210
260
  end
211
261
 
212
- Person.new(age: 9) # works
213
- Person.new(age: 8)
214
- #=> ArgumentError:
215
- #=> Value for attribute 'age' is not valid: 8
262
+ Server.new(address: '127.0.0.1')
263
+ #=> #<Server address="127.0.0.1">
264
+
265
+ Server.new(address: '127.0.0.999')
266
+ #=> !!! ValueSemantics::InvalidValue: Some attributes of `Server` are invalid:
267
+ #=* - address: "127.0.0.999"
216
268
  ```
217
269
 
218
270
  Default attribute values also pass through validation.
@@ -224,46 +276,48 @@ Coercion
224
276
  Coercion allows non-standard or "convenience" values to be converted into
225
277
  proper, valid values, where possible.
226
278
 
227
- For example, an object with an `IPAddr` attribute may allow string values,
228
- which are then coerced into `IPAddr` objects.
279
+ For example, an object with an `Pathname` attribute may allow string values,
280
+ which are then coerced into `Pathname` objects.
229
281
 
230
282
  Using the option `coerce: true`,
231
283
  coercion happens through a custom class method called `coerce_#{attr}`,
232
284
  which takes the raw value as an argument, and returns the coerced value.
233
285
 
234
286
  ```ruby
235
- class Server
287
+ require 'pathname'
288
+
289
+ class Document
236
290
  include ValueSemantics.for_attributes {
237
- address IPAddr, coerce: true
291
+ path Pathname, coerce: true
238
292
  }
239
293
 
240
- def self.coerce_address(value)
294
+ def self.coerce_path(value)
241
295
  if value.is_a?(String)
242
- IPAddr.new(value)
296
+ Pathname.new(value)
243
297
  else
244
298
  value
245
299
  end
246
300
  end
247
301
  end
248
302
 
249
- Server.new(address: '127.0.0.1')
250
- #=> #<Server address=#<IPAddr: IPv4:127.0.0.1/255.255.255.255>>
303
+ Document.new(path: '~/Documents/whatever.doc')
304
+ #=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
251
305
 
252
- Server.new(address: IPAddr.new('127.0.0.1'))
253
- #=> #<Server address=#<IPAddr: IPv4:127.0.0.1/255.255.255.255>>
306
+ Document.new(path: Pathname.new('~/Documents/whatever.doc'))
307
+ #=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
254
308
 
255
- Server.new(address: 42)
256
- #=> ArgumentError:
257
- #=> Value for attribute 'address' is not valid: 42
309
+ Document.new(path: 42)
310
+ #=> !!! ValueSemantics::InvalidValue: Some attributes of `Document` are invalid:
311
+ #=* - path: 42
258
312
  ```
259
313
 
260
314
  You can also use any callable object as a coercer.
261
315
  That means, you could use a lambda:
262
316
 
263
317
  ```ruby
264
- class Server
318
+ class Document
265
319
  include ValueSemantics.for_attributes {
266
- address IPAddr, coerce: ->(value) { IPAddr.new(value) }
320
+ path Pathname, coerce: ->(value) { Pathname.new(value) }
267
321
  }
268
322
  end
269
323
  ```
@@ -271,25 +325,25 @@ end
271
325
  Or a custom class:
272
326
 
273
327
  ```ruby
274
- class MyAddressCoercer
328
+ class MyPathCoercer
275
329
  def call(value)
276
- IPAddr.new(value)
330
+ Pathname.new(value)
277
331
  end
278
332
  end
279
333
 
280
- class Server
334
+ class Document
281
335
  include ValueSemantics.for_attributes {
282
- address IPAddr, coerce: MyAddressCoercer.new
336
+ path Pathname, coerce: MyPathCoercer.new
283
337
  }
284
338
  end
285
339
  ```
286
340
 
287
- Or reuse an existing class method:
341
+ Or reuse an existing method:
288
342
 
289
343
  ```ruby
290
- class Server
344
+ class Document
291
345
  include ValueSemantics.for_attributes {
292
- address IPAddr, coerce: IPAddr.method(:new)
346
+ path Pathname, coerce: Pathname.method(:new)
293
347
  }
294
348
  end
295
349
  ```
@@ -301,7 +355,85 @@ Another option is to raise an error within the coercion method.
301
355
 
302
356
  Default attribute values also pass through coercion.
303
357
  For example, the default value could be a string,
304
- which would then be coerced into an `IPAddr` object.
358
+ which would then be coerced into an `Pathname` object.
359
+
360
+
361
+ ## Built-in Coercers
362
+
363
+ ValueSemantics provides a few built-in coercer objects via the DSL.
364
+
365
+ ```ruby
366
+ class Config
367
+ include ValueSemantics.for_attributes {
368
+ # ArrayCoercer: takes an element coercer
369
+ paths coerce: ArrayCoercer(Pathname.method(:new))
370
+
371
+ # HashCoercer: takes a key and value coercer
372
+ env coerce: HashCoercer(
373
+ keys: :to_sym.to_proc,
374
+ values: :to_i.to_proc,
375
+ )
376
+ }
377
+ end
378
+
379
+ config = Config.new(
380
+ paths: ['/a', '/b'],
381
+ env: { 'AAAA' => '1', 'BBBB' => '2' },
382
+ )
383
+
384
+ config.paths #=> [#<Pathname:/a>, #<Pathname:/b>]
385
+ config.env #=> {:AAAA=>1, :BBBB=>2}
386
+ ```
387
+
388
+
389
+ ## Nesting
390
+
391
+ It is fairly common to nest value objects inside each other. This
392
+ works as expected, but coercion is not automatic.
393
+
394
+ For nested coercion, use the `.coercer` class method that
395
+ ValueSemantics provides. It returns a coercer object that accepts
396
+ strings for attribute names, and will ignore attributes that the value
397
+ class does not define, instead of raising an error.
398
+
399
+ This works well in combination with `ArrayCoercer`.
400
+
401
+ ```ruby
402
+ class CrabClaw
403
+ include ValueSemantics.for_attributes {
404
+ size Either(:big, :small)
405
+ }
406
+ end
407
+
408
+ class Crab
409
+ include ValueSemantics.for_attributes {
410
+ left_claw CrabClaw, coerce: CrabClaw.coercer
411
+ right_claw CrabClaw, coerce: CrabClaw.coercer
412
+ }
413
+ end
414
+
415
+ class Ocean
416
+ include ValueSemantics.for_attributes {
417
+ crabs ArrayOf(Crab), coerce: ArrayCoercer(Crab.coercer)
418
+ }
419
+ end
420
+
421
+ ocean = Ocean.new(
422
+ crabs: [
423
+ {
424
+ 'left_claw' => { 'size' => :small },
425
+ 'right_claw' => { 'size' => :small },
426
+ voiced_by: 'Samuel E. Wright', # this attr will be ignored
427
+ }, {
428
+ 'left_claw' => { 'size' => :big },
429
+ 'right_claw' => { 'size' => :big },
430
+ }
431
+ ]
432
+ )
433
+
434
+ ocean.crabs.first #=> #<Crab left_claw=#<CrabClaw size=:small> right_claw=#<CrabClaw size=:small>>
435
+ ocean.crabs.first.right_claw.size #=> :small
436
+ ```
305
437
 
306
438
 
307
439
  ## ValueSemantics::Struct
@@ -311,11 +443,41 @@ one step, similar to how `Struct` works from the Ruby standard library. For
311
443
  example:
312
444
 
313
445
  ```ruby
314
- Cat = ValueSemantics::Struct.new do
315
- name String, default: "Mittens"
446
+ Pigeon = ValueSemantics::Struct.new do
447
+ name String, default: "Jannie"
448
+ end
449
+
450
+ Pigeon.new.name #=> "Jannie"
451
+ ```
452
+
453
+
454
+ ## Known Issues
455
+
456
+ Some valid attribute names result in invalid Ruby syntax when using the DSL.
457
+ In these situations, you can use the DSL method `def_attr` instead.
458
+
459
+ For example, if you want an attribute named `then`:
460
+
461
+ ```ruby
462
+ # Can't do this:
463
+ class Conditional
464
+ include ValueSemantics.for_attributes {
465
+ then String
466
+ else String
467
+ }
316
468
  end
469
+ #=> !!! SyntaxError: README.md:461: syntax error, unexpected `then'
470
+ #=* then String
471
+ #=* ^~~~
472
+
317
473
 
318
- Cat.new.name #=> "Mittens"
474
+ # This will work
475
+ class Conditional
476
+ include ValueSemantics.for_attributes {
477
+ def_attr :then, String
478
+ def_attr :else, String
479
+ }
480
+ end
319
481
  ```
320
482
 
321
483
 
@@ -341,7 +503,15 @@ Or install it yourself as:
341
503
  Bug reports and pull requests are welcome on GitHub at:
342
504
  https://github.com/tomdalling/value_semantics
343
505
 
344
- Keep in mind that this gem aims to be as close to 100% backwards compatible as possible.
506
+ Keep in mind that this gem aims to be as close to 100% backwards compatible as
507
+ possible.
508
+
509
+ I'm happy to accept PRs that:
510
+
511
+ - Improve error messages for a better developer experience, especially those
512
+ that support a TDD workflow.
513
+ - Add new and helpful built-in validators and coercers
514
+ - Implement automatic freezing of value objects (must be opt-in)
345
515
 
346
516
  ## License
347
517