metanorma-release 0.2.2 → 0.2.3

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +19 -1
  3. data/.rubocop_todo.yml +250 -319
  4. data/README.adoc +120 -233
  5. data/Rakefile +2 -2
  6. data/exe/metanorma-release +2 -2
  7. data/lib/metanorma/release/aggregation_pipeline.rb +59 -45
  8. data/lib/metanorma/release/asset_processor.rb +10 -8
  9. data/lib/metanorma/release/cache_store.rb +6 -6
  10. data/lib/metanorma/release/change_detector.rb +7 -3
  11. data/lib/metanorma/release/channel.rb +13 -39
  12. data/lib/metanorma/release/channel_filter.rb +26 -10
  13. data/lib/metanorma/release/cli.rb +129 -100
  14. data/lib/metanorma/release/commands/aggregate.rb +39 -54
  15. data/lib/metanorma/release/commands/package.rb +20 -12
  16. data/lib/metanorma/release/commands/{publish.rb → release_command.rb} +20 -12
  17. data/lib/metanorma/release/config.rb +104 -0
  18. data/lib/metanorma/release/content_hash.rb +11 -3
  19. data/lib/metanorma/release/delta_state.rb +55 -18
  20. data/lib/metanorma/release/file_routing.rb +8 -5
  21. data/lib/metanorma/release/index.rb +132 -0
  22. data/lib/metanorma/release/interfaces.rb +15 -15
  23. data/lib/metanorma/release/platform/github/manifest_reader.rb +4 -4
  24. data/lib/metanorma/release/platform/github/publisher.rb +23 -11
  25. data/lib/metanorma/release/platform/github/release_fetcher.rb +12 -3
  26. data/lib/metanorma/release/platform/github.rb +10 -7
  27. data/lib/metanorma/release/platform/local/directory_discoverer.rb +1 -1
  28. data/lib/metanorma/release/platform/local/fetcher.rb +17 -12
  29. data/lib/metanorma/release/platform/local/publisher.rb +9 -7
  30. data/lib/metanorma/release/platform/local.rb +4 -4
  31. data/lib/metanorma/release/platform/null/publisher.rb +3 -2
  32. data/lib/metanorma/release/platform/null.rb +1 -1
  33. data/lib/metanorma/release/platform.rb +3 -3
  34. data/lib/metanorma/release/platform_factory.rb +48 -29
  35. data/lib/metanorma/release/publication.rb +335 -0
  36. data/lib/metanorma/release/release_pipeline.rb +85 -52
  37. data/lib/metanorma/release/repo_ref.rb +5 -2
  38. data/lib/metanorma/release/site.rb +66 -0
  39. data/lib/metanorma/release/slug_strategy.rb +163 -0
  40. data/lib/metanorma/release/version.rb +1 -1
  41. data/lib/metanorma/release/zip_packager.rb +31 -8
  42. data/lib/metanorma/release.rb +68 -94
  43. metadata +22 -26
  44. data/lib/metanorma/release/aggregation_interfaces.rb +0 -27
  45. data/lib/metanorma/release/channel_audience.rb +0 -24
  46. data/lib/metanorma/release/channel_config.rb +0 -55
  47. data/lib/metanorma/release/channel_manifest.rb +0 -192
  48. data/lib/metanorma/release/channel_registry.rb +0 -60
  49. data/lib/metanorma/release/config_fetcher.rb +0 -11
  50. data/lib/metanorma/release/config_locator.rb +0 -37
  51. data/lib/metanorma/release/config_resolver.rb +0 -37
  52. data/lib/metanorma/release/document_id.rb +0 -45
  53. data/lib/metanorma/release/document_index.rb +0 -183
  54. data/lib/metanorma/release/document_metadata.rb +0 -39
  55. data/lib/metanorma/release/document_stage.rb +0 -86
  56. data/lib/metanorma/release/document_type.rb +0 -55
  57. data/lib/metanorma/release/document_version.rb +0 -50
  58. data/lib/metanorma/release/naming_strategy.rb +0 -158
  59. data/lib/metanorma/release/platform/github/config_fetcher.rb +0 -40
  60. data/lib/metanorma/release/platform/local/config_fetcher.rb +0 -20
  61. data/lib/metanorma/release/rake_tasks.rb +0 -71
  62. data/lib/metanorma/release/relaton_enricher.rb +0 -138
  63. data/lib/metanorma/release/release_metadata.rb +0 -79
  64. data/lib/metanorma/release/release_tag.rb +0 -49
  65. data/lib/metanorma/release/rxl_extractor.rb +0 -115
  66. data/lib/metanorma/release/stage_filter.rb +0 -18
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'yaml'
4
-
5
- module Metanorma
6
- module Release
7
- class ChannelConfig
8
- def self.from_yaml(yaml_string)
9
- data = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
10
- raise ArgumentError, 'Invalid channel config YAML' unless data.is_a?(Hash)
11
-
12
- registry = ChannelRegistry.from_yaml(yaml_string)
13
- defaults = data['defaults'] || {}
14
- default_visibility = defaults['visibility'] || 'public'
15
- default_channels = parse_channels(defaults['channels'])
16
-
17
- new(registry: registry, default_visibility: default_visibility,
18
- default_channels: default_channels)
19
- end
20
-
21
- def self.from_file(path)
22
- if File.directory?(path)
23
- channels_yml = File.join(path, 'channels.yml')
24
- raise ArgumentError, "Channel config file not found: #{path}" unless File.exist?(channels_yml)
25
-
26
- return from_file(channels_yml)
27
- end
28
-
29
- raise ArgumentError, "Channel config file not found: #{path}" unless File.exist?(path)
30
-
31
- from_yaml(File.read(path))
32
- end
33
-
34
- def self.empty
35
- new(registry: ChannelRegistry.all_allowed,
36
- default_visibility: 'public', default_channels: [])
37
- end
38
-
39
- def initialize(registry:, default_visibility:, default_channels:)
40
- @registry = registry
41
- @default_visibility = default_visibility
42
- @default_channels = default_channels.freeze
43
- freeze
44
- end
45
-
46
- attr_reader :registry, :default_visibility, :default_channels
47
-
48
- def self.parse_channels(list)
49
- return [] unless list
50
-
51
- list.map { |c| Channel.parse(c.to_s) }
52
- end
53
- end
54
- end
55
- end
@@ -1,192 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'yaml'
4
-
5
- module Metanorma
6
- module Release
7
- class DocumentReleasePolicy
8
- def self.from_defaults(visibility, channels)
9
- ch = build_channels(visibility, channels)
10
- is_released = visibility != 'private' || !ch.empty?
11
- new(release: is_released, channels: ch, stage_allow_list: nil)
12
- end
13
-
14
- def self.from_entry(entry)
15
- ch = build_channels(entry.visibility, entry.channels)
16
- new(release: true, channels: ch, stage_allow_list: entry.stages_set)
17
- end
18
-
19
- def self.not_released
20
- new(release: false, channels: [].freeze, stage_allow_list: nil)
21
- end
22
-
23
- def initialize(release:, channels:, stage_allow_list:)
24
- @release = release
25
- @channels = channels.freeze
26
- @stage_allow_list = stage_allow_list
27
- freeze
28
- end
29
-
30
- def release?
31
- @release
32
- end
33
-
34
- attr_reader :channels, :stage_allow_list
35
-
36
- def self.build_channels(visibility, explicit_channels)
37
- return explicit_channels if explicit_channels && !explicit_channels.empty?
38
-
39
- case visibility
40
- when 'public' then [Channel.public('default')]
41
- when 'members' then [Channel.members('default')]
42
- else [].freeze
43
- end
44
- end
45
- end
46
-
47
- class ManifestEntry
48
- attr_reader :source, :pattern, :visibility, :channels, :stages
49
-
50
- def initialize(source:, pattern:, visibility:, channels:, stages:)
51
- @source = source
52
- @pattern = pattern
53
- @visibility = visibility
54
- @channels = channels
55
- @stages = stages
56
- freeze
57
- end
58
-
59
- def match_priority
60
- return 100 if source
61
- return 50 + pattern.to_s.length if pattern
62
-
63
- 0
64
- end
65
-
66
- def stages_set
67
- return nil if stages.nil? || stages.empty?
68
-
69
- Set.new(stages.map(&:downcase))
70
- end
71
- end
72
-
73
- class ChannelManifest
74
- def self.parse(yaml_hash)
75
- defaults = yaml_hash['defaults'] || {}
76
- default_visibility = defaults['visibility'] || 'public'
77
- default_channels = parse_channels(defaults['channels'])
78
- entries = parse_entries(yaml_hash['documents'] || [])
79
- config_source = yaml_hash['config']
80
-
81
- new(entries: entries, default_visibility: default_visibility,
82
- default_channels: default_channels, explicit: true, config_source: config_source)
83
- end
84
-
85
- def self.from_yaml(yaml_string)
86
- yaml = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
87
- raise ArgumentError, 'Manifest YAML is empty' unless yaml.is_a?(Hash)
88
-
89
- parse(yaml)
90
- end
91
-
92
- def self.from_file(path)
93
- raise ArgumentError, "Manifest file not found: #{path}" unless File.exist?(path)
94
-
95
- from_yaml(File.read(path))
96
- end
97
-
98
- def self.all_public
99
- new(entries: [], default_visibility: 'public',
100
- default_channels: [Channel.public('default')], explicit: false, config_source: nil)
101
- end
102
-
103
- def self.all_private
104
- new(entries: [], default_visibility: 'private',
105
- default_channels: [], explicit: false, config_source: nil)
106
- end
107
-
108
- def initialize(entries:, default_visibility:, default_channels:, explicit:, config_source: nil)
109
- @entries = entries
110
- @default_visibility = default_visibility
111
- @default_channels = default_channels.freeze
112
- @explicit = explicit
113
- @config_source = config_source
114
- freeze
115
- end
116
-
117
- def resolve(document)
118
- return default_policy unless @explicit
119
-
120
- entry = find_best_match(document)
121
- return DocumentReleasePolicy.from_defaults(@default_visibility, @default_channels) unless entry
122
-
123
- DocumentReleasePolicy.from_entry(entry)
124
- end
125
-
126
- def list_all
127
- @entries
128
- end
129
-
130
- def all_channels
131
- (@default_channels + @entries.flat_map(&:channels)).uniq
132
- end
133
-
134
- def explicit?
135
- @explicit
136
- end
137
-
138
- attr_reader :config_source
139
-
140
- private
141
-
142
- def default_policy
143
- DocumentReleasePolicy.from_defaults(@default_visibility, @default_channels)
144
- end
145
-
146
- def find_best_match(document)
147
- source = extract_source(document)
148
- matches = @entries.select { |e| entry_matches?(e, source) }
149
- return nil if matches.empty?
150
-
151
- matches.max_by(&:match_priority)
152
- end
153
-
154
- def entry_matches?(entry, source)
155
- return false unless source
156
- return true if entry.source && entry.source == source
157
- return true if entry.pattern && File.fnmatch?(entry.pattern, source)
158
-
159
- false
160
- end
161
-
162
- def extract_source(document)
163
- document['source_path']
164
- end
165
-
166
- def self.parse_channels(channel_list)
167
- return [] unless channel_list
168
-
169
- channel_list.map { |c| Channel.parse(c.to_s) }
170
- end
171
-
172
- def self.parse_entries(documents)
173
- documents.map do |doc|
174
- validate_entry!(doc)
175
- ManifestEntry.new(
176
- source: doc['source'],
177
- pattern: doc['pattern'],
178
- visibility: doc['visibility'],
179
- channels: parse_channels(doc['channels']),
180
- stages: doc['stages']
181
- )
182
- end
183
- end
184
-
185
- def self.validate_entry!(doc)
186
- return unless doc['source']&.include?('..')
187
-
188
- raise ArgumentError, "Path traversal detected in manifest source: #{doc['source']}"
189
- end
190
- end
191
- end
192
- end
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'yaml'
4
-
5
- module Metanorma
6
- module Release
7
- class ChannelRegistry
8
- def self.from_yaml(yaml_string)
9
- data = YAML.safe_load(yaml_string, permitted_classes: [Symbol])
10
- raise ArgumentError, 'Invalid channel registry YAML' unless data.is_a?(Hash)
11
-
12
- channels = parse_channel_list(data['channels'])
13
- new(channels: channels)
14
- end
15
-
16
- def self.from_file(path)
17
- raise ArgumentError, "Channel registry file not found: #{path}" unless File.exist?(path)
18
-
19
- from_yaml(File.read(path))
20
- end
21
-
22
- def self.all_allowed
23
- new(channels: [])
24
- end
25
-
26
- def initialize(channels:)
27
- @channels = channels.freeze
28
- @channel_set = channels.to_set
29
- freeze
30
- end
31
-
32
- def valid?(channel)
33
- return true if @channels.empty?
34
-
35
- @channel_set.include?(channel)
36
- end
37
-
38
- def include?(channel_or_string)
39
- valid?(Channel.parse(channel_or_string.to_s))
40
- end
41
-
42
- attr_reader :channels
43
-
44
- def empty?
45
- @channels.empty?
46
- end
47
-
48
- def self.parse_channel_list(list)
49
- return [] unless list
50
-
51
- list.filter_map do |entry|
52
- case entry
53
- when Hash then Channel.parse(entry['name'].to_s)
54
- when String then Channel.parse(entry)
55
- end
56
- end
57
- end
58
- end
59
- end
60
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Metanorma
4
- module Release
5
- module ConfigFetcher
6
- def fetch(source)
7
- raise NotImplementedError, "#{self.class} must implement #fetch"
8
- end
9
- end
10
- end
11
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Metanorma
4
- module Release
5
- class ConfigLocator
6
- CONFIG_FILES = ['.metanorma.yml', '.metanorma.yaml'].freeze
7
- CONFIG_DIRS = ['.metanorma'].freeze
8
-
9
- def self.find(start_dir = Dir.pwd)
10
- new.find(start_dir)
11
- end
12
-
13
- def find(start_dir)
14
- dir = File.expand_path(start_dir)
15
- loop do
16
- CONFIG_FILES.each do |name|
17
- path = File.join(dir, name)
18
- return ChannelConfig.from_file(path) if File.exist?(path)
19
- end
20
-
21
- CONFIG_DIRS.each do |name|
22
- path = File.join(dir, name)
23
- next unless File.directory?(path)
24
-
25
- channels = File.join(path, 'channels.yml')
26
- return ChannelConfig.from_file(channels) if File.exist?(channels)
27
- end
28
-
29
- parent = File.dirname(dir)
30
- return nil if parent == dir
31
-
32
- dir = parent
33
- end
34
- end
35
- end
36
- end
37
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Metanorma
4
- module Release
5
- module ConfigResolver
6
- def resolve_channel_config(cli_source, manifest)
7
- return fetch_config(cli_source) if cli_source
8
- return fetch_config(manifest.config_source) if manifest&.config_source
9
-
10
- found = ConfigLocator.find
11
- return found if found
12
-
13
- ChannelConfig.empty
14
- end
15
-
16
- def load_manifest(path)
17
- return nil unless path && File.exist?(path)
18
-
19
- ChannelManifest.from_file(path)
20
- end
21
-
22
- private
23
-
24
- def fetch_config(source)
25
- if source.start_with?('local:')
26
- Platform::Local::ConfigFetcher.new.fetch(source)
27
- elsif source.include?('/')
28
- Platform::Local::ConfigFetcher.new.fetch("local:#{source}")
29
- else
30
- require 'octokit'
31
- client = PlatformFactory.build_github_client(nil)
32
- Platform::GitHub::ConfigFetcher.new(client: client).fetch(source)
33
- end
34
- end
35
- end
36
- end
37
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Metanorma
4
- module Release
5
- class DocumentId
6
- def self.from_raw(raw_identifier)
7
- normalized = raw_identifier.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-|-$/, '')
8
- raise ArgumentError, 'Document ID cannot be empty' if normalized.empty?
9
-
10
- new(normalized)
11
- end
12
-
13
- def self.from_normalized(value)
14
- raise ArgumentError, 'Document ID cannot be empty' if value.nil? || value.strip.empty?
15
-
16
- new(value.to_s.strip)
17
- end
18
-
19
- def initialize(value)
20
- @value = value
21
- freeze
22
- end
23
-
24
- def to_s
25
- @value
26
- end
27
-
28
- def tag_prefix
29
- @value
30
- end
31
-
32
- def file_name
33
- @value
34
- end
35
-
36
- def eql?(other)
37
- other.is_a?(self.class) && @value == other.to_s
38
- end
39
-
40
- def hash
41
- @value.hash
42
- end
43
- end
44
- end
45
- end
@@ -1,183 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
-
5
- module Metanorma
6
- module Release
7
- DocumentFile = Struct.new(:name, :path, keyword_init: true) do
8
- def extension
9
- File.extname(name).delete_prefix('.')
10
- end
11
- end
12
-
13
- DocumentSource = Struct.new(
14
- :owner, :repo, :tag, :release_url, :release_date,
15
- keyword_init: true
16
- ) do
17
- def repo_key
18
- "#{owner}/#{repo}"
19
- end
20
- end
21
-
22
- IndexParameters = Struct.new(
23
- :organizations, :channels, :topic, :repo_count,
24
- keyword_init: true
25
- )
26
-
27
- IndexSummary = Struct.new(
28
- :repo_count, :document_count, :channels_found,
29
- keyword_init: true
30
- )
31
-
32
- class AggregatedDocument
33
- def self.from_h(hash)
34
- files = (hash['files'] || []).map do |f|
35
- DocumentFile.new(name: f['name'], path: f['path'])
36
- end
37
- source_data = hash['source'] || {}
38
- source = DocumentSource.new(
39
- owner: source_data['owner'], repo: source_data['repo'],
40
- tag: source_data['tag'], release_url: source_data['releaseUrl'],
41
- release_date: source_data['releaseDate']
42
- )
43
- new(
44
- id: hash['id'], title: hash['title'], edition: hash['edition'],
45
- stage: hash['stage'], doctype: hash.fetch('doctype', ''),
46
- channels: hash['channels'] || [], formats: hash['formats'] || [],
47
- flavor: hash['flavor'], content_hash: hash['contentHash'],
48
- source: source, files: files
49
- )
50
- end
51
-
52
- attr_reader :id, :title, :edition, :stage, :doctype, :channels,
53
- :formats, :flavor, :content_hash, :source, :files
54
-
55
- def initialize(id:, title:, edition:, stage:, doctype:, channels:,
56
- formats:, flavor:, content_hash:, source:, files:)
57
- @id = id
58
- @title = title
59
- @edition = edition
60
- @stage = stage
61
- @doctype = doctype
62
- @channels = channels.freeze
63
- @formats = formats.freeze
64
- @flavor = flavor
65
- @content_hash = content_hash
66
- @source = source
67
- @files = files.freeze
68
- freeze
69
- end
70
-
71
- def to_h
72
- {
73
- 'id' => id, 'title' => title, 'edition' => edition,
74
- 'stage' => stage, 'doctype' => doctype,
75
- 'channels' => channels, 'formats' => formats,
76
- 'flavor' => flavor, 'contentHash' => content_hash,
77
- 'source' => {
78
- 'owner' => source.owner, 'repo' => source.repo,
79
- 'tag' => source.tag, 'releaseUrl' => source.release_url,
80
- 'releaseDate' => source.release_date
81
- },
82
- 'files' => files.map { |f| { 'name' => f.name, 'path' => f.path } }
83
- }
84
- end
85
- end
86
-
87
- class DocumentIndex
88
- SCHEMA_VERSION = 1
89
-
90
- SchemaError = Class.new(StandardError)
91
-
92
- def self.from_json(json_string)
93
- data = JSON.parse(json_string)
94
- validate!(data)
95
- new(
96
- documents: (data['documents'] || []).map { |d| AggregatedDocument.from_h(d) },
97
- parameters: IndexParameters.new(
98
- organizations: data.dig('parameters', 'organizations') || [],
99
- channels: data.dig('parameters', 'channels') || [],
100
- topic: data.dig('parameters', 'topic'),
101
- repo_count: data.dig('parameters', 'repoCount') || 0
102
- ),
103
- generated_at: data['generatedAt']
104
- )
105
- end
106
-
107
- def self.from_documents(documents, parameters:)
108
- new(documents: documents, parameters: parameters)
109
- end
110
-
111
- def initialize(documents:, parameters:, generated_at: nil)
112
- @documents = documents.freeze
113
- @parameters = parameters
114
- @generated_at = generated_at || Time.now.utc.iso8601
115
- freeze
116
- end
117
-
118
- attr_reader :documents, :parameters
119
-
120
- def summary
121
- IndexSummary.new(
122
- repo_count: @parameters.repo_count,
123
- document_count: @documents.length,
124
- channels_found: channels
125
- )
126
- end
127
-
128
- def channels
129
- @documents.flat_map(&:channels).uniq.sort
130
- end
131
-
132
- def document_count
133
- @documents.length
134
- end
135
-
136
- def empty?
137
- @documents.empty?
138
- end
139
-
140
- def to_h
141
- {
142
- 'version' => SCHEMA_VERSION,
143
- 'generatedAt' => @generated_at,
144
- 'parameters' => {
145
- 'organizations' => @parameters.organizations,
146
- 'channels' => @parameters.channels,
147
- 'topic' => @parameters.topic,
148
- 'repoCount' => @parameters.repo_count
149
- },
150
- 'summary' => {
151
- 'repoCount' => summary.repo_count,
152
- 'documentCount' => summary.document_count,
153
- 'channelsFound' => summary.channels_found
154
- },
155
- 'documents' => @documents.map(&:to_h)
156
- }
157
- end
158
-
159
- def to_json(*_args)
160
- JSON.generate(to_h)
161
- end
162
-
163
- def write(path)
164
- FileUtils.mkdir_p(File.dirname(path))
165
- File.write(path, to_json)
166
- end
167
-
168
- def self.validate!(data)
169
- raise SchemaError, "Missing 'version' field" unless data.key?('version')
170
- unless data['version'] == SCHEMA_VERSION
171
- raise SchemaError,
172
- "Unsupported schema version: #{data['version']}. Expected #{SCHEMA_VERSION}"
173
- end
174
- raise SchemaError, "Missing 'documents' field" unless data.key?('documents')
175
-
176
- data['documents'].each do |doc|
177
- raise SchemaError, "Document missing required field 'id'" unless doc.key?('id')
178
- raise SchemaError, "Document missing required field 'title'" unless doc.key?('title')
179
- end
180
- end
181
- end
182
- end
183
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Metanorma
4
- module Release
5
- class DocumentMetadata
6
- attr_reader :id, :title, :version, :doctype, :document_type,
7
- :flavor, :revdate, :source_path, :output_dir,
8
- :formats, :file_base_name
9
-
10
- def initialize(id:, title:, version:, doctype:, document_type:,
11
- flavor:, revdate:, source_path:, output_dir:,
12
- formats:, file_base_name:)
13
- @id = id
14
- @title = title
15
- @version = version
16
- @doctype = doctype
17
- @document_type = document_type
18
- @flavor = flavor
19
- @revdate = revdate
20
- @source_path = source_path
21
- @output_dir = output_dir
22
- @formats = formats.freeze
23
- @file_base_name = file_base_name
24
- @lookup = {
25
- 'id' => @id, 'title' => @title, 'doctype' => @doctype,
26
- 'document_type' => @document_type, 'flavor' => @flavor,
27
- 'revdate' => @revdate, 'source_path' => @source_path,
28
- 'output_dir' => @output_dir, 'formats' => @formats,
29
- 'file_base_name' => @file_base_name
30
- }.freeze
31
- freeze
32
- end
33
-
34
- def [](key)
35
- @lookup[key]
36
- end
37
- end
38
- end
39
- end