etcher 1.5.0 → 2.0.0

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: 75820bd97bf8b61960802c1bb85cb91d5ad583778ca5860d4a887eea59f516a5
4
- data.tar.gz: b83d2f5ed78a7ef5ba8b622b63855226413fb4a574cf44577a9f825509a33d82
3
+ metadata.gz: 18c97ac57c0742fedf8872da7aa401ceae29552ccc9e78ce0d8df72fc8fff08c
4
+ data.tar.gz: feb2442ddc16419bc49dc97ba1e449e226713d57a2c1fd6714e8592479faf636
5
5
  SHA512:
6
- metadata.gz: 891f2042e506243598e6545846ea5a4aeae21658315f5c25e32121c45279a64721cea0c275cf8ce7d723724007e108d3e09b7d538b6d28c7a049c7599da09c73
7
- data.tar.gz: 0cb1f523435a2244994e88a9a51852f2a67b83c10bb3b817b147c9c80dd615bf777ecec069b2121af497dc225c6f1a032f53e856d41961145255b792eb70c80c
6
+ metadata.gz: cd2eb81d5e6a950c8d2cc7205fa09d41608bf2a835e25aa13dc5bbbb9bf4f6a60eea36716d5a132047aba29290a3706c8b3b6b54c175df9f95d0e25943719d25
7
+ data.tar.gz: af20a0db0eaefd28a6a69c1bf0193084ec841a1042cc6f842667c3cc2da803d064e244670429c30a621905615b4a6aee963cab081028b6d10d7b563444fa6969
checksums.yaml.gz.sig CHANGED
Binary file
data/README.adoc CHANGED
@@ -15,6 +15,7 @@
15
15
  :pipeable_link: link:https://alchemists.io/projects/pipeable[Pipeable]
16
16
  :runcom_link: link:https://alchemists.io/projects/runcom[Runcom]
17
17
  :sod_link: link:https://alchemists.io/projects/sod[Sod]
18
+ :string_formats_link: link:https://docs.ruby-lang.org/en/3.3/format_specifications_rdoc.html[String Formats]
18
19
  :struct_link: link:https://alchemists.io/articles/ruby_structs[Struct]
19
20
  :versionaire_link: link:https://alchemists.io/projects/versionaire[Versionaire]
20
21
  :xdg_link: link:https://alchemists.io/projects/xdg[XDG]
@@ -28,7 +29,7 @@ ____
28
29
  [Use] strong acid or mordant to cut into the unprotected parts of a metal surface to create a design in intaglio (incised) in the metal.
29
30
  ____
30
31
 
31
- By using Etcher, you have a reliable way to load default configurations (i.e. {environment_link}, {json_link}, {yaml_link}) which can be validated and etched into _frozen_ records (i.e. {hash_link}, {data_link}, {struct_link}) for consumption within your application which doesn't violate the {demeter_link}. This comes complete with transformations and validations all via a simple Object API and pairs well with the {xdg_link}, {runcom_link}, and {sod_link} gems.
32
+ By using Etcher, you have a reliable way to load default configurations (i.e. {hash_link}, {environment_link}, {json_link}, {yaml_link}) which can be validated and etched into _frozen_ records (i.e. {hash_link}, {data_link}, {struct_link}) for consumption within your application which doesn't violate the {demeter_link}. This comes complete with loaders, transformations, and validations all via a simple Object API that pairs well with the {xdg_link}, {runcom_link}, and {sod_link} gems.
32
33
 
33
34
  toc::[]
34
35
 
@@ -36,7 +37,7 @@ toc::[]
36
37
 
37
38
  * Supports contracts which respond to `#call` to validate a {hash_link} before building the final record. Pairs well with the {dry_schema_link} and {dry_validation_link} gems.
38
39
  * Supports models which respond to `.[]` for consuming a splatted {hash_link} to instantiate new records. Pairs well with primitives such as: {hash_link}, {data_link}, and {struct_link}.
39
- * Supports loading of default configurations from the {environment_link}, a {json_link} configuration, a {yaml_link} configuration, or anything that can answer a hash.
40
+ * Supports loading of default configurations from a {hash_link}, the {environment_link}, a {json_link} configuration, a {yaml_link} configuration, or anything that can answer a hash.
40
41
  * Supports multiple transformations which can process loaded configuration hashes and answer a transformed hash.
41
42
  * Supports {hash_link} overrides as a final customization which is handy for Command Line Interfaces (CLIs), as aided by {sod_link}, or anything that might require user input at runtime.
42
43
 
@@ -111,7 +112,7 @@ transformer = lambda do |attributes, key = :user|
111
112
  end
112
113
 
113
114
  Etcher::Registry.new(contract:, model:, transformers: [transformer])
114
- .add_loader(Etcher::Loaders::Environment.new(%w[USER HOME]))
115
+ .add_loader(:environment, %w[USER HOME])
115
116
  .then { |registry| Etcher.new(registry).call }
116
117
 
117
118
  # Success(#<data user="DEMO", home="/Users/demo">)
@@ -133,16 +134,16 @@ While this is a more advanced use case, you'll usually only need to register a c
133
134
  As hinted at above, the complete sequence of steps are performed in the order listed:
134
135
 
135
136
  . *Load*: Each loader, if any, is called and merged with the previous loader to build initial attributes.
136
- . *Override*: Any overrides are merged with the result of the last loader to produce updated attributes. ⚠️ _In Version 2.0.0, this step will happen after the Transform step._
137
137
  . *Transform*: Each transformer, if any, is called to transform and manipulate the attributes.
138
+ . *Override*: Overrides, if any, are merged with the result of the last transformer so you can fine tune the data as desired.
138
139
  . *Validate*: The contract is called to validate the attributes as previously loaded, overwritten, and transformed.
139
140
  . *Model*: The model consumes the attributes of the validated contract and creates a new record for you to use as needed.
140
141
 
141
- You can use the above steps as a reference when using this gem. Each step is explained in greater detail below.
142
+ Each step _mutates_ the attributes of the previous step in order to produce a record (success) or error (failure). You can use the above steps as a reference when using this gem. Each step is explained in greater below.
142
143
 
143
144
  === Registry
144
145
 
145
- The registry is provided as a way to register any/all complexity for before creating a new Etcher instance. Here's what you get by default:
146
+ The registry provides a way to register any/all behavior for before creating a new Etcher instance. Here's what you get by default:
146
147
 
147
148
  [source,ruby]
148
149
  ----
@@ -179,7 +180,7 @@ Contracts are a critical piece of this workflow as they provide a way to validat
179
180
 
180
181
  * `#call`: Must be able to consume a {hash_link} and answer an object which can respond to `#to_monad`.
181
182
 
182
- The best gems which adhere to this interface are: {dry_schema_link} and {dry_validation_link}. You'll also want to make sure the {dry_monads_link} extensions are loaded, as briefly shown earlier, so the result will respond to `#to_monad`. Here's how to enable monad support if using both gems:
183
+ Both {dry_schema_link} and {dry_validation_link} respond to the `#to_monad` message. Ensure the {dry_monads_link} extensions are loaded too, as briefly shown earlier, so the result will respond to the `#to_monad` message. Here's how to enable monad support if using both gems:
183
184
 
184
185
  [source,ruby]
185
186
  ----
@@ -209,7 +210,7 @@ etcher.call from: "Mork", to: "Mindy"
209
210
  # Success({:from=>"Mork", :to=>"Mindy"})
210
211
  ----
211
212
 
212
- Here you can see the power of using a contract to validate your data both as a failure and a success. Unfortunately, with the success, we only get a {hash_link} as a record and it would be nice to structured record which will be explained shortly.
213
+ Here you can see the power of using a contract to validate your data both as a failure and a success. Unfortunately, with the success, we only get a {hash_link} as a record but it would be better to have a data structure which will be explained shortly.
213
214
 
214
215
  === Types
215
216
 
@@ -279,7 +280,7 @@ Notice we get an failure if all attributes are not provided but if we supply the
279
280
 
280
281
  === Loaders
281
282
 
282
- Loaders are a great way to load a _default_ configuration for your application which can be in multiple formats. Loaders can either be defined when creating a new registry instance or added after the fact. Here are a few examples:
283
+ Loaders are a great way to load a _default_ configuration for your application which can be in multiple formats. Loaders can either be defined when creating a new registry instance or added after the fact. Here are a couple examples:
283
284
 
284
285
  [source,ruby]
285
286
  ----
@@ -292,16 +293,35 @@ registry = Etcher::Registry.new.add_loader MyLoader.new
292
293
 
293
294
  There are a few guidelines to using them:
294
295
 
295
- * They must respond to `#call` with no arguments.
296
- * All keys are symbolized which helps streamline merging and overriding values from the same keys across multiple configurations.
296
+ * All loaders must respond to `#call` with no arguments.
297
+ * All loaders must answer either a success with attributes (i.e. `Success attributes`) or a failure with details about the failure (i.e. `Failure step: :load, constant: MyLoader, payload: "My error message.`)
298
+ * All keys are symbolized after the loader is called which helps streamline merging and overriding values from the same keys across multiple configurations.
297
299
  * All nested keys will be flattened after being loaded. This means a key structure of `{demo: {one: "test"}}` will be flattened to `demo_one: "test"` which adheres to the {demeter_link} when a new recored is _etched_ for you.
298
300
  * The order in which you define your loaders matters. This means the first loader defined will be processed first, then the second, and so forth. Loaders defined last take precedence over previously defined loaders when overriding the same keys.
299
301
 
300
- The next couple of sections will help you learn about the supported loaders and how to build your own custom loader.
302
+ For convenience, all loaders -- only packaged with this gem -- can be registered by symbol instead of constant/instance. Example:
303
+
304
+ [source,ruby]
305
+ ----
306
+ registry = Etcher::Registry.new
307
+
308
+ # Environment
309
+ registry.add_loader :environment
310
+
311
+ # JSON
312
+ registry.add_loader :json, "path/to/configuration.json"
313
+
314
+ # YAML
315
+ registry.add_loader :yaml, "path/to/configuration.yml"
316
+ ----
317
+
318
+ Any positional or keyword arguments will be passed to the loader's constructor. _This only works when using `Registry#add_loader`, though._
319
+
320
+ The next sections will help you learn about the supported loaders and how to build your own custom loader.
301
321
 
302
322
  ==== Environment
303
323
 
304
- Use `Etcher::Loaders::Environment` to load configuration information from your {environment_link}. By default, this object wraps `ENV`, uses an empty array for keys to include, and answers a filtered hash where all keys are downcased. _If you don't specify keys to include, then an empty hash is answered back_. Here's a few examples:
324
+ Use `:environment` or `Etcher::Loaders::Environment` to load configuration information from your {environment_link}. By default, this object wraps `ENV`, uses an empty array for included keys, and answers a filtered hash where all keys are downcased. _If you don't specify keys to include, then an empty hash is answered back_. Here's a few examples:
305
325
 
306
326
  [source,ruby]
307
327
  ----
@@ -323,6 +343,25 @@ loader.call
323
343
 
324
344
  This loader is great for pulling from environment variables as a fallback configuration for your application.
325
345
 
346
+ ==== Hash
347
+
348
+ Use `:hash` or `Etcher::Loaders::Hash` to load in-memory attributes. By default, this loader will answer an empty hash if not supplied with any attributes. Here's a few examples:
349
+
350
+ [source,ruby]
351
+ ----
352
+ # Default behavior.
353
+ loader = Etcher::Loaders::Hash.new
354
+ loader.call
355
+ # Success({})
356
+
357
+ # With custom attributes
358
+ loader = Etcher::Loaders::Hash.new one: 1, two: 2
359
+ loader.call
360
+ # Success({:one=>1, :two=>2})
361
+ ----
362
+
363
+ This loader is great for adding custom attributes, overriding/adjusting attributes from a previous loader, or customizing attributes for testing purposes within a test suite.
364
+
326
365
  ==== JSON
327
366
 
328
367
  Use `Etcher::Loaders::JSON` to load configuration information from a {json_link} file. Here's how to use this loader (using a file that doesn't exist):
@@ -344,9 +383,12 @@ loader = Etcher::Loaders::JSON.new "your/path/to/configuration.json",
344
383
  loader.call # Success({})
345
384
  ----
346
385
 
347
- Otherwise, if the file exists with content, you'll get a `Hash` wrapped as a `Success`.
386
+ If the file exists with _valid_ content, you'll get a `Hash` wrapped as a `Success`. In situations in which the file doesn't exist, you'll get a `Success` with an empty hash and debug information logged instead. Any failures will be provided with step, constant, and payload details. Example:
348
387
 
349
- ℹ️ The logger is only used to log debug information when issues are encountered when reading from the file. This is done to reduce noise in your console when a configuration might have issues and can safely revert to the fallback in order to load the rest of the configuration.
388
+ [source,ruby]
389
+ ----
390
+ Failure step: :load, constant: Etcher::Loaders::JSON, payload: "Danger!"
391
+ ----
350
392
 
351
393
  ==== YAML
352
394
 
@@ -369,9 +411,12 @@ loader = Etcher::Loaders::YAML.new "your/path/to/configuration.yml",
369
411
  loader.call # Success({})
370
412
  ----
371
413
 
372
- Otherwise, if the file exists with content, you'll get a `Hash` wrapped as a `Success`.
414
+ If the file exists with _valid_ content, you'll get a `Hash` wrapped as a `Success`. In situations in which the file doesn't exist, you'll get a `Success` with an empty hash and debug information logged instead. Any failures will be provided with step, constant, and payload details. Example:
373
415
 
374
- ℹ️ The logger is only used to log debug information when issues are encountered when reading from the file. This is done to reduce noise in your console when a configuration might have issues and can safely revert to the fallback in order to load the rest of the configuration.
416
+ [source,ruby]
417
+ ----
418
+ Failure step: :load, constant: Etcher::Loaders::YAML, payload: "Danger!"
419
+ ----
375
420
 
376
421
  ==== Custom
377
422
 
@@ -384,26 +429,31 @@ require "dry/monads"
384
429
  class Demo
385
430
  include Dry::Monads[:result]
386
431
 
387
- def initialize fallback: {}
388
- @fallback = fallback
432
+ def initialize processor: Processor.new
433
+ @processor = processor
389
434
  end
390
435
 
391
- def call = Success fallback
436
+ def call
437
+ Success processor.call
438
+ rescue ProcessorError => error
439
+ Failure step: :load, constant: self.class, payload: error.message
440
+ end
392
441
 
393
442
  private
394
443
 
395
- attr_reader :fallback
444
+ attr_reader :processor
396
445
  end
397
446
 
398
- etcher = Etcher::Registry[loaders: [Demo.new]].then { |registry| Etcher.new registry }
399
- etcher.call # Success({})
447
+ registry = Etcher::Registry[loaders: [Demo.new]]
448
+
449
+ Etcher.new(registry).call
400
450
  ----
401
451
 
402
- While the above isn't super useful since it only answers whatever you provide as fallback information, you can see there is little effort required to implement and customize as desired.
452
+ While the above assumes you have some kind of `Processor` for loading attributes, you can see there is little effort required to implement and customize as desired.
403
453
 
404
454
  === Transformers
405
455
 
406
- Transformers are great for modifying specific keys and values. They give you finer grained control over your configuration and are the last step before validating and creating an associated record of your configuration. Transformers can either be defined when creating a new registry instance or added after the fact. Here are a few examples:
456
+ Transformers are great for _mutating_ specific keys and values. They give you fine grained customization over your configuration. Transformers can either be defined when creating a new registry instance or added after the fact. Here are a couple examples:
407
457
 
408
458
  [source,ruby]
409
459
  ----
@@ -418,8 +468,9 @@ The guidelines for using transformers are:
418
468
 
419
469
  * They can be initialized with whatever requirements you need.
420
470
  * They must respond to `#call` which takes a required `attributes` positional argument and answers a modified version of these attributes (`Hash`) wrapped as a monad.
421
- * When using a proc/lambda, the first, _required_, parameter should be the `attributes` parameter followed by an _optional_ positional `key` parameter with a default value. This allows you to quickly refactor the key later while also reducing key duplication throughout your implementation.
422
- * When using a class, the `key` should be your first positional parameter with a default value. Additional parameters can be supplied after if desired.
471
+ * They must answer either a success with attributes (i.e. `Success attributes`) or a failure with details about the failure (i.e. `Failure step: :transform, constant: MyTransformer, payload: "My error message.`)
472
+ * When using a proc/lambda, the first, _required_, parameter should be the `attributes` parameter followed by a second positional `key` parameter.
473
+ * When using a class, the `key` should be your first positional parameter. Additional parameters can be supplied after if desired.
423
474
  * The `attributes` passed to your transformer will have symbolized keys so you don't need to transform them further.
424
475
 
425
476
  For example, the following capitalizes all values (which may or may not be good depending on your data structure):
@@ -468,52 +519,145 @@ etcher = Etcher.new(registry)
468
519
  etcher.call
469
520
  ----
470
521
 
471
- If you'd like prebuilt transformers, the following details what is supplied by this gem.
522
+ For convenience, all transformers -- only packaged with this gem -- can be registered by symbol instead of constant/instance. Example:
472
523
 
473
- ==== Time
524
+ [source,ruby]
525
+ ----
526
+ registry = Etcher::Registry.new
527
+
528
+ # Format
529
+ registry.add_transformer :format, :project_uri
530
+
531
+ # Time
532
+ registry.add_transformer :time
533
+ ----
534
+
535
+ Any positional or keyword arguments will be passed to the transformers's constructor. _This only works when using `Registry#add_transformer`, though._ The following sections provide more details on each.
536
+
537
+ ==== Basename
474
538
 
475
- Use `Etcher::Transformers::Time` to transform the `loaded_at` key in your configuration when you want to know the current time at which the configuration was loaded. Handy for situations where you need to calculate relative time or format time based on when your configuration was loaded.
539
+ Use `Etcher::Transformers::Basename` to dynamically obtain the name of the current directory as a value for a key. This is handy for scripting or CLI purposes when needing to know the name of the current project you are working in. Example:
476
540
 
477
- Even though `loaded_at` is the default key and `Time.now.utc` is the default fallback, you're not limited to using different keys and fallbacks. Example:
541
+ [source,ruby]
542
+ ----
543
+ transformer = Etcher::Transformers::Basename.new :demo
544
+ transformer.call({})
545
+ # Success({:demo=>"scratch"})
546
+
547
+ transformer = Etcher::Transformers::Basename.new :demo, fallback: "undefined"
548
+ transformer.call({})
549
+ # Success({:demo=>"undefined"})
550
+
551
+ transformer = Etcher::Transformers::Basename.new :demo
552
+ transformer.call({demo: "defined"})
553
+ # Success({:demo=>"defined"})
554
+ ----
555
+
556
+ ==== Root
557
+
558
+ Use `Etcher::Transformers::Root` to dynamically obtain the current path as a value for a key. This is handy for obtaining the absolute path to a new or existing directory. Example:
478
559
 
479
560
  [source,ruby]
480
561
  ----
481
- transformer = Etcher::Transformers::Time.new
562
+ transformer = Etcher::Transformers::Root.new :demo
563
+ transformer.call({})
564
+ # Success({:demo=>#<Pathname:/Users/demo/Engineering/OSS/scratch>})
565
+
566
+ transformer = Etcher::Transformers::Root.new :demo, fallback: "undefined"
482
567
  transformer.call({})
483
- # Success({:loaded_at=>2024-05-23 22:18:27.92767 UTC})
568
+ # Success({:demo=>#<Pathname:/Users/demo/Engineering/undefined>})
569
+
570
+ transformer = Etcher::Transformers::Root.new :demo
571
+ transformer.call({demo: "defined"})
572
+ # Success({:demo=>#<Pathname:/Users/demo/Engineering/defined>})
573
+ ----
574
+
575
+ ==== Format
576
+
577
+ Use `Etcher::Transformers::Format` to transform any key's value by using the configuration's existing attributes to format the value of a specific key using the {string_formats_link} Specification. Example:
578
+
579
+ [source,ruby]
580
+ ----
581
+ attributes = {
582
+ organization_uri: "https://acme.io",
583
+ project_name: "test",
584
+ project_uri: "%<organization_uri>s/projects/%<project_name>s"
585
+ }
586
+
587
+ Etcher::Transformers::Format.new(:project_uri).call attributes
588
+ # Success(
589
+ {
590
+ organization_uri: "https://acme.io",
591
+ project_name: "test",
592
+ project_uri: "https://acme.io/projects/test"
593
+ }
594
+ )
595
+
596
+ attributes.delete :project_name
597
+ transformer.call attributes
484
598
 
599
+ # Failure(
600
+ # {
601
+ # step: :transform,
602
+ # constant: Etcher::Transformers::Format,
603
+ # payload: "Unable to transform :project_uri, missing specifier: \"<project_name>\"."
604
+ # }
605
+ # )
606
+ ----
607
+
608
+ You can also, safely, transform a value which _doesn't_ have string specifiers:
609
+
610
+ [source,ruby]
611
+ ----
612
+ Etcher::Transformers::Format.new(:version).call(version: "1.2.3")
613
+ # Success({:version=>"1.2.3"})
614
+ ----
615
+
616
+ Normally, you'd get a "too many arguments for format string" warning but this transformer detects and immediate skips formatting when no string specifiers are detected. This is handy for situations where your configuration supports values which may or may not need formatting.
617
+
618
+ ==== Time
619
+
620
+ Use `Etcher::Transformers::Time` to transform the any key in your configuration when you want to know the current time at which the configuration was loaded. Handy for situations where you need to calculate relative time or format time based on when your configuration was loaded.
621
+
622
+ You must supply a key and `Time.now.utc` is the default fallback. You can customize as desired. Example:
623
+
624
+ [source,ruby]
625
+ ----
485
626
  transformer = Etcher::Transformers::Time.new :now
486
627
  transformer.call({})
487
- # Success({:now=>2024-05-23 22:18:49.93189 UTC})
628
+ # Success({:now=>2024-06-15 22:43:29.178488 UTC})
488
629
 
489
630
  transformer = Etcher::Transformers::Time.new :now, fallback: Time.utc(2000, 1, 1)
490
631
  transformer.call({})
491
632
  # Success({:now=>2000-01-01 00:00:00 UTC})
492
633
 
493
- transformer = Etcher::Transformers::Time.new
494
- transformer.call({loaded_at: Time.utc(2000, 1, 1)})
495
- # Success({:loaded_at=>2000-01-01 00:00:00 UTC})
634
+ transformer = Etcher::Transformers::Time.new :now
635
+ transformer.call({now: Time.utc(2000, 1, 1)})
636
+ # Success({:now=>2000-01-01 00:00:00 UTC})
496
637
  ----
497
638
 
498
639
  === Overrides
499
640
 
500
- Overrides are what you pass to the Etcher instance when called. Example:
641
+ Overrides are what you pass to the Etcher instance when called. They allow you to override any values that were loaded and/or transformed. Example:
501
642
 
502
643
  [source,ruby]
503
644
  ----
504
645
  etcher = Etcher.new
646
+
647
+ # With symbol keys.
505
648
  etcher.call name: "test", label: "Test"
649
+ # Success({:name=>"test", :label=>"Test"})
506
650
 
651
+ # With string keys.
652
+ etcher.call "name" => "test", "label" => "Test"
507
653
  # Success({:name=>"test", :label=>"Test"})
508
654
  ----
509
655
 
510
- Overrides are applied _after_ any loaders are processed and _before_ any transformations. They are a nice way to deal with user input during runtime or provide any additional configuration not supplied by the loading of your default configuration while still allowing you to transform them if desired.
511
-
512
- ⚠️ In Version 2.0.0, this step will be changed to occur _after_ the Transform step for maximum flexibility.
656
+ Overrides are applied _after_ any transforms and _before_ validations. They are a nice way to deal with user input during runtime or provide additional attributes not supplied by the loading and/or transforming of your default configuration while ensuring they are validated properly. Any string keys will be transformed to symbol keys to ensure consistency and reduce issues when merged.
513
657
 
514
658
  === Resolver
515
659
 
516
- In situations where you'd like Etcher to handle the complete load, transform, validate, and build steps for you, then you can use the resolver. This is provided for use cases where you'd like Etcher to handle everything for you and abort if otherwise. Example:
660
+ In situations where you'd like Etcher to handle the complete load, transform, override, validate, and model steps for you, then you can use the resolver. This is provided for use cases where you'd like Etcher to handle everything for you and abort if otherwise. Example:
517
661
 
518
662
  [source,ruby]
519
663
  ----
@@ -540,23 +684,22 @@ registry = Etcher::Registry.new(contract:, model:)
540
684
 
541
685
  Etcher.call registry
542
686
 
543
- # 🔥 Unable to load configuration due to the following issues:
687
+ # 🛑 Etcher validate failure (Etcher::Builder). Unable to load configuration:
544
688
  # - to is missing
545
689
  # - from is missing
546
690
 
547
691
  Etcher.call registry, to: "Mindy"
548
692
 
549
- # 🔥 Unable to load configuration due to the following issues:
693
+ # 🛑 Etcher validate failure (Etcher::Builder). Unable to load configuration:
550
694
  # - from is missing
551
695
 
552
-
553
696
  registry = Etcher::Registry.new(model: Data.define(:name, :label))
554
697
  Etcher.call registry, to: "Mindy"
555
698
 
556
- # 🔥 Build failure: :record. Missing keywords: :name, :label.
699
+ # 🛑 Etcher model failure (Etcher::Builder). Missing keywords: :name, :label.
557
700
  ----
558
701
 
559
- 💡 When using a custom registry, make sure it's the first argument. All arguments afterwards can be any number of key/values overrides which is similar to how `Etcher.new` works.
702
+ 💡 When using a custom registry, make sure it's the first argument. Additional arguments can be supplied afterwards and they can be any number of key/value overrides which is similar to how `Etcher.new` works.
560
703
 
561
704
  == Development
562
705
 
@@ -580,7 +723,7 @@ bin/console
580
723
 
581
724
  The following illustrates the full sequences of events when _etching_ new records:
582
725
 
583
- image::https://alchemists.io/images/projects/etcher/doc/architecture.svg[Architecture Diagram]
726
+ image::https://alchemists.io/images/projects/etcher/architecture.png[Architecture Diagram,1250,1071,role=focal_point]
584
727
 
585
728
  == Tests
586
729
 
data/etcher.gemspec CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "etcher"
5
- spec.version = "1.5.0"
5
+ spec.version = "2.0.0"
6
6
  spec.authors = ["Brooke Kuhlmann"]
7
7
  spec.email = ["brooke@alchemists.io"]
8
8
  spec.homepage = "https://alchemists.io/projects/etcher"
@@ -23,11 +23,11 @@ Gem::Specification.new do |spec|
23
23
  spec.cert_chain = [Gem.default_cert_path]
24
24
 
25
25
  spec.required_ruby_version = "~> 3.3"
26
- spec.add_dependency "cogger", "~> 0.15"
26
+ spec.add_dependency "cogger", "~> 0.21"
27
27
  spec.add_dependency "core", "~> 1.0"
28
28
  spec.add_dependency "dry-monads", "~> 1.6"
29
29
  spec.add_dependency "dry-types", "~> 1.7"
30
- spec.add_dependency "refinements", "~> 12.1"
30
+ spec.add_dependency "refinements", "~> 12.5"
31
31
  spec.add_dependency "versionaire", "~> 13.0"
32
32
  spec.add_dependency "zeitwerk", "~> 2.6"
33
33
 
@@ -16,27 +16,25 @@ module Etcher
16
16
  end
17
17
 
18
18
  def call(**overrides)
19
- load(overrides.symbolize_keys!).then { |attributes| transform attributes }
20
- .bind { |attributes| validate attributes }
21
- .bind { |attributes| model attributes }
19
+ load.bind { |attributes| transform attributes }
20
+ .fmap { |attributes| attributes.merge! overrides.symbolize_keys! }
21
+ .bind { |attributes| validate attributes }
22
+ .bind { |attributes| model attributes }
22
23
  end
23
24
 
24
25
  private
25
26
 
26
27
  attr_reader :registry
27
28
 
28
- def load overrides
29
+ def load
29
30
  registry.loaders
30
31
  .map { |loader| loader.call.fmap { |pairs| pairs.flatten_keys.symbolize_keys! } }
31
- .each
32
- .with_object({}) { |attributes, all| attributes.bind { |body| all.merge! body } }
33
- .merge!(overrides.flatten_keys)
34
- .then { |attributes| Success attributes }
32
+ .reduce(Success({})) { |all, result| merge all, result }
35
33
  end
36
34
 
37
35
  def transform attributes
38
- registry.transformers.reduce attributes do |all, transformer|
39
- all.bind { |body| transformer.call body }
36
+ registry.transformers.reduce Success(attributes) do |all, transformer|
37
+ merge all, transformer.call(attributes)
40
38
  end
41
39
  end
42
40
 
@@ -44,13 +42,22 @@ module Etcher
44
42
  registry.contract
45
43
  .call(attributes)
46
44
  .to_monad
47
- .or { |result| Failure step: __method__, payload: result.errors.to_h }
45
+ .or do |result|
46
+ Failure step: __method__, constant: self.class, payload: result.errors.to_h
47
+ end
48
48
  end
49
49
 
50
50
  def model attributes
51
51
  Success registry.model[**attributes.to_h].freeze
52
52
  rescue ArgumentError => error
53
- Failure step: __method__, payload: "#{error.message.capitalize}."
53
+ Failure step: __method__, constant: self.class, payload: "#{error.message.capitalize}."
54
+ end
55
+
56
+ def merge(*items)
57
+ case items
58
+ in Success(all), Success(subset) then Success(all.merge!(subset))
59
+ else items.last
60
+ end
54
61
  end
55
62
  end
56
63
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ # Finds internal constant if moniker matches, otherwise answers a failure.
6
+ module Etcher
7
+ include Dry::Monads[:result]
8
+
9
+ Finder = lambda do |namespace, moniker|
10
+ Etcher.const_get(namespace)
11
+ .constants
12
+ .find { |constant| constant.downcase == moniker }
13
+ .then do |constant|
14
+ return Dry::Monads::Success Etcher.const_get("#{namespace}::#{constant}") if constant
15
+
16
+ Dry::Monads::Failure "Unable to select #{moniker.inspect} within #{namespace.downcase}."
17
+ end
18
+ rescue NameError
19
+ Dry::Monads::Failure "Invalid namespace: #{namespace.inspect}."
20
+ end
21
+ end
@@ -9,16 +9,16 @@ module Etcher
9
9
  class Environment
10
10
  include Dry::Monads[:result]
11
11
 
12
- def initialize includes = Core::EMPTY_ARRAY, source: ENV
13
- @includes = Array includes
14
- @source = source
12
+ def initialize attributes = ENV, only: Core::EMPTY_ARRAY
13
+ @attributes = attributes
14
+ @only = Array only
15
15
  end
16
16
 
17
- def call = Success source.slice(*includes).transform_keys(&:downcase)
17
+ def call = Success attributes.slice(*only).transform_keys(&:downcase)
18
18
 
19
19
  private
20
20
 
21
- attr_reader :includes, :source
21
+ attr_reader :attributes, :only
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module Etcher
6
+ module Loaders
7
+ # Loads (wraps) raw attributes.
8
+ class Hash
9
+ include Dry::Monads[:result]
10
+
11
+ def initialize(**attributes)
12
+ @attributes = attributes
13
+ end
14
+
15
+ def call = Success attributes
16
+
17
+ private
18
+
19
+ attr_reader :attributes
20
+ end
21
+ end
22
+ end
@@ -18,22 +18,33 @@ module Etcher
18
18
 
19
19
  def call
20
20
  Success ::JSON.load_file(path)
21
- rescue TypeError, Errno::ENOENT
22
- debug_and_fallback "Invalid path: #{path_info}. Using fallback."
23
- rescue ::JSON::ParserError => error
24
- debug_and_fallback "#{error.message}. Path: #{path_info}. Using fallback."
21
+ rescue Errno::ENOENT, TypeError then debug_invalid_path
22
+ rescue ::JSON::ParserError => error then content_failure error
25
23
  end
26
24
 
27
25
  private
28
26
 
29
27
  attr_reader :path, :fallback, :logger
30
28
 
31
- def path_info = path.to_s.inspect
32
-
33
- def debug_and_fallback message
34
- logger.debug { message }
29
+ def debug_invalid_path
30
+ logger.debug { "Invalid path: #{path_info}. Using fallback." }
35
31
  Success fallback
36
32
  end
33
+
34
+ def content_failure error
35
+ constant = self.class
36
+ token = error.message[/(?<token>'.+?')/, :token].to_s.tr "'", ""
37
+
38
+ if token.empty?
39
+ Failure step: :load, constant:, payload: "File is empty: #{path_info}."
40
+ else
41
+ Failure step: :load,
42
+ constant:,
43
+ payload: "Invalid content: #{token.inspect}. Path: #{path_info}."
44
+ end
45
+ end
46
+
47
+ def path_info = path.to_s.inspect
37
48
  end
38
49
  end
39
50
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "core"
4
4
  require "dry/monads"
5
+ require "refinements/string"
5
6
  require "yaml"
6
7
 
7
8
  module Etcher
@@ -10,6 +11,8 @@ module Etcher
10
11
  class YAML
11
12
  include Dry::Monads[:result]
12
13
 
14
+ using Refinements::String
15
+
13
16
  def initialize path, fallback: Core::EMPTY_HASH, logger: LOGGER
14
17
  @path = path
15
18
  @fallback = fallback
@@ -18,10 +21,10 @@ module Etcher
18
21
 
19
22
  def call
20
23
  load
21
- rescue TypeError, Errno::ENOENT
22
- debug_and_fallback "Invalid path: #{path_info}. Using fallback."
23
- rescue Psych::Exception => error
24
- debug_and_fallback "#{error.message}. Path: #{path_info}. Using fallback."
24
+ rescue Errno::ENOENT, TypeError then debug_invalid_path
25
+ rescue Psych::AliasesNotEnabled then alias_failure
26
+ rescue Psych::DisallowedClass => error then disallowed_failure error
27
+ rescue Psych::SyntaxError => error then syntax_failure error
25
28
  end
26
29
 
27
30
  private
@@ -31,17 +34,48 @@ module Etcher
31
34
  def load
32
35
  content = ::YAML.safe_load_file path
33
36
 
34
- return Success content if content.is_a? Hash
37
+ case content
38
+ in ::Hash then Success content
39
+ in nil then empty_failure
40
+ else invalid_failure content
41
+ end
42
+ end
35
43
 
36
- debug_and_fallback "Invalid content: #{content.inspect}. Using fallback."
44
+ def debug_invalid_path
45
+ logger.debug { "Invalid path: #{path_info}. Using fallback." }
46
+ Success fallback
37
47
  end
38
48
 
39
- def path_info = path.to_s.inspect
49
+ def empty_failure
50
+ Failure step: :load, constant: self.class, payload: "File is empty: #{path_info}."
51
+ end
40
52
 
41
- def debug_and_fallback message
42
- logger.debug { message }
43
- Success fallback
53
+ def invalid_failure content
54
+ Failure step: :load,
55
+ constant: self.class,
56
+ payload: "Invalid content: #{content.inspect}. Path: #{path_info}."
57
+ end
58
+
59
+ def alias_failure
60
+ Failure step: :load,
61
+ constant: self.class,
62
+ payload: "Aliases are disabled, please remove. Path: #{path_info}."
63
+ end
64
+
65
+ def disallowed_failure error
66
+ Failure step: :load,
67
+ constant: self.class,
68
+ payload: "Invalid type, #{error.message.down}. Path: #{path_info}."
44
69
  end
70
+
71
+ def syntax_failure error
72
+ Failure step: :load,
73
+ constant: self.class,
74
+ payload: "Invalid syntax, #{error.message[/found.+/]}. " \
75
+ "Path: #{path_info}."
76
+ end
77
+
78
+ def path_info = path.to_s.inspect
45
79
  end
46
80
  end
47
81
  end
@@ -3,17 +3,42 @@
3
3
  module Etcher
4
4
  # Provides a registry of customization for loading and resolving a configuration.
5
5
  Registry = Data.define :contract, :model, :loaders, :transformers do
6
+ def self.find namespace, moniker, logger: LOGGER
7
+ case Finder.call namespace, moniker
8
+ in Success(constant) then constant
9
+ in Failure(message) then logger.abort message
10
+ else logger.abort "Unable to find constant in registry."
11
+ end
12
+ end
13
+
6
14
  def initialize contract: Contract, model: Hash, loaders: [], transformers: []
7
15
  super
8
16
  end
9
17
 
10
- def add_loader loader
11
- loaders.append loader
18
+ def add_loader(loader, ...) = add(loader, :Loaders, ...)
19
+
20
+ def remove_loader(index) = remove index, loaders
21
+
22
+ def add_transformer(transformer, ...) = add(transformer, :Transformers, ...)
23
+
24
+ def remove_transformer(index) = remove index, transformers
25
+
26
+ private
27
+
28
+ def add(item, namespace, ...)
29
+ collection = __send__ namespace.downcase
30
+
31
+ if item.is_a? Symbol
32
+ self.class.find(namespace, item).then { |kind| collection.append kind.new(...) }
33
+ else
34
+ collection.append item
35
+ end
36
+
12
37
  self
13
38
  end
14
39
 
15
- def add_transformer transformer
16
- transformers.append transformer
40
+ def remove index, collection
41
+ collection.delete_at index
17
42
  self
18
43
  end
19
44
  end
@@ -10,35 +10,33 @@ module Etcher
10
10
 
11
11
  using Refinements::Array
12
12
 
13
- def initialize registry = Registry.new, kernel: Kernel, logger: LOGGER
13
+ def initialize registry = Registry.new, logger: LOGGER
14
14
  @builder = Builder.new registry
15
- @kernel = kernel
16
15
  @logger = logger
17
16
  end
18
17
 
19
18
  def call(**overrides)
20
19
  case builder.call(**overrides)
21
20
  in Success(attributes) then attributes
22
- in Failure(step:, payload: String => payload)
23
- logger.fatal { "Build failure: #{step.inspect}. #{payload}" }
24
- kernel.abort
25
- in Failure(step:, payload: Hash => payload) then log_and_abort payload
26
- else fail StandardError, "Unable to parse configuration."
21
+ in Failure(step:, constant:, payload: String => payload)
22
+ logger.abort "#{step.capitalize} failure (#{constant}). #{payload}"
23
+ in Failure(step:, constant:, payload: Hash => payload)
24
+ log_and_abort step, constant, payload
25
+ in Failure(String => message) then logger.abort message
26
+ else logger.abort "Unable to parse failure."
27
27
  end
28
28
  end
29
29
 
30
30
  private
31
31
 
32
- attr_reader :builder, :kernel, :logger
32
+ attr_reader :builder, :logger
33
33
 
34
- def log_and_abort errors
35
- logger.fatal do
36
- details = errors.map { |key, message| " - #{key} #{message.to_sentence}\n" }
37
- .join
38
- "Unable to load configuration due to the following issues:\n#{details}"
39
- end
34
+ def log_and_abort step, constant, errors
35
+ details = errors.map { |key, message| " - #{key} #{message.to_sentence}\n" }
36
+ .join
40
37
 
41
- kernel.abort
38
+ logger.abort "#{step.capitalize} failure (#{constant}). " \
39
+ "Unable to load configuration:\n#{details}"
42
40
  end
43
41
  end
44
42
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "refinements/hash"
5
+
6
+ module Etcher
7
+ module Transformers
8
+ # Conditionally updates value based on path.
9
+ class Basename
10
+ include Dry::Monads[:result]
11
+
12
+ using Refinements::Hash
13
+
14
+ def initialize key, fallback: Pathname.pwd.basename.to_s
15
+ @key = key
16
+ @fallback = fallback
17
+ end
18
+
19
+ def call attributes
20
+ attributes.fetch_value(key) { attributes.merge! key => fallback }
21
+ Success attributes
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :key, :fallback
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+
5
+ module Etcher
6
+ module Transformers
7
+ # Formats given key using existing and/or placeholder attributes.
8
+ class Format
9
+ include Dry::Monads[:result]
10
+
11
+ def initialize key, **pass_throughs
12
+ @key = key
13
+ @pass_throughs = pass_throughs
14
+ @pattern = /%<.+>s/o
15
+ end
16
+
17
+ def call attributes
18
+ value = attributes[key]
19
+
20
+ return Success attributes unless value && value.match?(pattern)
21
+
22
+ Success attributes.merge!(key => format(value, **attributes, **pass_throughs))
23
+ rescue KeyError => error
24
+ Failure step: :transform,
25
+ constant: self.class,
26
+ payload: "Unable to transform #{key.inspect}, missing specifier: " \
27
+ "\"#{error.message[/<.+>/]}\"."
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :key, :pass_throughs, :pattern
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "refinements/hash"
5
+
6
+ module Etcher
7
+ module Transformers
8
+ # Conditionally updates value based on path.
9
+ class Root
10
+ include Dry::Monads[:result]
11
+
12
+ using Refinements::Hash
13
+
14
+ def initialize key, fallback: Pathname.pwd
15
+ @key = key
16
+ @fallback = fallback
17
+ end
18
+
19
+ def call attributes
20
+ value = attributes.fetch_value(key) { fallback }
21
+ Success attributes.merge!(key => Pathname(value).expand_path)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :key, :fallback
27
+ end
28
+ end
29
+ end
@@ -8,14 +8,14 @@ module Etcher
8
8
  class Time
9
9
  include Dry::Monads[:result]
10
10
 
11
- def initialize key = :loaded_at, fallback: ::Time.now.utc
11
+ def initialize key, fallback: ::Time.now.utc
12
12
  @key = key
13
13
  @fallback = fallback
14
14
  end
15
15
 
16
16
  def call attributes
17
- attributes.fetch(key) { fallback }
18
- .then { |value| Success attributes.merge!(key => value) }
17
+ attributes.fetch(key) { attributes.merge! key => fallback }
18
+ Success attributes
19
19
  end
20
20
 
21
21
  private
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: etcher
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brooke Kuhlmann
@@ -35,7 +35,7 @@ cert_chain:
35
35
  3n5C8/6Zh9DYTkpcwPSuIfAga6wf4nXc9m6JAw8AuMLaiWN/r/2s4zJsUHYERJEu
36
36
  gZGm4JqtuSg8pYjPeIJxS960owq+SfuC+jxqmRA54BisFCv/0VOJi7tiJVY=
37
37
  -----END CERTIFICATE-----
38
- date: 2024-05-23 00:00:00.000000000 Z
38
+ date: 2024-07-05 00:00:00.000000000 Z
39
39
  dependencies:
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: cogger
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.15'
46
+ version: '0.21'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.15'
53
+ version: '0.21'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: core
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -99,14 +99,14 @@ dependencies:
99
99
  requirements:
100
100
  - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: '12.1'
102
+ version: '12.5'
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: '12.1'
109
+ version: '12.5'
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: versionaire
112
112
  requirement: !ruby/object:Gem::Requirement
@@ -150,11 +150,16 @@ files:
150
150
  - lib/etcher.rb
151
151
  - lib/etcher/builder.rb
152
152
  - lib/etcher/contract.rb
153
+ - lib/etcher/finder.rb
153
154
  - lib/etcher/loaders/environment.rb
155
+ - lib/etcher/loaders/hash.rb
154
156
  - lib/etcher/loaders/json.rb
155
157
  - lib/etcher/loaders/yaml.rb
156
158
  - lib/etcher/registry.rb
157
159
  - lib/etcher/resolver.rb
160
+ - lib/etcher/transformers/basename.rb
161
+ - lib/etcher/transformers/format.rb
162
+ - lib/etcher/transformers/root.rb
158
163
  - lib/etcher/transformers/time.rb
159
164
  - lib/etcher/types.rb
160
165
  homepage: https://alchemists.io/projects/etcher
@@ -183,7 +188,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
183
188
  - !ruby/object:Gem::Version
184
189
  version: '0'
185
190
  requirements: []
186
- rubygems_version: 3.5.10
191
+ rubygems_version: 3.5.14
187
192
  signing_key:
188
193
  specification_version: 4
189
194
  summary: A monadic configuration loader, transformer, and validator.
metadata.gz.sig CHANGED
Binary file