kanon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/kanon/node.rb ADDED
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanon
4
+ class Node
5
+ def self.build(value)
6
+ case value
7
+ when Hash then new(value.each_with_object({}) { |(key, nested), result| result[key_for(key)] = build(nested) })
8
+ when Array then value.map { |nested| build(nested) }.freeze
9
+ else value
10
+ end
11
+ end
12
+
13
+ def self.key_for(key)
14
+ key.respond_to?(:to_sym) ? key.to_sym : key
15
+ end
16
+ private_class_method :key_for
17
+
18
+ def initialize(values)
19
+ @values = values.freeze
20
+ reject_reserved_keys
21
+ freeze
22
+ end
23
+
24
+ def [](key)
25
+ @values.fetch(lookup_key(key)) { raise UnknownKey, "no key #{key} here, keys: #{keys.join(', ')}" }
26
+ end
27
+
28
+ def key?(key)
29
+ @values.key?(lookup_key(key))
30
+ end
31
+
32
+ def keys
33
+ @values.keys
34
+ end
35
+
36
+ def dig(*path)
37
+ path.inject(self) do |level, key|
38
+ case level
39
+ when Node then level[key]
40
+ when Array then dig_into_array(level, key)
41
+ else raise UnknownKey, "no key #{key} inside a #{level.class}"
42
+ end
43
+ end
44
+ end
45
+
46
+ def to_h
47
+ @values.each_with_object({}) { |(key, value), result| result[key] = self.class.unwrap(value) }
48
+ end
49
+
50
+ def self.unwrap(value)
51
+ case value
52
+ when Node then value.to_h
53
+ when Array then value.map { |nested| unwrap(nested) }
54
+ else value
55
+ end
56
+ end
57
+
58
+ def ==(other)
59
+ other.is_a?(Node) && to_h == other.to_h
60
+ end
61
+ alias eql? ==
62
+
63
+ def hash
64
+ to_h.hash
65
+ end
66
+
67
+ def inspect
68
+ "#<Kanon::Node keys: #{keys.join(', ')}>"
69
+ end
70
+
71
+ def as_json(*)
72
+ inspect
73
+ end
74
+
75
+ def to_json(*)
76
+ require 'json'
77
+ JSON.generate(as_json)
78
+ end
79
+
80
+ def encode_with(coder)
81
+ coder.represent_scalar(nil, inspect)
82
+ end
83
+
84
+ def instance_variables
85
+ []
86
+ end
87
+
88
+ def marshal_dump
89
+ raise TypeError, "#{inspect} refuses serialization that would expose its values"
90
+ end
91
+
92
+ private
93
+
94
+ def method_missing(name, *arguments)
95
+ return @values[name] if arguments.empty? && @values.key?(name)
96
+
97
+ raise NoMethodError, "undefined method `#{name}' for #{inspect}"
98
+ end
99
+
100
+ def respond_to_missing?(name, include_private = false)
101
+ @values.key?(name.to_sym) || super
102
+ end
103
+
104
+ def dig_into_array(array, key)
105
+ raise UnknownKey, "no index #{key} in an Array of #{array.size}" unless key.is_a?(Integer)
106
+
107
+ array.fetch(key) { raise UnknownKey, "no index #{key} in an Array of #{array.size}" }
108
+ end
109
+
110
+ def lookup_key(key)
111
+ key.respond_to?(:to_sym) ? key.to_sym : key
112
+ end
113
+
114
+ def reject_reserved_keys
115
+ reserved = keys.select { |key| key.is_a?(Symbol) && self.class.method_defined?(key) }
116
+ return if reserved.empty?
117
+
118
+ raise ReservedKey, "#{reserved.join(', ')} clash with node methods and cannot be config keys"
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanon
4
+ module Typecast
5
+ TRUE_VALUES = %w[1 true yes on].freeze
6
+ FALSE_VALUES = %w[0 false no off].freeze
7
+ TYPE_NAMES = %w[string integer float boolean list].freeze
8
+
9
+ private_constant :TRUE_VALUES, :FALSE_VALUES
10
+
11
+ module_function
12
+
13
+ def like_sample(raw_value, sample, source)
14
+ case sample
15
+ when true, false then boolean(raw_value, source)
16
+ when Integer then integer(raw_value, source)
17
+ when Float then float(raw_value, source)
18
+ when Array then list(raw_value)
19
+ else raw_value
20
+ end
21
+ end
22
+
23
+ def by_type_name(raw_value, type_name, source)
24
+ reject_unknown_type(type_name, source)
25
+
26
+ case type_name.to_s
27
+ when 'string' then raw_value.to_s
28
+ when 'integer' then integer(raw_value, source)
29
+ when 'float' then float(raw_value, source)
30
+ when 'boolean' then boolean(raw_value, source)
31
+ when 'list' then list(raw_value)
32
+ end
33
+ end
34
+
35
+ def reject_unknown_type(type_name, source)
36
+ return if TYPE_NAMES.include?(type_name.to_s)
37
+
38
+ raise InvalidValue, "#{source} declares unknown type #{type_name.inspect}"
39
+ end
40
+
41
+ def boolean(raw_value, source)
42
+ return true if TRUE_VALUES.include?(raw_value.to_s.downcase)
43
+ return false if FALSE_VALUES.include?(raw_value.to_s.downcase)
44
+
45
+ raise InvalidValue,
46
+ "#{source} must be one of #{(TRUE_VALUES + FALSE_VALUES).join(', ')}, got #{describe(raw_value)}"
47
+ end
48
+
49
+ # A list that leaves no items counts as no value, so a mandatory one fails
50
+ # rather than arriving empty.
51
+ def list(raw_value)
52
+ return raw_value if raw_value.is_a?(Array)
53
+
54
+ items = raw_value.to_s.split(',').map(&:strip).reject(&:empty?)
55
+ items.empty? ? nil : items
56
+ end
57
+
58
+ def describe(raw_value)
59
+ "#{raw_value.class} of #{raw_value.to_s.length} characters"
60
+ end
61
+
62
+ # The base is explicit: without it a leading zero would be read as octal, and
63
+ # port 010 would turn into 8.
64
+ def integer(raw_value, source)
65
+ raw_value.is_a?(Integer) ? raw_value : Integer(raw_value.to_s, 10)
66
+ rescue ArgumentError, TypeError
67
+ raise InvalidValue, "#{source} must be an integer, got #{describe(raw_value)}", cause: nil
68
+ end
69
+
70
+ def float(raw_value, source)
71
+ Float(raw_value)
72
+ rescue ArgumentError, TypeError
73
+ raise InvalidValue, "#{source} must be a number, got #{describe(raw_value)}", cause: nil
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanon
4
+ VERSION = '0.1.0'
5
+ end
data/lib/kanon.rb ADDED
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+ require 'yaml'
5
+ require 'erb'
6
+ require_relative 'kanon/version'
7
+ require_relative 'kanon/errors'
8
+ require_relative 'kanon/document'
9
+ require_relative 'kanon/node'
10
+ require_relative 'kanon/typecast'
11
+ require_relative 'kanon/env_declarations'
12
+
13
+ module Kanon
14
+ DEFAULT_DIRECTORY = 'config'
15
+ DEFAULT_ENVIRONMENT = 'development'
16
+ ENVIRONMENT_VARIABLES = %w[APP_ENV RAILS_ENV RACK_ENV].freeze
17
+
18
+ @monitor = Monitor.new
19
+ @storage = {}
20
+
21
+ class << self
22
+ def directory
23
+ @monitor.synchronize { @directory ||= DEFAULT_DIRECTORY }
24
+ end
25
+
26
+ def directory=(path)
27
+ raise EmptyDirectory, 'configuration directory cannot be empty' if blank?(path)
28
+
29
+ @monitor.synchronize do
30
+ @directory = path.to_s.strip
31
+ clear
32
+ end
33
+ end
34
+
35
+ def environment
36
+ @monitor.synchronize { @environment ||= given_environment || DEFAULT_ENVIRONMENT }
37
+ end
38
+
39
+ def environment=(name)
40
+ raise EmptyEnvironment, 'environment name cannot be empty' if blank?(name)
41
+
42
+ @monitor.synchronize do
43
+ @environment = name.to_s.strip
44
+ clear
45
+ end
46
+ end
47
+
48
+ def read_config(file_name)
49
+ data = build(file_name.to_s.to_sym)
50
+ data = { file_name.to_s.to_sym => data } unless data.is_a?(Hash)
51
+
52
+ Kanon::Node.build(data)
53
+ end
54
+
55
+ def [](file_name)
56
+ load_config(file_name)
57
+ end
58
+
59
+ def load_config(file_name)
60
+ name = file_name.to_s.to_sym
61
+
62
+ @monitor.synchronize { @storage[name] ||= read_config(name) }
63
+ end
64
+
65
+ def load_configs(*file_names)
66
+ file_names.to_h { |file_name| [file_name.to_s.to_sym, load_config(file_name)] }
67
+ end
68
+
69
+ def loaded_files
70
+ @monitor.synchronize { @storage.keys }
71
+ end
72
+
73
+ def expected_variables(file_name)
74
+ declarations, schema = prepare(file_name.to_s.to_sym)
75
+
76
+ declarations.expected(schema)
77
+ end
78
+
79
+ private
80
+
81
+ def clear
82
+ @monitor.synchronize { @storage.clear }
83
+ end
84
+
85
+ def given_environment
86
+ ENVIRONMENT_VARIABLES.map { |name| ENV.fetch(name, nil) }.find { |value| !blank?(value) }&.strip
87
+ end
88
+
89
+ def blank?(value)
90
+ value.nil? || value.to_s.strip.empty?
91
+ end
92
+
93
+ def build(file_name)
94
+ declarations, schema = prepare(file_name)
95
+
96
+ deep_freeze(declarations.resolve(schema))
97
+ end
98
+
99
+ def prepare(file_name)
100
+ prefix, schema = Kanon::Document.new(directory, file_name, environment).schema
101
+
102
+ [Kanon::EnvDeclarations.new(file_name, prefix), schema]
103
+ end
104
+
105
+ def deep_freeze(value)
106
+ case value
107
+ when Hash then value.each_value { |nested| deep_freeze(nested) }.freeze
108
+ when Array then value.each { |nested| deep_freeze(nested) }.freeze
109
+ else value.freeze
110
+ end
111
+ end
112
+ end
113
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kanon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Maksimenko Pavel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-15 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ A YAML file becomes a frozen tree of nodes with dotted access, and the values
15
+ that belong to the environment are declared in that same file. A read that
16
+ misses a mandatory value fails naming every absent variable at once, and a
17
+ typo in a key raises instead of returning nil. ERB tags keep working, so a
18
+ file that already reads the environment that way needs no rewriting. The
19
+ standard library only, with the config directory and the environment name
20
+ injected from outside.
21
+ email:
22
+ - pavel.g.maksimenko@gmail.com
23
+ executables: []
24
+ extensions: []
25
+ extra_rdoc_files: []
26
+ files:
27
+ - CHANGELOG.md
28
+ - LICENSE
29
+ - README.md
30
+ - docs/decisions.md
31
+ - docs/specification.md
32
+ - kanon.gemspec
33
+ - lib/kanon.rb
34
+ - lib/kanon/document.rb
35
+ - lib/kanon/env_declarations.rb
36
+ - lib/kanon/errors.rb
37
+ - lib/kanon/node.rb
38
+ - lib/kanon/typecast.rb
39
+ - lib/kanon/version.rb
40
+ homepage: https://github.com/MaksimenkoPG/kanon
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ homepage_uri: https://github.com/MaksimenkoPG/kanon
45
+ source_code_uri: https://github.com/MaksimenkoPG/kanon
46
+ changelog_uri: https://github.com/MaksimenkoPG/kanon/blob/master/CHANGELOG.md
47
+ rubygems_mfa_required: 'true'
48
+ post_install_message:
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '2.7'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.1.6
64
+ signing_key:
65
+ specification_version: 4
66
+ summary: A YAML config becomes a frozen tree of nodes, and declares the environment
67
+ variables it needs
68
+ test_files: []