cosing 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.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ module Annex
5
+ class Base
6
+ def initialize
7
+ @rules = {}
8
+ end
9
+
10
+ def keys
11
+ @rules.keys
12
+ end
13
+
14
+ def add_rule(params)
15
+ return unless params[:reference_number]
16
+
17
+ @rules[params[:reference_number]] = self.class::Rule.new(
18
+ **params.merge(annex: self.class.name.gsub("::", " "))
19
+ )
20
+ end
21
+
22
+ def lookup(reference_number)
23
+ @rules.fetch(
24
+ reference_number.to_s,
25
+ fuzzy_find(reference_number.to_s)
26
+ )
27
+ rescue KeyError => e
28
+ # Known missing data from original source
29
+ return nil if reference_number == "19" && instance_of?(Annex::VI)
30
+ return nil if reference_number == "41" && instance_of?(Annex::VI)
31
+ return nil if reference_number == "31" && instance_of?(Annex::VI)
32
+ return nil if reference_number == "44" && instance_of?(Annex::VI)
33
+ return nil if reference_number == "268" && instance_of?(Annex::III)
34
+
35
+ raise e
36
+ end
37
+
38
+ private
39
+
40
+ def fuzzy_find(reference_number)
41
+ return @rules[reference_number] if @rules.key?(reference_number)
42
+
43
+ candidates = @rules.keys.grep(/#{reference_number}[abcd]/)
44
+ candidates
45
+ .map { |candidate| @rules[candidate] }
46
+ .tap do |candidates|
47
+ if candidates.empty?
48
+ raise KeyError,
49
+ "Could not find #{reference_number} in #{self.class}, " \
50
+ "valid keys are #{keys}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ module Annex
5
+ class II < Base
6
+ class Rule < Rule
7
+ attribute :inn, Types::String
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ module Annex
5
+ class III < Base
6
+ class Rule < Rule
7
+ attribute :common_ingredients, Types::Array.of(Types::String)
8
+ attribute :inn, Types::String
9
+ attribute :maximum_concentration, Types::String
10
+ attribute :other_restrictions, Types::String
11
+ attribute :product_type, Types::String
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ module Annex
5
+ class IV < Base
6
+ class Rule < Rule
7
+ attribute :colour, Types::String
8
+ attribute :colour_index_number, Types::String
9
+ attribute :maximum_concentration, Types::String
10
+ attribute :other_restrictions, Types::String
11
+ attribute :product_type, Types::String
12
+ attribute :wording_of_conditions, Types::String
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ module Cosing
2
+ module Annex
3
+ class Rule < Dry::Struct
4
+ attribute :annex, Types::String
5
+ attribute :cas_numbers, Types::Array.of(Types::String)
6
+ attribute :chemical_name, Types::String
7
+ attribute :cmr, Types::String
8
+ attribute :ec_numbers, Types::Array.of(Types::String)
9
+ attribute :identified_ingredients, Types::Array.of(Types::String)
10
+ attribute :other_regulations, Types::String
11
+ attribute :reference_number, Types::String
12
+ attribute :regulated_by, Types::String
13
+ attribute :regulation, Types::String
14
+ attribute :sccs_opinions, Types::Array.of(SccsOpinion)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ module Cosing
2
+ module Annex
3
+ class SccsOpinion < Dry::Struct
4
+ attribute :code, Types::String
5
+ attribute :description, Types::String
6
+
7
+ def inspect
8
+ to_h
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ module Annex
5
+ class V < Base
6
+ class Rule < Rule
7
+ attribute :inn, Types::String
8
+ attribute :maximum_concentration, Types::String
9
+ attribute :other_restrictions, Types::String
10
+ attribute :product_type, Types::String
11
+ attribute :wording_of_conditions, Types::String
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ module Annex
5
+ class VI < Base
6
+ class Rule < Rule
7
+ attribute :common_ingredients, Types::Array.of(Types::String)
8
+ attribute :inn, Types::String
9
+ attribute :maximum_concentration, Types::String
10
+ attribute :other_restrictions, Types::String
11
+ attribute :product_type, Types::String
12
+ attribute :wording_of_conditions, Types::String
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "annex/sccs_opinion"
4
+ require_relative "annex/rule"
5
+ require_relative "annex/base"
6
+ require_relative "annex/ii"
7
+ require_relative "annex/iii"
8
+ require_relative "annex/iv"
9
+ require_relative "annex/v"
10
+ require_relative "annex/vi"
11
+
12
+ module Cosing
13
+ module Annex
14
+ module_function
15
+
16
+ def parse(path)
17
+ CSV.parse(
18
+ File.read(path),
19
+ headers: true,
20
+ liberal_parsing: true,
21
+ header_converters: :symbol
22
+ ) do |row|
23
+ row = row
24
+ .to_h
25
+ .transform_values do |value|
26
+ value.to_s.strip.then { |val| val == "-" ? "" : val }
27
+ end
28
+
29
+ cas_numbers = build_cas_numbers(row)
30
+ ec_numbers = build_ec_numbers(row)
31
+ sccs_opinions = build_sccs_opinions(row)
32
+
33
+ yield row.merge(
34
+ cas_numbers: cas_numbers.compact,
35
+ ec_numbers: ec_numbers.compact,
36
+ sccs_opinions: sccs_opinions.compact,
37
+ )
38
+ end
39
+ end
40
+
41
+ def load
42
+ annex_ii = Annex::II.new.tap do |annex|
43
+ parse("data/annex.II.csv") do |row|
44
+ identified_ingredients = transform_array!(
45
+ row,
46
+ key: :identified_ingredients,
47
+ split: ";"
48
+ )
49
+
50
+ annex.add_rule(
51
+ row.merge(
52
+ identified_ingredients: identified_ingredients.compact
53
+ )
54
+ )
55
+ end
56
+ end
57
+
58
+ annex_iii = Annex::III.new.tap do |annex|
59
+ parse("data/annex.III.csv") do |row|
60
+ common_ingredients = transform_array!(
61
+ row,
62
+ key: :common_ingredients,
63
+ split: ";"
64
+ )
65
+ identified_ingredients = transform_array!(
66
+ row,
67
+ key: :identified_ingredients,
68
+ split: ";"
69
+ )
70
+
71
+ annex.add_rule(
72
+ row.merge(
73
+ common_ingredients: common_ingredients.compact,
74
+ identified_ingredients: identified_ingredients.compact,
75
+ other_restrictions: row[:other]
76
+ )
77
+ )
78
+ end
79
+ end
80
+
81
+ annex_iv = Annex::IV.new.tap do |annex|
82
+ parse("data/annex.IV.csv") do |row|
83
+ identified_ingredients = transform_array!(
84
+ row,
85
+ key: :identified_ingredients,
86
+ split: ";"
87
+ )
88
+
89
+ annex.add_rule(
90
+ row.merge(
91
+ identified_ingredients: identified_ingredients.compact,
92
+ other_restrictions: row[:other]
93
+ )
94
+ )
95
+ end
96
+ end
97
+
98
+ annex_v = Annex::V.new.tap do |annex|
99
+ parse("data/annex.V.csv") do |row|
100
+ common_ingredients = transform_array!(
101
+ row,
102
+ key: :common_ingredients,
103
+ split: ";"
104
+ )
105
+ identified_ingredients = transform_array!(
106
+ row,
107
+ key: :identified_ingredients,
108
+ split: ";"
109
+ )
110
+
111
+ annex.add_rule(
112
+ row.merge(
113
+ common_ingredients: common_ingredients.compact,
114
+ identified_ingredients: identified_ingredients.compact,
115
+ other_restrictions: row[:other]
116
+ )
117
+ )
118
+ end
119
+ end
120
+
121
+ annex_vi = Annex::VI.new.tap do |annex|
122
+ parse("data/annex.VI.csv") do |row|
123
+ common_ingredients = transform_array!(
124
+ row,
125
+ key: :common_ingredients,
126
+ split: ";"
127
+ )
128
+ identified_ingredients = transform_array!(
129
+ row,
130
+ key: :identified_ingredients,
131
+ split: ";"
132
+ )
133
+
134
+ annex.add_rule(
135
+ row.merge(
136
+ common_ingredients: common_ingredients.compact,
137
+ identified_ingredients: identified_ingredients.compact,
138
+ other_restrictions: row[:other]
139
+ )
140
+ )
141
+ end
142
+ end
143
+
144
+ {
145
+ ii: annex_ii,
146
+ iii: annex_iii,
147
+ iv: annex_iv,
148
+ v: annex_v,
149
+ vi: annex_vi
150
+ }
151
+ end
152
+
153
+ def transform_array!(params, key:, split:)
154
+ params
155
+ .delete(key)
156
+ .split(split)
157
+ .map(&:strip)
158
+ .reject { |n| n == "-" }
159
+ end
160
+
161
+ def build_sccs_opinions(row)
162
+ transform_array!(
163
+ row,
164
+ key: :sccs_opinions,
165
+ split: ";"
166
+ ).map do |opinion|
167
+ code, *description = opinion.split("-")
168
+
169
+ SccsOpinion.new(
170
+ code:,
171
+ description: description.join("-")
172
+ )
173
+ end
174
+ end
175
+
176
+ def build_cas_numbers(row)
177
+ transform_array!(
178
+ row,
179
+ key: :cas_number,
180
+ split: "/"
181
+ ).map do |cas_number|
182
+ match = cas_number.match(/(?<cas_number>\d{2,7}-\d{2}-\d)/)
183
+ match[:cas_number] if match
184
+ end
185
+ end
186
+
187
+ def build_ec_numbers(row)
188
+ transform_array!(
189
+ row,
190
+ key: :ec_number,
191
+ split: "/"
192
+ ).map do |ec_number|
193
+ match = ec_number.match(/(?<ec_number>\d{3}-\d{3}-\d)/)
194
+ match[:ec_number] if match
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ class Database
5
+ attr_reader :annexes, :ingredients
6
+
7
+ def initialize(annexes)
8
+ @annexes = annexes
9
+ @ingredients = {}
10
+ end
11
+
12
+ def add_ingredient(params)
13
+ restrictions = transform_array!(params, key: :restriction, split: "\n")
14
+ annotation_pattern = %r{([IVX]+)/([IVX]*/)?([\dabcd,]+)}
15
+
16
+ regulations = restrictions
17
+ .flat_map do |restriction|
18
+ matches = restriction.scan(annotation_pattern)
19
+ next unless matches.any?
20
+
21
+ hits = matches.flat_map do |match|
22
+ numeral, _, reference_number = match
23
+
24
+ reference_number.split(",").flat_map do |number|
25
+ #debugger if number == "41" && numeral == "VI"
26
+ @annexes[numeral.downcase.to_sym].lookup(number)
27
+ end
28
+ end
29
+
30
+ hits.compact
31
+ end
32
+
33
+ cas_numbers = transform_array!(
34
+ params,
35
+ key: :cas_number,
36
+ split: "/"
37
+ ).map do |cas_number|
38
+ match = cas_number.match(/(?<cas_number>\d{2,7}-\d{2}-\d)/)
39
+ match[:cas_number] if match
40
+ end
41
+
42
+ functions = transform_array!(params, key: :functions, split: ",")
43
+
44
+ einecs_numbers = transform_array!(
45
+ params,
46
+ key: :einecs_number,
47
+ split: "/"
48
+ ).map do |einecs_number|
49
+ match = einecs_number.match(/(?<einecs_number>\d{3}-\d{3}-\d)/)
50
+ match[:einecs_number] if match
51
+ end
52
+
53
+ @ingredients[params[:reference_number]] = Ingredient.new(
54
+ functions:,
55
+ restrictions: restrictions.compact,
56
+ regulations: regulations.compact,
57
+ cas_numbers: cas_numbers.compact,
58
+ einecs_numbers: einecs_numbers.compact,
59
+ **params
60
+ )
61
+ end
62
+
63
+ def save(filepath, pretty: false)
64
+ output = if pretty
65
+ JSON.pretty_generate(@ingredients.to_h)
66
+ else
67
+ JSON.dump(@ingredients.to_h)
68
+ end
69
+
70
+ File.write(filepath, output)
71
+ end
72
+
73
+ private
74
+
75
+ def transform_array!(params, key:, split:)
76
+ params
77
+ .delete(key)
78
+ .split(split)
79
+ .map(&:strip)
80
+ .reject { |n| n == "-" }
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ class Ingredient < Dry::Struct
5
+ attribute :reference_number, Types::String
6
+ attribute :inci_name, Types::String
7
+ attribute :inn, Types::String
8
+ attribute :ph_eur_name, Types::String
9
+ attribute :cas_numbers, Types::Array.of(Types::CasNumber)
10
+ attribute :einecs_numbers, Types::Array.of(Types::EinecsNumber)
11
+ attribute :description, Types::String
12
+ attribute :restrictions, Types::Array.of(Types::String)
13
+ attribute :functions, Types::Array.of(Types::String)
14
+ attribute :regulations, Types::Array
15
+
16
+ def inspect
17
+ to_h
18
+ end
19
+
20
+ def to_json(...)
21
+ to_h.to_json(...)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ module Types
5
+ include Dry.Types()
6
+
7
+ CasNumber = Types::String.constrained(format: /\A\d{2,7}-\d{2}-\d\z/)
8
+ EinecsNumber = Types::String.constrained(format: /\A\d{3}-\d{3}-\d\z/)
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosing
4
+ VERSION = "0.1.0"
5
+ end
data/lib/cosing.rb ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+ require "dry/types"
6
+ require "dry/struct"
7
+ require_relative "cosing/version"
8
+ require_relative "cosing/types"
9
+ require_relative "cosing/ingredient"
10
+ require_relative "cosing/annex"
11
+ require_relative "cosing/database"
12
+
13
+ module Cosing
14
+ module_function
15
+
16
+ class Error < StandardError; end
17
+ # Your code goes here...
18
+
19
+ def load
20
+ Database.new(Annex.load).tap do |database|
21
+ ingredient_file = File.read("data/ingredients.csv").delete("\r")
22
+
23
+ CSV.parse(
24
+ ingredient_file,
25
+ headers: true,
26
+ liberal_parsing: true,
27
+ header_converters: :symbol
28
+ ) do |row|
29
+ row =
30
+ row
31
+ .to_h
32
+ .transform_values(&:to_s)
33
+ .transform_values(&:strip)
34
+
35
+ database.add_ingredient(row)
36
+ end
37
+ end
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cosing
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nolan J Tait
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-01-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-struct
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-types
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.7'
41
+ description: COSING database
42
+ email:
43
+ - nolanjtait@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rspec"
49
+ - ".rubocop.yml"
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - cosing.gemspec
55
+ - data/annex.II.csv
56
+ - data/annex.III.csv
57
+ - data/annex.IV.csv
58
+ - data/annex.V.csv
59
+ - data/annex.VI.csv
60
+ - data/ingredients.csv
61
+ - lib/cosing.rb
62
+ - lib/cosing/annex.rb
63
+ - lib/cosing/annex/base.rb
64
+ - lib/cosing/annex/ii.rb
65
+ - lib/cosing/annex/iii.rb
66
+ - lib/cosing/annex/iv.rb
67
+ - lib/cosing/annex/rule.rb
68
+ - lib/cosing/annex/sccs_opinion.rb
69
+ - lib/cosing/annex/v.rb
70
+ - lib/cosing/annex/vi.rb
71
+ - lib/cosing/database.rb
72
+ - lib/cosing/ingredient.rb
73
+ - lib/cosing/types.rb
74
+ - lib/cosing/version.rb
75
+ homepage: https://github.com/inhouse-work/cosing
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://github.com/inhouse-work/cosing
80
+ source_code_uri: https://github.com/inhouse-work/cosing
81
+ changelog_uri: https://github.com/inhouse-work/cosing
82
+ rubygems_mfa_required: 'true'
83
+ post_install_message:
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.4.10
99
+ signing_key:
100
+ specification_version: 4
101
+ summary: COSING database
102
+ test_files: []