asciichem 0.21.0 → 0.23.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/Gemfile +1 -0
- data/asciichem.gemspec +1 -1
- data/lib/asciichem/cli.rb +89 -5
- data/lib/asciichem/resolver/adapter.rb +98 -0
- data/lib/asciichem/resolver/cache.rb +66 -0
- data/lib/asciichem/resolver/pubchem.rb +96 -0
- data/lib/asciichem/resolver/substance.rb +105 -0
- data/lib/asciichem/resolver.rb +77 -0
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem.rb +1 -0
- data/scripts/update-model-schemas.sh +26 -0
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a97d83231f84e4dde6c03c18f88ae7c34a30779e2550ca3f859e91bc58365f84
|
|
4
|
+
data.tar.gz: dbb27614ede6ba6987f09942465624b8d84dda4041444f63326982d84c365bdd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 47d0e9bf10643d6a7fb24779d1baaaf1382e4c11b1b43375e1543b7cacb5169ea94784db8c1ab70a2f117590966bc96ca4809c994fd83f6295ca63fa56bf3c01
|
|
7
|
+
data.tar.gz: 84386eb43091bbcde85bf7ed8bc428544865bd87f985e2b986d2578c5cb2e25a11d0ef39b880bcd781a5da6b061e4a6501d18a02bd2cf3016da369dd72afd365
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
All notable changes to AsciiChem are documented here.
|
|
4
4
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.23.0] - 2026-09-12
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- `AsciiChem::Resolver` — multi-source identifier resolution (TODO.impl
|
|
10
|
+
38): opt-in adapter registry, one substance per source with
|
|
11
|
+
provenance, cross-check Conflict on InChIKey disagreement, TTL
|
|
12
|
+
cache in the user cache dir.
|
|
13
|
+
- `Resolver::PubChem` — keyless PUG-REST adapter with recorded
|
|
14
|
+
offline fixtures; CAS via the name namespace (TODO.impl 39).
|
|
15
|
+
- CLI `resolve` (networked, cached, `--refresh`) and offline
|
|
16
|
+
`validate` for identifier annotations (TODO.impl 41).
|
|
17
|
+
|
|
18
|
+
## [0.22.0] - 2026-09-12
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- CLI `--from asciichem|smiles|molfile` on `convert`, plus
|
|
22
|
+
`model-json`, `smiles`, `molfile`, and `structural-svg` output
|
|
23
|
+
targets (TODO.impl 60).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Conformance validation uses schemas vendored in `spec/schemas`
|
|
27
|
+
(json_schemer dev dependency); the asciichem-model rubygem
|
|
28
|
+
dependency is removed — the contract repository is no longer
|
|
29
|
+
distributed as a gem (TODO.impl 59).
|
|
30
|
+
|
|
6
31
|
## [0.21.0] - 2026-09-12
|
|
7
32
|
|
|
8
33
|
### Added
|
|
@@ -386,7 +411,9 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
386
411
|
`version`.
|
|
387
412
|
- Comprehensive RSpec suite with round-trip conformance.
|
|
388
413
|
|
|
389
|
-
[Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.
|
|
414
|
+
[Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.23.0...HEAD
|
|
415
|
+
[0.23.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.22.0...v0.23.0
|
|
416
|
+
[0.22.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.21.0...v0.22.0
|
|
390
417
|
[0.21.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.20.0...v0.21.0
|
|
391
418
|
[0.20.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.19.0...v0.20.0
|
|
392
419
|
[0.18.1]: https://github.com/asciichem/asciichem-ruby/compare/v0.18.0...v0.18.1
|
data/Gemfile
CHANGED
data/asciichem.gemspec
CHANGED
data/lib/asciichem/cli.rb
CHANGED
|
@@ -10,16 +10,22 @@ module AsciiChem
|
|
|
10
10
|
# and command banners, matching the executable name.
|
|
11
11
|
package_name "asciichem"
|
|
12
12
|
|
|
13
|
-
desc "convert -i INPUT -t FORMAT", "Convert
|
|
14
|
-
method_option :input, aliases: "-i", type: :string,
|
|
15
|
-
desc: "
|
|
13
|
+
desc "convert -i INPUT -t FORMAT", "Convert INPUT to FORMAT (mathml|text|html|latex|svg|structural-svg|model-json|cml|smiles|molfile)"
|
|
14
|
+
method_option :input, aliases: "-i", type: :string,
|
|
15
|
+
desc: "Source text (or '-' for stdin)"
|
|
16
16
|
method_option :file, aliases: "-f", type: :string,
|
|
17
|
-
desc: "Read
|
|
17
|
+
desc: "Read source from a file"
|
|
18
|
+
method_option :from, type: :string, default: "asciichem",
|
|
19
|
+
desc: "Input grammar: asciichem|smiles|molfile"
|
|
18
20
|
method_option :format, aliases: "-t", type: :string, default: "mathml",
|
|
19
21
|
desc: "Output format"
|
|
20
22
|
def convert
|
|
23
|
+
unless options["input"] || options["file"]
|
|
24
|
+
raise AsciiChem::ParseError, "provide -i INPUT or -f FILE"
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
source = read_source
|
|
22
|
-
formula =
|
|
28
|
+
formula = ingest(source, options[:from])
|
|
23
29
|
puts render(formula, options[:format])
|
|
24
30
|
rescue AsciiChem::ParseError => e
|
|
25
31
|
warn "Parse error: #{e.message}"
|
|
@@ -80,6 +86,61 @@ module AsciiChem
|
|
|
80
86
|
"asciichem #{command.usage}"
|
|
81
87
|
end
|
|
82
88
|
|
|
89
|
+
desc "resolve --cas X | --name X | ...", "Resolve a substance from a source (network; cached)"
|
|
90
|
+
method_option :cas, type: :string, desc: "CAS registry number"
|
|
91
|
+
method_option :name, type: :string, desc: "Substance name"
|
|
92
|
+
method_option :cid, type: :string, desc: "PubChem CID"
|
|
93
|
+
method_option :inchikey, type: :string, desc: "InChIKey"
|
|
94
|
+
method_option :smiles, type: :string, desc: "SMILES"
|
|
95
|
+
method_option :source, type: :string, default: "pubchem", desc: "Resolver source"
|
|
96
|
+
method_option :refresh, type: :boolean, default: false, desc: "Bypass the cache"
|
|
97
|
+
method_option :format, aliases: "-t", type: :string, default: "model-json",
|
|
98
|
+
desc: "Output: model-json | text | smiles"
|
|
99
|
+
def resolve
|
|
100
|
+
convention, value = %i[cas name cid inchikey smiles]
|
|
101
|
+
.filter_map { |k| [k, options[k.to_s]] if options[k.to_s] }
|
|
102
|
+
.first
|
|
103
|
+
raise AsciiChem::Error, "give one of --cas/--name/--cid/--inchikey/--smiles" unless value
|
|
104
|
+
|
|
105
|
+
convention = { cas: "cas", name: "name", cid: "pubchem-cid",
|
|
106
|
+
inchikey: "inchikey", smiles: "smiles" }.fetch(convention)
|
|
107
|
+
substance = AsciiChem::Resolver[options[:source]].new.resolve(
|
|
108
|
+
value: value, convention: convention, refresh: options[:refresh]
|
|
109
|
+
)
|
|
110
|
+
raise AsciiChem::Error, "#{options[:source]} does not know #{value.inspect}" unless substance
|
|
111
|
+
|
|
112
|
+
puts case options[:format].to_s
|
|
113
|
+
when "text" then substance.preferred_name.to_s
|
|
114
|
+
when "smiles" then substance.identifier_value("canonical-smiles").to_s
|
|
115
|
+
else substance.to_model_json
|
|
116
|
+
end
|
|
117
|
+
rescue AsciiChem::Error => e
|
|
118
|
+
warn "Resolve error: #{e.message}"
|
|
119
|
+
exit 3
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
desc "validate -i INPUT", "Offline identifier validation"
|
|
123
|
+
method_option :input, aliases: "-i", type: :string, required: true
|
|
124
|
+
def validate
|
|
125
|
+
formula = AsciiChem.parse(options[:input])
|
|
126
|
+
annotations = formula.nodes.grep(AsciiChem::Model::Molecule).flat_map(&:identifiers)
|
|
127
|
+
if annotations.empty?
|
|
128
|
+
puts "no identifier annotations found"
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
annotations.each do |identifier|
|
|
132
|
+
known = AsciiChem::Identifiers.known?(identifier.convention)
|
|
133
|
+
valid = known && AsciiChem::Identifiers.valid?(identifier.convention, identifier.value)
|
|
134
|
+
status = known ? (valid ? "ok" : "INVALID") : "unknown convention"
|
|
135
|
+
puts format("%-12s %-40s %s", identifier.convention, identifier.value, status)
|
|
136
|
+
end
|
|
137
|
+
exit 1 if annotations.any? { |i| AsciiChem::Identifiers.known?(i.convention) &&
|
|
138
|
+
!AsciiChem::Identifiers.valid?(i.convention, i.value) }
|
|
139
|
+
rescue AsciiChem::ParseError => e
|
|
140
|
+
warn "Parse error: #{e.message}"
|
|
141
|
+
exit 1
|
|
142
|
+
end
|
|
143
|
+
|
|
83
144
|
private
|
|
84
145
|
|
|
85
146
|
def read_source
|
|
@@ -89,8 +150,31 @@ module AsciiChem
|
|
|
89
150
|
options[:input]
|
|
90
151
|
end
|
|
91
152
|
|
|
153
|
+
# One ingestion point per input grammar (TODO.v2 09): every
|
|
154
|
+
# grammar funnels into the same semantic model, so every output
|
|
155
|
+
# format works regardless of the input language.
|
|
156
|
+
def ingest(source, from)
|
|
157
|
+
case from.to_s
|
|
158
|
+
when "asciichem" then AsciiChem.parse(source)
|
|
159
|
+
when "smiles" then AsciiChem.parse_smiles(source)
|
|
160
|
+
when "molfile" then molfile_formula(source)
|
|
161
|
+
else
|
|
162
|
+
raise AsciiChem::ParseError, "unknown --from grammar: #{from}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# parse_molfile returns a single Molecule; wrap it so every
|
|
167
|
+
# formatter's Formula contract holds.
|
|
168
|
+
def molfile_formula(source)
|
|
169
|
+
text = File.file?(source) ? File.read(source) : source
|
|
170
|
+
AsciiChem::Model::Formula.new(nodes: [AsciiChem.parse_molfile(text)])
|
|
171
|
+
end
|
|
172
|
+
|
|
92
173
|
def render(formula, format)
|
|
93
174
|
return formula.to_cml if format.to_sym == :cml
|
|
175
|
+
return formula.to_model_json if format.to_sym == :"model-json"
|
|
176
|
+
return formula.to_smiles if format.to_sym == :smiles
|
|
177
|
+
return formula.nodes.first.to_molfile if format.to_sym == :molfile
|
|
94
178
|
|
|
95
179
|
AsciiChem::Formatter.render(format.to_sym, formula)
|
|
96
180
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Resolver
|
|
5
|
+
# A source adapter. Subclasses register themselves:
|
|
6
|
+
#
|
|
7
|
+
# class MySource < Adapter
|
|
8
|
+
# source_name :mysource
|
|
9
|
+
# supports "name", "inchikey"
|
|
10
|
+
# def fetch_record(...); end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# `resolve` handles caching around `fetch_record`; the transport
|
|
14
|
+
# (`fetch`) is injectable so specs run offline.
|
|
15
|
+
class Adapter
|
|
16
|
+
class << self
|
|
17
|
+
# Declares the source name and registers the adapter (called
|
|
18
|
+
# from the class body — `inherited` fires before the body
|
|
19
|
+
# runs, so it cannot see the subclass configuration).
|
|
20
|
+
def source_name(name = nil)
|
|
21
|
+
return @source_name if name.nil?
|
|
22
|
+
|
|
23
|
+
@source_name = name.to_s
|
|
24
|
+
AsciiChem::Resolver.register(@source_name, self)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def supports(*conventions)
|
|
28
|
+
@supported_conventions = conventions.map(&:to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def supported_conventions
|
|
32
|
+
@supported_conventions || []
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def supports?(convention)
|
|
37
|
+
self.class.supported_conventions.include?(convention.to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Cache-aware resolution. `fetch` must answer #get(url) -> body
|
|
41
|
+
# String (NetFetch by default; specs inject recorded responses).
|
|
42
|
+
def resolve(value:, convention:, fetch: nil, cache: nil, refresh: false)
|
|
43
|
+
cache ||= Cache.default
|
|
44
|
+
fetch ||= NetFetch.new
|
|
45
|
+
key = cache.key_for(self.class.source_name, convention, value)
|
|
46
|
+
|
|
47
|
+
unless refresh
|
|
48
|
+
cached = cache.read(key)
|
|
49
|
+
return cached if cached
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
substance = fetch_record(value: value, convention: convention, fetch: fetch)
|
|
53
|
+
.tap { |s| s&.identifiers&.each { |i| i.provenance ||= default_provenance } }
|
|
54
|
+
cache.write(key, substance) if substance
|
|
55
|
+
substance
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Not-found is nil, not an error — sources legitimately answer
|
|
59
|
+
# "unknown substance" during multi-source resolution.
|
|
60
|
+
def fetch_record(**)
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def default_provenance
|
|
65
|
+
Provenance.new(
|
|
66
|
+
source: self.class.source_name,
|
|
67
|
+
retrieved_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
68
|
+
attribution: attribution
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def attribution
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Minimal stdlib HTTP transport. No runtime gem dependencies.
|
|
78
|
+
class NetFetch
|
|
79
|
+
def get(url, limit = 3)
|
|
80
|
+
raise Error, "too many redirects" if limit.zero?
|
|
81
|
+
|
|
82
|
+
uri = URI(url)
|
|
83
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
84
|
+
open_timeout: 5, read_timeout: 10) do |http|
|
|
85
|
+
http.request(Net::HTTP::Get.new(uri.request_uri, "User-Agent" => "asciichem-resolver"))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
case response
|
|
89
|
+
when Net::HTTPSuccess then response.body
|
|
90
|
+
when Net::HTTPRedirection then get(URI.join(uri, response["location"]).to_s, limit - 1)
|
|
91
|
+
when Net::HTTPNotFound then nil
|
|
92
|
+
else
|
|
93
|
+
raise Error, "HTTP #{response.code}: #{response.message}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module AsciiChem
|
|
8
|
+
module Resolver
|
|
9
|
+
# Resolution cache in the USER cache dir (never the gem install
|
|
10
|
+
# dir): entries carry source, retrieved-at, attribution, TTL.
|
|
11
|
+
class Cache
|
|
12
|
+
DEFAULT_TTL = 7 * 24 * 60 * 60 # chemistry identifiers are stable
|
|
13
|
+
|
|
14
|
+
attr_reader :dir
|
|
15
|
+
|
|
16
|
+
def self.default_dir
|
|
17
|
+
base = ENV.fetch("XDG_CACHE_HOME", nil) || File.join(Dir.home, ".cache")
|
|
18
|
+
File.join(base, "asciichem", "resolver")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(dir: self.class.default_dir, ttl: DEFAULT_TTL)
|
|
22
|
+
@dir = dir
|
|
23
|
+
@ttl = ttl
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.default
|
|
27
|
+
@default ||= new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def key_for(source, convention, value)
|
|
31
|
+
Digest::SHA256.hexdigest("#{source}:#{convention}:#{value}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def read(key)
|
|
35
|
+
path = File.join(dir, "#{key}.json")
|
|
36
|
+
return nil unless File.file?(path)
|
|
37
|
+
|
|
38
|
+
entry = JSON.parse(File.read(path))
|
|
39
|
+
return nil if stale?(entry)
|
|
40
|
+
|
|
41
|
+
Substance.from_model_json(JSON.generate(entry["substance"]))
|
|
42
|
+
rescue JSON::ParserError, StandardError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def write(key, substance)
|
|
47
|
+
FileUtils.mkdir_p(dir)
|
|
48
|
+
File.write(File.join(dir, "#{key}.json"), JSON.pretty_generate(
|
|
49
|
+
cached_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
50
|
+
ttl_seconds: @ttl,
|
|
51
|
+
substance: JSON.parse(substance.to_model_json)
|
|
52
|
+
))
|
|
53
|
+
substance
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def stale?(entry)
|
|
59
|
+
cached_at = Time.parse(entry["cached_at"]) rescue nil
|
|
60
|
+
return true unless cached_at
|
|
61
|
+
|
|
62
|
+
Time.now.utc - cached_at > entry.fetch("ttl_seconds", @ttl)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/https"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module AsciiChem
|
|
9
|
+
module Resolver
|
|
10
|
+
# PubChem source adapter (TODO.impl 39; TODO.v2 07 Layer 2).
|
|
11
|
+
# PubChem is the default source: keyless PUG-REST, permissive
|
|
12
|
+
# terms, and the bulk mirror of Common Chemistry's validated CAS
|
|
13
|
+
# RNs (CAS numbers are indexed as names). Politeness: one request
|
|
14
|
+
# per resolution, 5/10s timeouts, no retries — PubChem asks for
|
|
15
|
+
# max 5 requests/second.
|
|
16
|
+
#
|
|
17
|
+
# Citation profile (TODO.v2 08): publisher NCBI, per-access
|
|
18
|
+
# versioning (retrievedAt provenance), compound link
|
|
19
|
+
# https://pubchem.ncbi.nlm.nih.gov/compound/{cid}.
|
|
20
|
+
class PubChem < Adapter
|
|
21
|
+
source_name :pubchem
|
|
22
|
+
supports "name", "cas", "pubchem-cid", "inchikey", "inchi", "smiles", "canonical-smiles"
|
|
23
|
+
|
|
24
|
+
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound"
|
|
25
|
+
PROPERTIES = %w[CanonicalSMILES IsomericSMILES MolecularFormula
|
|
26
|
+
MolecularWeight IUPACName InChI InChIKey].freeze
|
|
27
|
+
private_constant :BASE, :PROPERTIES
|
|
28
|
+
|
|
29
|
+
def fetch_record(value:, convention:, fetch:)
|
|
30
|
+
body = fetch.get(url_for(value, convention))
|
|
31
|
+
return nil unless body
|
|
32
|
+
|
|
33
|
+
props = JSON.parse(body).dig("PropertyTable", "Properties", 0)
|
|
34
|
+
return nil unless props
|
|
35
|
+
|
|
36
|
+
substance_from(props)
|
|
37
|
+
rescue JSON::ParserError => e
|
|
38
|
+
raise Error, "pubchem returned malformed JSON: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def attribution
|
|
42
|
+
"PubChem, U.S. National Library of Medicine"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# CAS numbers are indexed as PubChem names (Common Chemistry
|
|
48
|
+
# mirror), so both map to the name namespace.
|
|
49
|
+
def url_for(value, convention)
|
|
50
|
+
namespace = { "name" => "name", "cas" => "name", "pubchem-cid" => "cid",
|
|
51
|
+
"inchikey" => "inchikey", "inchi" => "inchi",
|
|
52
|
+
"smiles" => "smiles", "canonical-smiles" => "smiles" }.fetch(convention.to_s)
|
|
53
|
+
"#{BASE}/#{namespace}/#{CGI.escape(value.to_s)}/property/#{PROPERTIES.join(',')}/JSON"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def substance_from(props)
|
|
57
|
+
provenance = default_provenance
|
|
58
|
+
identifiers = []
|
|
59
|
+
identifiers << Identifier.new(value: props["CID"].to_s, convention: "pubchem-cid", provenance: provenance) if props["CID"]
|
|
60
|
+
add_identifier(identifiers, props, "InChIKey", "inchikey", provenance)
|
|
61
|
+
add_identifier(identifiers, props, "InChI", "inchi", provenance)
|
|
62
|
+
add_identifier(identifiers, props, "CanonicalSMILES", "canonical-smiles", provenance)
|
|
63
|
+
|
|
64
|
+
Substance.new(
|
|
65
|
+
preferred_name: props["IUPACName"],
|
|
66
|
+
formula: props["MolecularFormula"],
|
|
67
|
+
molecular_weight: props["MolecularWeight"]&.to_f,
|
|
68
|
+
identifiers: identifiers,
|
|
69
|
+
properties: [
|
|
70
|
+
Property.new(name: "molecular-formula", value: props["MolecularFormula"], provenance: provenance),
|
|
71
|
+
Property.new(name: "molecular-weight", value: props["MolecularWeight"],
|
|
72
|
+
units: "g/mol", provenance: provenance)
|
|
73
|
+
].compact,
|
|
74
|
+
# Best effort: PubChem canonical SMILES may use constructs
|
|
75
|
+
# outside the supported subset (stereo); then the SMILES
|
|
76
|
+
# stays as an identifier and structure is nil — no silent loss.
|
|
77
|
+
structure: parse_structure(props["CanonicalSMILES"])
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def add_identifier(list, props, key, convention, provenance)
|
|
82
|
+
return unless props[key]
|
|
83
|
+
|
|
84
|
+
list << Identifier.new(value: props[key], convention: convention, provenance: provenance)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_structure(smiles)
|
|
88
|
+
return nil unless smiles
|
|
89
|
+
|
|
90
|
+
AsciiChem.parse_smiles(smiles).nodes.first
|
|
91
|
+
rescue AsciiChem::ParseError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Resolver
|
|
5
|
+
# Domain value objects for resolution results. These are data
|
|
6
|
+
# carriers; (de)serialization to the canonical wire form goes
|
|
7
|
+
# through the lutaml-model Wire classes only.
|
|
8
|
+
Provenance = Struct.new(:source, :retrieved_at, :source_version, :attribution, keyword_init: true)
|
|
9
|
+
Identifier = Struct.new(:value, :convention, :provenance, keyword_init: true)
|
|
10
|
+
Property = Struct.new(:name, :value, :units, :provenance, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# A resolved substance: identity + provenance + best-effort
|
|
13
|
+
# structure. `structure` is a Model::Molecule when the source's
|
|
14
|
+
# SMILES parses within the supported subset; otherwise nil and the
|
|
15
|
+
# SMILES stays available as an identifier — nothing is dropped.
|
|
16
|
+
class Substance
|
|
17
|
+
attr_reader :preferred_name, :synonyms, :formula, :molecular_weight,
|
|
18
|
+
:identifiers, :properties, :structure
|
|
19
|
+
|
|
20
|
+
def initialize(preferred_name: nil, synonyms: [], formula: nil,
|
|
21
|
+
molecular_weight: nil, identifiers: [], properties: [],
|
|
22
|
+
structure: nil)
|
|
23
|
+
@preferred_name = preferred_name
|
|
24
|
+
@synonyms = synonyms
|
|
25
|
+
@formula = formula
|
|
26
|
+
@molecular_weight = molecular_weight
|
|
27
|
+
@identifiers = identifiers
|
|
28
|
+
@properties = properties
|
|
29
|
+
@structure = structure
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def identifier_value(convention)
|
|
33
|
+
identifiers.find { |i| i.convention == convention }&.value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def provenance
|
|
37
|
+
identifiers.first&.provenance
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Canonical wire form (asciichem-model v1 substance-record).
|
|
41
|
+
def to_model_json
|
|
42
|
+
Wire::SubstanceRecord.new(
|
|
43
|
+
type: "substance-record",
|
|
44
|
+
preferred_name: preferred_name,
|
|
45
|
+
synonyms: synonyms,
|
|
46
|
+
formula: formula,
|
|
47
|
+
molecular_weight: molecular_weight,
|
|
48
|
+
identifiers: identifiers.map do |i|
|
|
49
|
+
Wire::ProvenancedIdentifier.new(
|
|
50
|
+
identifier: Wire::Identifier.new(type: "identifier", value: i.value, convention: i.convention),
|
|
51
|
+
provenance: provenance_wire(i.provenance)
|
|
52
|
+
)
|
|
53
|
+
end,
|
|
54
|
+
properties: properties.map do |p|
|
|
55
|
+
Wire::ProvenancedProperty.new(
|
|
56
|
+
name: p.name, value: p.value, units: p.units,
|
|
57
|
+
provenance: provenance_wire(p.provenance)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
).to_json
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Rebuilds a Substance from the wire form (the cache path).
|
|
64
|
+
def self.from_model_json(json)
|
|
65
|
+
wire = Wire::SubstanceRecord.from_json(json)
|
|
66
|
+
new(
|
|
67
|
+
preferred_name: wire.preferred_name,
|
|
68
|
+
synonyms: Array(wire.synonyms),
|
|
69
|
+
formula: wire.formula,
|
|
70
|
+
molecular_weight: wire.molecular_weight,
|
|
71
|
+
identifiers: Array(wire.identifiers).map do |pi|
|
|
72
|
+
Identifier.new(
|
|
73
|
+
value: pi.identifier&.value,
|
|
74
|
+
convention: pi.identifier&.convention,
|
|
75
|
+
provenance: provenance_domain(pi.provenance)
|
|
76
|
+
)
|
|
77
|
+
end,
|
|
78
|
+
properties: Array(wire.properties).map do |p|
|
|
79
|
+
Property.new(name: p.name, value: p.value, units: p.units,
|
|
80
|
+
provenance: provenance_domain(p.provenance))
|
|
81
|
+
end
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.provenance_domain(wire)
|
|
86
|
+
return nil unless wire
|
|
87
|
+
|
|
88
|
+
Provenance.new(source: wire.source, retrieved_at: wire.retrieved_at,
|
|
89
|
+
source_version: wire.source_version, attribution: wire.attribution)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def provenance_wire(p)
|
|
95
|
+
return nil unless p
|
|
96
|
+
|
|
97
|
+
Wire::Provenance.new(
|
|
98
|
+
type: "provenance",
|
|
99
|
+
source: p.source, retrieved_at: p.retrieved_at,
|
|
100
|
+
source_version: p.source_version, attribution: p.attribution
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "asciichem/errors"
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
# Resolution turns identifiers into substance records (TODO.v2 07
|
|
7
|
+
# Layer 2; TODO.impl 38). Multi-source by design: every answering
|
|
8
|
+
# source produces its own record with provenance, because the
|
|
9
|
+
# citation track (TODO.v2 08) emits one bibitem per (substance,
|
|
10
|
+
# source) the user chooses to cite.
|
|
11
|
+
#
|
|
12
|
+
# Sources are opt-in adapters that self-register (OCP, same pattern
|
|
13
|
+
# as the linter checks). NC-licensed sources are never registered by
|
|
14
|
+
# default — Common Chemistry ships only after the maintainer
|
|
15
|
+
# sign-off recorded in TODO.v2 07.
|
|
16
|
+
module Resolver
|
|
17
|
+
autoload :Adapter, "asciichem/resolver/adapter"
|
|
18
|
+
autoload :Cache, "asciichem/resolver/cache"
|
|
19
|
+
autoload :PubChem, "asciichem/resolver/pubchem"
|
|
20
|
+
autoload :Substance, "asciichem/resolver/substance"
|
|
21
|
+
|
|
22
|
+
# Raised when sources disagree on substance identity (cross-check
|
|
23
|
+
# mode). The message lists the disagreement — never hidden.
|
|
24
|
+
class Conflict < AsciiChem::Error; end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def adapters
|
|
28
|
+
@adapters ||= {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def register(name, adapter)
|
|
32
|
+
adapters[name.to_s] = adapter
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def [](name)
|
|
36
|
+
adapters.fetch(name.to_s)
|
|
37
|
+
rescue KeyError
|
|
38
|
+
raise Error, "unknown resolver source #{name.inspect} (registered: #{adapters.keys.sort.join(', ')})"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolves against every opt-in source, returning one Substance
|
|
42
|
+
# per source that answers (nil entries dropped). Sources answer
|
|
43
|
+
# in registration order.
|
|
44
|
+
def resolve_all(value:, convention:, sources: nil, fetch: nil, cache: nil, refresh: false)
|
|
45
|
+
selected = sources ? Array(sources).map { |s| self[s] } : adapters.values
|
|
46
|
+
selected.filter_map do |adapter_class|
|
|
47
|
+
adapter = adapter_class.new
|
|
48
|
+
next unless adapter.supports?(convention)
|
|
49
|
+
|
|
50
|
+
adapter.resolve(value: value, convention: convention, fetch: fetch, cache: cache, refresh: refresh)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# First answering source wins.
|
|
55
|
+
def resolve(**args)
|
|
56
|
+
resolve_all(**args).first
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Cross-check mode: all sources must agree on the primary key
|
|
60
|
+
# (InChIKey) when more than one source provides one.
|
|
61
|
+
def resolve!(**args)
|
|
62
|
+
substances = resolve_all(**args)
|
|
63
|
+
keys = substances.filter_map { |s| s.identifier_value("inchikey") }.uniq
|
|
64
|
+
if keys.length > 1
|
|
65
|
+
raise Conflict,
|
|
66
|
+
"sources disagree on substance identity (InChIKeys: #{keys.join(' vs ')})"
|
|
67
|
+
end
|
|
68
|
+
substances.first
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Open, permissive sources register eagerly; opt-in NC sources do
|
|
73
|
+
# not self-register (registered manually after sign-off).
|
|
74
|
+
adapters # ensure the registry exists before adapter files load
|
|
75
|
+
constants.each { |c| const_get(c) unless c == :Conflict }
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/asciichem/version.rb
CHANGED
data/lib/asciichem.rb
CHANGED
|
@@ -25,6 +25,7 @@ module AsciiChem
|
|
|
25
25
|
autoload :Molfile, "asciichem/molfile"
|
|
26
26
|
autoload :Parser, "asciichem/parser"
|
|
27
27
|
autoload :PeriodicTable, "asciichem/periodic_table"
|
|
28
|
+
autoload :Resolver, "asciichem/resolver"
|
|
28
29
|
autoload :RingBonds, "asciichem/ring_bonds"
|
|
29
30
|
autoload :Smiles, "asciichem/smiles"
|
|
30
31
|
autoload :Structure, "asciichem/structure"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Re-vendor the asciichem-model v1 JSON Schemas (YAML) and examples.
|
|
3
|
+
# Usage: scripts/update-model-schemas.sh [model-tag] (default: main).
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
tag="${1:-main}"
|
|
7
|
+
work="$(mktemp -d)"
|
|
8
|
+
trap 'rm -rf "$work"' EXIT
|
|
9
|
+
|
|
10
|
+
git clone --depth 1 --branch "$tag" https://github.com/asciichem/asciichem-model.git "$work/model"
|
|
11
|
+
|
|
12
|
+
python3 - "$work/model" <<'PY'
|
|
13
|
+
import os, shutil, sys
|
|
14
|
+
src = os.path.join(sys.argv[1], "schemas", "v1")
|
|
15
|
+
dst = "spec/schemas"
|
|
16
|
+
if os.path.exists(dst):
|
|
17
|
+
shutil.rmtree(dst)
|
|
18
|
+
os.makedirs(dst)
|
|
19
|
+
for name in os.listdir(src):
|
|
20
|
+
if name.endswith(".yaml"):
|
|
21
|
+
shutil.copy(os.path.join(src, name), os.path.join(dst, name))
|
|
22
|
+
shutil.copytree(os.path.join(src, "examples"), os.path.join(dst, "examples"))
|
|
23
|
+
print("vendored", len([f for f in os.listdir(dst) if f.endswith('.yaml')]), "schemas")
|
|
24
|
+
PY
|
|
25
|
+
|
|
26
|
+
echo "spec/schemas updated from asciichem-model $tag"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asciichem
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.23.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -114,19 +114,19 @@ dependencies:
|
|
|
114
114
|
- !ruby/object:Gem::Version
|
|
115
115
|
version: '1.3'
|
|
116
116
|
- !ruby/object:Gem::Dependency
|
|
117
|
-
name:
|
|
117
|
+
name: json_schemer
|
|
118
118
|
requirement: !ruby/object:Gem::Requirement
|
|
119
119
|
requirements:
|
|
120
120
|
- - "~>"
|
|
121
121
|
- !ruby/object:Gem::Version
|
|
122
|
-
version:
|
|
122
|
+
version: '2.4'
|
|
123
123
|
type: :development
|
|
124
124
|
prerelease: false
|
|
125
125
|
version_requirements: !ruby/object:Gem::Requirement
|
|
126
126
|
requirements:
|
|
127
127
|
- - "~>"
|
|
128
128
|
- !ruby/object:Gem::Version
|
|
129
|
-
version:
|
|
129
|
+
version: '2.4'
|
|
130
130
|
description: AsciiChem is an ASCII syntax for representing chemical formulae, reactions,
|
|
131
131
|
electron configurations, bonds, crystals, spectra, calculations, Z-matrices, and
|
|
132
132
|
reaction mechanisms. It parses to a semantic model and renders to MathML, HTML,
|
|
@@ -228,6 +228,11 @@ files:
|
|
|
228
228
|
- lib/asciichem/molfile/writer.rb
|
|
229
229
|
- lib/asciichem/parser.rb
|
|
230
230
|
- lib/asciichem/periodic_table.rb
|
|
231
|
+
- lib/asciichem/resolver.rb
|
|
232
|
+
- lib/asciichem/resolver/adapter.rb
|
|
233
|
+
- lib/asciichem/resolver/cache.rb
|
|
234
|
+
- lib/asciichem/resolver/pubchem.rb
|
|
235
|
+
- lib/asciichem/resolver/substance.rb
|
|
231
236
|
- lib/asciichem/ring_bonds.rb
|
|
232
237
|
- lib/asciichem/smiles.rb
|
|
233
238
|
- lib/asciichem/smiles/parser.rb
|
|
@@ -245,6 +250,7 @@ files:
|
|
|
245
250
|
- lib/asciichem/wire/identity.rb
|
|
246
251
|
- lib/asciichem/wire_adapter.rb
|
|
247
252
|
- lib/asciichem/xml_builder.rb
|
|
253
|
+
- scripts/update-model-schemas.sh
|
|
248
254
|
homepage: https://www.asciichem.org
|
|
249
255
|
licenses:
|
|
250
256
|
- BSD-2-Clause
|