cosing 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []