Skip to content

Parsing and models

thermoml_io.parser

Safe, namespace-aware parsing of ThermoML XML documents.

The implementation follows the public IUPAC ThermoML schema directly. It is independent of third-party ThermoML Python implementations.

validate_xml_schema(source: XMLSource, schema: str | Path) -> None

Validate a ThermoML XML source against an explicit local XSD.

The package intentionally does not fetch a mutable schema URL implicitly. Callers should pin and checksum the schema used by their workflow.

Source code in src/thermoml_io/parser.py
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
def validate_xml_schema(source: XMLSource, schema: str | Path) -> None:
    """Validate a ThermoML XML source against an explicit local XSD.

    The package intentionally does not fetch a mutable schema URL implicitly.
    Callers should pin and checksum the schema used by their workflow.
    """
    data, _ = _read_source(source)
    try:
        import xmlschema

        validator = xmlschema.XMLSchema(str(schema))
        validator.validate(io.BytesIO(data))
    except Exception as exc:
        raise ThermoMLValidationError(
            f"ThermoML document does not validate against {schema!s}: {exc}"
        ) from exc

parse_thermoml(source: XMLSource, *, source_label: str | None = None, retrieved_at: datetime | None = None, schema: str | Path | None = None) -> ThermoMLDocument

Parse a ThermoML XML document into immutable scientific objects.

Parameters:

Name Type Description Default
source XMLSource

XML bytes, XML text, a local path, or a binary file object.

required
source_label str | None

Provenance locator overriding an inferred local path.

None
retrieved_at datetime | None

Retrieval timestamp for network-originated bytes.

None
schema str | Path | None

Optional explicit XSD path. The parser never downloads a mutable schema implicitly.

None

Returns:

Type Description
ThermoMLDocument

Parsed document with SHA-256 provenance and resolved local references.

Raises:

Type Description
ThermoMLParseError

If required XML structure or numeric fields are malformed.

ThermoMLValidationError

If XSD or semantic reference validation fails.

Notes

This parser is an original implementation of the IUPAC ThermoML schema. It currently decodes PureOrMixtureData fully. ReactionData entries are counted and reported as unsupported warnings rather than silently represented as mixture data.

References

M. Frenkel et al., "XML-based IUPAC standard for experimental, predicted, and critically evaluated thermodynamic property data storage and capture", Pure Appl. Chem. 78 (2006) 541-612. DOI: 10.1351/pac200678030541.

Source code in src/thermoml_io/parser.py
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
def parse_thermoml(
    source: XMLSource,
    *,
    source_label: str | None = None,
    retrieved_at: datetime | None = None,
    schema: str | Path | None = None,
) -> ThermoMLDocument:
    """Parse a ThermoML XML document into immutable scientific objects.

    Parameters
    ----------
    source:
        XML bytes, XML text, a local path, or a binary file object.
    source_label:
        Provenance locator overriding an inferred local path.
    retrieved_at:
        Retrieval timestamp for network-originated bytes.
    schema:
        Optional explicit XSD path. The parser never downloads a mutable schema
        implicitly.

    Returns
    -------
    ThermoMLDocument
        Parsed document with SHA-256 provenance and resolved local references.

    Raises
    ------
    ThermoMLParseError
        If required XML structure or numeric fields are malformed.
    ThermoMLValidationError
        If XSD or semantic reference validation fails.

    Notes
    -----
    This parser is an original implementation of the IUPAC ThermoML schema.
    It currently decodes ``PureOrMixtureData`` fully. ``ReactionData`` entries
    are counted and reported as unsupported warnings rather than silently
    represented as mixture data.

    References
    ----------
    M. Frenkel et al., "XML-based IUPAC standard for experimental, predicted,
    and critically evaluated thermodynamic property data storage and capture",
    Pure Appl. Chem. 78 (2006) 541-612. DOI: 10.1351/pac200678030541.
    """
    data, inferred_label = _read_source(source)
    if schema is not None:
        validate_xml_schema(data, schema)
    try:
        root = SafeElementTree.fromstring(data)
    except (SafeElementTree.ParseError, DefusedXmlException) as exc:
        raise ThermoMLParseError(f"Invalid or unsafe ThermoML XML: {exc}") from exc
    return _document_from_root(
        root,
        provenance=SourceProvenance(
            locator=source_label or inferred_label,
            sha256=hashlib.sha256(data).hexdigest(),
            retrieved_at=retrieved_at,
        ),
    )

parse_thermoml_json(source: JSONSource, *, source_label: str | None = None, retrieved_at: datetime | None = None, recovery: SourceRecovery | None = None) -> ThermoMLDocument

Parse the official NIST JSON representation of a ThermoML document.

NIST JSON objects carry tml_elements lists that preserve XML element ordering. The parser reconstructs an in-memory ThermoML tree and sends it through the same semantic decoder and reference validation used for XML. The source SHA-256 always describes the exact JSON bytes parsed.

Notes

JSON numbers are loaded directly as :class:~decimal.Decimal, without a binary floating-point round trip. Nevertheless, the JSON representation may not preserve the exact lexical decimal spelling used in related XML. This limitation is also recorded in ThermoMLDocument.warnings.

Source code in src/thermoml_io/parser.py
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
def parse_thermoml_json(
    source: JSONSource,
    *,
    source_label: str | None = None,
    retrieved_at: datetime | None = None,
    recovery: SourceRecovery | None = None,
) -> ThermoMLDocument:
    """Parse the official NIST JSON representation of a ThermoML document.

    NIST JSON objects carry ``tml_elements`` lists that preserve XML element
    ordering. The parser reconstructs an in-memory ThermoML tree and sends it
    through the same semantic decoder and reference validation used for XML.
    The source SHA-256 always describes the exact JSON bytes parsed.

    Notes
    -----
    JSON numbers are loaded directly as :class:`~decimal.Decimal`, without a
    binary floating-point round trip. Nevertheless, the JSON representation
    may not preserve the exact lexical decimal spelling used in related XML.
    This limitation is also recorded in ``ThermoMLDocument.warnings``.
    """
    data, inferred_label = _read_json_source(source)
    try:
        decoded = json.loads(
            data,
            parse_float=Decimal,
            parse_constant=_reject_json_constant,
        )
    except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
        raise ThermoMLParseError(f"Invalid official ThermoML JSON: {exc}") from exc
    if not isinstance(decoded, dict):
        raise ThermoMLParseError("Official ThermoML JSON root must be an object.")
    value = cast(dict[str, Any], decoded)
    root = Element(f"{{{THERMOML_NAMESPACE}}}DataReport")
    _json_object_children(root, value, context="DataReport")
    related_md5 = value.get("THERMOML_MD5_CHECKSUM")
    if related_md5 is not None and (
        not isinstance(related_md5, str) or re.fullmatch(r"[0-9a-fA-F]{32}", related_md5) is None
    ):
        raise ThermoMLParseError("THERMOML_MD5_CHECKSUM must be a 32-digit hex value.")
    return _document_from_root(
        root,
        provenance=SourceProvenance(
            locator=source_label or inferred_label,
            sha256=hashlib.sha256(data).hexdigest(),
            retrieved_at=retrieved_at,
            media_type="application/json",
            related_xml_md5=related_md5.casefold() if related_md5 else None,
            recovery=recovery,
        ),
        extra_warnings=(_JSON_LEXICAL_WARNING,),
    )

load_thermoml_url(url: str, *, timeout: float = 30.0, max_bytes: int = DEFAULT_MAX_DOWNLOAD_BYTES, schema: str | Path | None = None, json_fallback: Literal['never', 'on_xml_error'] = 'on_xml_error') -> ThermoMLDocument

Download and parse one HTTPS ThermoML document with size limits.

Remote bytes are held in memory and are not persisted by this function. The source URL, checksum, and UTC retrieval time are stored as provenance.

Source code in src/thermoml_io/parser.py
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
def load_thermoml_url(
    url: str,
    *,
    timeout: float = 30.0,
    max_bytes: int = DEFAULT_MAX_DOWNLOAD_BYTES,
    schema: str | Path | None = None,
    json_fallback: Literal["never", "on_xml_error"] = "on_xml_error",
) -> ThermoMLDocument:
    """Download and parse one HTTPS ThermoML document with size limits.

    Remote bytes are held in memory and are not persisted by this function.
    The source URL, checksum, and UTC retrieval time are stored as provenance.
    """
    if json_fallback not in {"never", "on_xml_error"}:
        raise ValueError("json_fallback must be 'never' or 'on_xml_error'.")
    parsed = urlparse(url)
    if parsed.scheme != "https":
        raise ThermoMLDownloadError("Only HTTPS ThermoML URLs are accepted.")
    request = Request(url, headers={"User-Agent": "thermoml-io/0.1"})
    try:
        with urlopen(request, timeout=timeout) as response:
            declared_length = response.headers.get("Content-Length")
            if declared_length and int(declared_length) > max_bytes:
                raise ThermoMLDownloadError(
                    f"Remote document declares {declared_length} bytes; limit is {max_bytes}."
                )
            data = response.read(max_bytes + 1)
    except (HTTPError, URLError, TimeoutError, ValueError) as exc:
        raise ThermoMLDownloadError(f"Could not download {url!r}: {exc}") from exc
    if len(data) > max_bytes:
        raise ThermoMLDownloadError(
            f"Remote document exceeded the {max_bytes}-byte download limit."
        )
    retrieved_at = datetime.now(UTC)
    try:
        return parse_thermoml(
            data,
            source_label=url,
            retrieved_at=retrieved_at,
            schema=schema,
        )
    except ThermoMLParseError as xml_error:
        if json_fallback == "never" or not parsed.path.casefold().endswith(".xml"):
            raise
        json_url = parsed._replace(path=f"{parsed.path[:-4]}.json").geturl()
        recovery = SourceRecovery(
            strategy="paired-nist-json",
            failed_locator=url,
            failed_sha256=hashlib.sha256(data).hexdigest(),
            failed_media_type="application/xml",
            failure_type=type(xml_error).__name__,
            failure_message=str(xml_error),
        )
        try:
            document = load_thermoml_json_url(
                json_url,
                timeout=timeout,
                max_bytes=max_bytes,
                recovery=recovery,
            )
            xml_md5 = hashlib.md5(data, usedforsecurity=False).hexdigest()
            if document.provenance.related_xml_md5 != xml_md5:
                raise ThermoMLParseError(
                    "Paired official JSON does not report the MD5 checksum of the failed XML bytes."
                )
            return document
        except ThermoMLError as json_error:
            raise ThermoMLParseError(
                f"XML failed ({xml_error}); paired official JSON also failed ({json_error})."
            ) from json_error

load_thermoml_json_url(url: str, *, timeout: float = 30.0, max_bytes: int = DEFAULT_MAX_DOWNLOAD_BYTES, recovery: SourceRecovery | None = None) -> ThermoMLDocument

Download and parse one official NIST ThermoML JSON document safely.

Source code in src/thermoml_io/parser.py
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
def load_thermoml_json_url(
    url: str,
    *,
    timeout: float = 30.0,
    max_bytes: int = DEFAULT_MAX_DOWNLOAD_BYTES,
    recovery: SourceRecovery | None = None,
) -> ThermoMLDocument:
    """Download and parse one official NIST ThermoML JSON document safely."""
    parsed = urlparse(url)
    if parsed.scheme != "https":
        raise ThermoMLDownloadError("Only HTTPS ThermoML URLs are accepted.")
    request = Request(
        url,
        headers={"Accept": "application/json", "User-Agent": "thermoml-io/0.1"},
    )
    try:
        with urlopen(request, timeout=timeout) as response:
            declared_length = response.headers.get("Content-Length")
            if declared_length and int(declared_length) > max_bytes:
                raise ThermoMLDownloadError(
                    f"Remote document declares {declared_length} bytes; limit is {max_bytes}."
                )
            data = response.read(max_bytes + 1)
    except (HTTPError, URLError, TimeoutError, ValueError) as exc:
        raise ThermoMLDownloadError(f"Could not download {url!r}: {exc}") from exc
    if len(data) > max_bytes:
        raise ThermoMLDownloadError(
            f"Remote document exceeded the {max_bytes}-byte download limit."
        )
    return parse_thermoml_json(
        data,
        source_label=url,
        retrieved_at=datetime.now(UTC),
        recovery=recovery,
    )

thermoml_io.models

Immutable scientific data model for ThermoML documents.

The model preserves publication, sample, experimental, phase, and uncertainty metadata without forcing heterogeneous ThermoML properties into a single rectangular representation. Tabular views are constructed separately.

SourceRecovery dataclass

Audit record for recovery from an unreadable primary serialization.

The successful replacement remains the source described by :class:SourceProvenance. These fields retain the failed source so a downstream table never hides that recovery occurred.

Source code in src/thermoml_io/models.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@dataclass(frozen=True, slots=True)
class SourceRecovery:
    """Audit record for recovery from an unreadable primary serialization.

    The successful replacement remains the source described by
    :class:`SourceProvenance`. These fields retain the failed source so a
    downstream table never hides that recovery occurred.
    """

    strategy: str
    failed_locator: str | None
    failed_sha256: str
    failed_media_type: str
    failure_type: str
    failure_message: str
    lexical_numeric_representation_preserved: bool = False

SourceProvenance dataclass

Provenance of the exact serialized source parsed by the library.

Parameters:

Name Type Description Default
locator str | None

Local path, URL, persistent identifier, or user-supplied label. It may be absent for in-memory documents.

required
sha256 str

SHA-256 digest of the original source bytes.

required
retrieved_at datetime | None

UTC timestamp recorded by the network loader, when applicable.

None
media_type str

Media type of the original serialization.

'application/xml'
related_xml_md5 str | None

NIST-provided MD5 checksum of the related XML representation, when reported by an official JSON document. This is a relationship field, not the integrity digest used for the parsed JSON bytes.

None
recovery SourceRecovery | None

Explicit audit record when this source replaced an unreadable primary serialization.

None
Source code in src/thermoml_io/models.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@dataclass(frozen=True, slots=True)
class SourceProvenance:
    """Provenance of the exact serialized source parsed by the library.

    Parameters
    ----------
    locator:
        Local path, URL, persistent identifier, or user-supplied label. It may
        be absent for in-memory documents.
    sha256:
        SHA-256 digest of the original source bytes.
    retrieved_at:
        UTC timestamp recorded by the network loader, when applicable.
    media_type:
        Media type of the original serialization.
    related_xml_md5:
        NIST-provided MD5 checksum of the related XML representation, when
        reported by an official JSON document. This is a relationship field,
        not the integrity digest used for the parsed JSON bytes.
    recovery:
        Explicit audit record when this source replaced an unreadable primary
        serialization.
    """

    locator: str | None
    sha256: str
    retrieved_at: datetime | None = None
    media_type: str = "application/xml"
    related_xml_md5: str | None = None
    recovery: SourceRecovery | None = None

Citation dataclass

Bibliographic metadata reported in a ThermoML document.

Source code in src/thermoml_io/models.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@dataclass(frozen=True, slots=True)
class Citation:
    """Bibliographic metadata reported in a ThermoML document."""

    authors: tuple[str, ...] = ()
    title: str | None = None
    publication_name: str | None = None
    year: int | None = None
    date: str | None = None
    volume: str | None = None
    pages: str | None = None
    doi: str | None = None
    url: str | None = None
    document_type: str | None = None
    source_type: str | None = None
    document_origin: str | None = None
    abstract: str | None = None
    keywords: tuple[str, ...] = ()
    language: str | None = None
    trc_reference_id: str | None = None

    @property
    def normalized_doi(self) -> str | None:
        """Return a lower-case DOI without a resolver URL prefix."""
        if self.doi is None:
            return None
        doi = self.doi.strip()
        for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
            if doi.lower().startswith(prefix):
                doi = doi[len(prefix) :]
                break
        return doi.lower()

normalized_doi: str | None property

Return a lower-case DOI without a resolver URL prefix.

PurityAssessment dataclass

One reported purification or purity-assessment step for a sample.

Source code in src/thermoml_io/models.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
@dataclass(frozen=True, slots=True)
class PurityAssessment:
    """One reported purification or purity-assessment step for a sample."""

    step: int | None = None
    purification_methods: tuple[str, ...] = ()
    analysis_methods: tuple[str, ...] = ()
    mole_percent: Decimal | None = None
    mole_percent_digits: int | None = None
    mass_percent: Decimal | None = None
    mass_percent_digits: int | None = None
    volume_percent: Decimal | None = None
    volume_percent_digits: int | None = None
    unspecified_percent: Decimal | None = None

Sample dataclass

Metadata for one material sample used by an experiment.

Source code in src/thermoml_io/models.py
118
119
120
121
122
123
124
125
@dataclass(frozen=True, slots=True)
class Sample:
    """Metadata for one material sample used by an experiment."""

    number: int
    source: str | None = None
    status: str | None = None
    purity: tuple[PurityAssessment, ...] = ()

Compound dataclass

Chemical identity and associated sample metadata.

The local_id is scoped to one ThermoML document and must never be used as a global chemical identifier.

Source code in src/thermoml_io/models.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
@dataclass(frozen=True, slots=True)
class Compound:
    """Chemical identity and associated sample metadata.

    The ``local_id`` is scoped to one ThermoML document and must never be used
    as a global chemical identifier.
    """

    local_id: int
    common_names: tuple[str, ...] = ()
    iupac_name: str | None = None
    cas_name: str | None = None
    formula: str | None = None
    standard_inchi: str | None = None
    standard_inchi_key: str | None = None
    cas_registry_number: str | None = None
    samples: tuple[Sample, ...] = ()

    @property
    def preferred_name(self) -> str:
        """Return the best available human-readable component label."""
        if self.common_names:
            return self.common_names[0]
        if self.iupac_name:
            return self.iupac_name
        if self.formula:
            return self.formula
        if self.standard_inchi_key:
            return self.standard_inchi_key
        return f"component-{self.local_id}"

    @property
    def stable_identifier(self) -> str:
        """Return the most stable reported identifier available."""
        if self.standard_inchi_key:
            return f"inchikey:{self.standard_inchi_key.upper()}"
        if self.standard_inchi:
            return f"inchi:{self.standard_inchi}"
        if self.cas_registry_number:
            return f"cas:{self.cas_registry_number}"
        return f"name:{self.preferred_name.casefold()}"

    def matches(self, query: str) -> bool:
        """Return whether ``query`` identifies this compound.

        Matching is case-insensitive and considers names, formula, CAS,
        standard InChI, and InChIKey. It is intentionally exact after trimming
        whitespace to avoid accidental chemical matches.
        """
        normalized = query.strip().casefold()
        identifiers = {
            self.preferred_name.casefold(),
            *(name.strip().casefold() for name in self.common_names),
        }
        for candidate in (
            self.iupac_name,
            self.cas_name,
            self.formula,
            self.standard_inchi,
            self.standard_inchi_key,
            self.cas_registry_number,
        ):
            if candidate:
                identifiers.add(candidate.strip().casefold())
        return normalized in identifiers

preferred_name: str property

Return the best available human-readable component label.

stable_identifier: str property

Return the most stable reported identifier available.

matches(query: str) -> bool

Return whether query identifies this compound.

Matching is case-insensitive and considers names, formula, CAS, standard InChI, and InChIKey. It is intentionally exact after trimming whitespace to avoid accidental chemical matches.

Source code in src/thermoml_io/models.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def matches(self, query: str) -> bool:
    """Return whether ``query`` identifies this compound.

    Matching is case-insensitive and considers names, formula, CAS,
    standard InChI, and InChIKey. It is intentionally exact after trimming
    whitespace to avoid accidental chemical matches.
    """
    normalized = query.strip().casefold()
    identifiers = {
        self.preferred_name.casefold(),
        *(name.strip().casefold() for name in self.common_names),
    }
    for candidate in (
        self.iupac_name,
        self.cas_name,
        self.formula,
        self.standard_inchi,
        self.standard_inchi_key,
        self.cas_registry_number,
    ):
        if candidate:
            identifiers.add(candidate.strip().casefold())
    return normalized in identifiers

Uncertainty dataclass

One uncertainty assessment associated with a reported quantity.

Values are retained in the same units as the corresponding ThermoML quantity. coverage_factor and confidence_level are dimensionless.

Source code in src/thermoml_io/models.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
@dataclass(frozen=True, slots=True)
class Uncertainty:
    """One uncertainty assessment associated with a reported quantity.

    Values are retained in the same units as the corresponding ThermoML
    quantity. ``coverage_factor`` and ``confidence_level`` are dimensionless.
    """

    kind: str
    assessment_number: int | None = None
    evaluator: str | None = None
    method: str | None = None
    standard_value: Decimal | None = None
    expanded_value: Decimal | None = None
    positive_standard_value: Decimal | None = None
    negative_standard_value: Decimal | None = None
    positive_expanded_value: Decimal | None = None
    negative_expanded_value: Decimal | None = None
    coverage_factor: Decimal | None = None
    confidence_level: Decimal | None = None

Repeatability dataclass

Repeatability metadata attached to a quantity definition or value.

Source code in src/thermoml_io/models.py
217
218
219
220
221
222
223
@dataclass(frozen=True, slots=True)
class Repeatability:
    """Repeatability metadata attached to a quantity definition or value."""

    evaluator: str | None = None
    method: str | None = None
    standard_value: Decimal | None = None

DeviceSpecification dataclass

Instrument or device specification reported by the source.

Source code in src/thermoml_io/models.py
226
227
228
229
230
231
232
233
234
@dataclass(frozen=True, slots=True)
class DeviceSpecification:
    """Instrument or device specification reported by the source."""

    evaluator: str | None = None
    method: str | None = None
    description: str | None = None
    value: Decimal | None = None
    confidence_level: Decimal | None = None

QuantityDefinition dataclass

Definition shared by properties, variables, and constraints.

Source code in src/thermoml_io/models.py
237
238
239
240
241
242
243
244
245
246
247
248
@dataclass(frozen=True, slots=True)
class QuantityDefinition:
    """Definition shared by properties, variables, and constraints."""

    number: int
    name: str
    phase: str | None = None
    component_id: int | None = None
    solvent_component_ids: tuple[int, ...] = ()
    uncertainties: tuple[Uncertainty, ...] = ()
    repeatability: tuple[Repeatability, ...] = ()
    device_specifications: tuple[DeviceSpecification, ...] = ()

PropertyDefinition dataclass

Bases: QuantityDefinition

Definition of one experimentally reported property.

Source code in src/thermoml_io/models.py
251
252
253
254
255
256
257
258
259
@dataclass(frozen=True, slots=True)
class PropertyDefinition(QuantityDefinition):
    """Definition of one experimentally reported property."""

    group: str = "Unknown"
    method: str | None = None
    presentation: str | None = None
    reference_phase: str | None = None
    standard_state: str | None = None

VariableDefinition dataclass

Bases: QuantityDefinition

Definition of one independent variable varied between data points.

Source code in src/thermoml_io/models.py
262
263
264
@dataclass(frozen=True, slots=True)
class VariableDefinition(QuantityDefinition):
    """Definition of one independent variable varied between data points."""

ConstraintDefinition dataclass

Bases: QuantityDefinition

Definition and fixed value of one experimental constraint.

Source code in src/thermoml_io/models.py
267
268
269
270
271
272
@dataclass(frozen=True, slots=True)
class ConstraintDefinition(QuantityDefinition):
    """Definition and fixed value of one experimental constraint."""

    value: Decimal | None = None
    significant_digits: int | None = None

MeasuredValue dataclass

Reported numeric value linked to a quantity definition.

Source code in src/thermoml_io/models.py
275
276
277
278
279
280
281
282
283
284
@dataclass(frozen=True, slots=True)
class MeasuredValue:
    """Reported numeric value linked to a quantity definition."""

    number: int
    value: Decimal
    lexical_value: str
    significant_digits: int | None = None
    uncertainties: tuple[Uncertainty, ...] = ()
    repeatability: tuple[Repeatability, ...] = ()

DataPoint dataclass

One ThermoML NumValues record.

Source code in src/thermoml_io/models.py
287
288
289
290
291
292
293
@dataclass(frozen=True, slots=True)
class DataPoint:
    """One ThermoML ``NumValues`` record."""

    index: int
    variable_values: tuple[MeasuredValue, ...] = ()
    property_values: tuple[MeasuredValue, ...] = ()

DataSet dataclass

One pure-compound or mixture experimental dataset.

Source code in src/thermoml_io/models.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
@dataclass(frozen=True, slots=True)
class DataSet:
    """One pure-compound or mixture experimental dataset."""

    number: int
    component_ids: tuple[int, ...]
    component_sample_numbers: tuple[tuple[int, int | None], ...] = ()
    purpose: str | None = None
    compiler: str | None = None
    contributor: str | None = None
    date_added: str | None = None
    phases: tuple[str, ...] = ()
    properties: tuple[PropertyDefinition, ...] = ()
    variables: tuple[VariableDefinition, ...] = ()
    constraints: tuple[ConstraintDefinition, ...] = ()
    points: tuple[DataPoint, ...] = ()

    @property
    def system_type(self) -> SystemType:
        """Classify the system by its number of distinct components."""
        order = len(set(self.component_ids))
        names: dict[int, SystemType] = {
            1: "pure",
            2: "binary",
            3: "ternary",
            4: "quaternary",
        }
        return names.get(order, "other")

    @property
    def observation_count(self) -> int:
        """Return the number of individual property values in the dataset."""
        return sum(len(point.property_values) for point in self.points)

system_type: SystemType property

Classify the system by its number of distinct components.

observation_count: int property

Return the number of individual property values in the dataset.

ThermoMLDocument dataclass

Complete parsed ThermoML document and its source provenance.

Source code in src/thermoml_io/models.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
@dataclass(frozen=True, slots=True)
class ThermoMLDocument:
    """Complete parsed ThermoML document and its source provenance."""

    version_major: int
    version_minor: int
    citation: Citation
    compounds: tuple[Compound, ...]
    datasets: tuple[DataSet, ...]
    provenance: SourceProvenance
    warnings: tuple[str, ...] = ()
    reaction_dataset_count: int = 0
    _compound_by_id: dict[int, Compound] = field(init=False, repr=False, compare=False)

    def __post_init__(self) -> None:
        mapping = {compound.local_id: compound for compound in self.compounds}
        object.__setattr__(self, "_compound_by_id", mapping)

    @property
    def schema_version(self) -> str:
        """Return the document-declared ThermoML version."""
        return f"{self.version_major}.{self.version_minor}"

    def compound(self, local_id: int) -> Compound:
        """Resolve a document-local component identifier."""
        return self._compound_by_id[local_id]

    def system_compounds(self, dataset: DataSet) -> tuple[Compound, ...]:
        """Resolve all components in ``dataset`` in document order."""
        return tuple(self.compound(local_id) for local_id in dataset.component_ids)

    def system_key(self, dataset: DataSet) -> str:
        """Return an order-independent, chemically stable system key."""
        identifiers = sorted(
            self.compound(local_id).stable_identifier for local_id in set(dataset.component_ids)
        )
        return " | ".join(identifiers)

    def dataset_key(self, dataset: DataSet) -> str:
        """Return a stable key for a dataset within a source publication."""
        source = self.citation.normalized_doi or self.provenance.sha256
        return f"{source}#pure-or-mixture-{dataset.number}"

schema_version: str property

Return the document-declared ThermoML version.

compound(local_id: int) -> Compound

Resolve a document-local component identifier.

Source code in src/thermoml_io/models.py
354
355
356
def compound(self, local_id: int) -> Compound:
    """Resolve a document-local component identifier."""
    return self._compound_by_id[local_id]

system_compounds(dataset: DataSet) -> tuple[Compound, ...]

Resolve all components in dataset in document order.

Source code in src/thermoml_io/models.py
358
359
360
def system_compounds(self, dataset: DataSet) -> tuple[Compound, ...]:
    """Resolve all components in ``dataset`` in document order."""
    return tuple(self.compound(local_id) for local_id in dataset.component_ids)

system_key(dataset: DataSet) -> str

Return an order-independent, chemically stable system key.

Source code in src/thermoml_io/models.py
362
363
364
365
366
367
def system_key(self, dataset: DataSet) -> str:
    """Return an order-independent, chemically stable system key."""
    identifiers = sorted(
        self.compound(local_id).stable_identifier for local_id in set(dataset.component_ids)
    )
    return " | ".join(identifiers)

dataset_key(dataset: DataSet) -> str

Return a stable key for a dataset within a source publication.

Source code in src/thermoml_io/models.py
369
370
371
372
def dataset_key(self, dataset: DataSet) -> str:
    """Return a stable key for a dataset within a source publication."""
    source = self.citation.normalized_doi or self.provenance.sha256
    return f"{source}#pure-or-mixture-{dataset.number}"

thermoml_io.identity

Chemical-identity aggregation and explicit component resolution.

ThermoML compound metadata remain immutable. This module builds a separate index that connects reported aliases through shared structural or registry identifiers and refuses to choose silently when a query remains ambiguous.

ComponentIdentity dataclass

One resolved chemical identity with all known exact aliases.

Parameters:

Name Type Description Default
preferred_name str

Human-readable label selected from the reported names or formula.

required
common_names tuple[str, ...]

Exact names observed in ThermoML or returned by an explicit resolver.

()
iupac_names tuple[str, ...]

Exact names observed in ThermoML or returned by an explicit resolver.

()
cas_names tuple[str, ...]

Exact names observed in ThermoML or returned by an explicit resolver.

()
formulas tuple[str, ...]

Reported molecular formulas. Formulas are aliases, not assumed unique.

()
standard_inchis tuple[str, ...]

Strong identifiers used to connect aliases across documents.

()
standard_inchi_keys tuple[str, ...]

Strong identifiers used to connect aliases across documents.

()
cas_registry_numbers tuple[str, ...]

Strong identifiers used to connect aliases across documents.

()
pubchem_cids tuple[int, ...]

Optional PubChem compound identifiers supplied only by explicit PubChem resolution.

()
Notes

This object is separate from :class:~thermoml_io.models.Compound; it does not mutate or replace source-reported ThermoML metadata.

Source code in src/thermoml_io/identity.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
@dataclass(frozen=True, slots=True)
class ComponentIdentity:
    """One resolved chemical identity with all known exact aliases.

    Parameters
    ----------
    preferred_name:
        Human-readable label selected from the reported names or formula.
    common_names, iupac_names, cas_names:
        Exact names observed in ThermoML or returned by an explicit resolver.
    formulas:
        Reported molecular formulas. Formulas are aliases, not assumed unique.
    standard_inchis, standard_inchi_keys, cas_registry_numbers:
        Strong identifiers used to connect aliases across documents.
    pubchem_cids:
        Optional PubChem compound identifiers supplied only by explicit
        PubChem resolution.

    Notes
    -----
    This object is separate from :class:`~thermoml_io.models.Compound`; it does
    not mutate or replace source-reported ThermoML metadata.
    """

    preferred_name: str
    common_names: tuple[str, ...] = ()
    iupac_names: tuple[str, ...] = ()
    cas_names: tuple[str, ...] = ()
    formulas: tuple[str, ...] = ()
    standard_inchis: tuple[str, ...] = ()
    standard_inchi_keys: tuple[str, ...] = ()
    cas_registry_numbers: tuple[str, ...] = ()
    pubchem_cids: tuple[int, ...] = ()

    @classmethod
    def from_compound(cls, compound: Compound) -> ComponentIdentity:
        """Create a detached identity from one source-reported compound."""
        return cls(
            preferred_name=compound.preferred_name,
            common_names=_unique(compound.common_names),
            iupac_names=_unique((compound.iupac_name,) if compound.iupac_name else ()),
            cas_names=_unique((compound.cas_name,) if compound.cas_name else ()),
            formulas=_unique((compound.formula,) if compound.formula else ()),
            standard_inchis=_unique((compound.standard_inchi,) if compound.standard_inchi else ()),
            standard_inchi_keys=_unique(
                (compound.standard_inchi_key,) if compound.standard_inchi_key else ()
            ),
            cas_registry_numbers=_unique(
                (compound.cas_registry_number,) if compound.cas_registry_number else ()
            ),
        )

    @property
    def stable_identifier(self) -> str:
        """Return the strongest deterministic identifier available."""
        if self.standard_inchi_keys:
            return f"inchikey:{self.standard_inchi_keys[0].upper()}"
        if self.standard_inchis:
            return f"inchi:{self.standard_inchis[0]}"
        if self.cas_registry_numbers:
            return f"cas:{self.cas_registry_numbers[0]}"
        return f"name:{_normalize(self.preferred_name)}"

    def values(self, kind: IdentifierKind = "auto") -> tuple[str, ...]:
        """Return exact identifier values considered for one query kind."""
        names = (*self.common_names, *self.iupac_names, *self.cas_names)
        mapping: dict[IdentifierKind, tuple[str, ...]] = {
            "name": names,
            "common": self.common_names,
            "iupac": self.iupac_names,
            "cas-name": self.cas_names,
            "formula": self.formulas,
            "cas": self.cas_registry_numbers,
            "inchi": self.standard_inchis,
            "inchikey": self.standard_inchi_keys,
            "cid": tuple(str(value) for value in self.pubchem_cids),
            "auto": (
                *names,
                *self.formulas,
                *self.cas_registry_numbers,
                *self.standard_inchis,
                *self.standard_inchi_keys,
                *(str(value) for value in self.pubchem_cids),
            ),
        }
        return mapping[kind]

    def matches(self, other: ComponentIdentity) -> bool:
        """Return whether ``other`` is compatible with this resolved identity.

        Shared strong identifiers decide first. If both sides report strong
        identifiers and none agree, aliases are not allowed to override that
        chemical conflict. Exact aliases are used only when at least one side
        lacks strong identity metadata.
        """
        strong_self = self._strong_tokens()
        strong_other = other._strong_tokens()
        if strong_self & strong_other:
            return True
        if strong_self and strong_other:
            return False
        aliases_self = {_normalize(value) for value in self.values("auto")}
        aliases_other = {_normalize(value) for value in other.values("auto")}
        return bool(aliases_self & aliases_other)

    def _strong_tokens(self) -> frozenset[tuple[str, str]]:
        return frozenset(
            (
                *(("inchikey", _normalize(value)) for value in self.standard_inchi_keys),
                *(("inchi", _normalize(value)) for value in self.standard_inchis),
                *(("cas", _normalize(value)) for value in self.cas_registry_numbers),
            )
        )

stable_identifier: str property

Return the strongest deterministic identifier available.

from_compound(compound: Compound) -> ComponentIdentity classmethod

Create a detached identity from one source-reported compound.

Source code in src/thermoml_io/identity.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@classmethod
def from_compound(cls, compound: Compound) -> ComponentIdentity:
    """Create a detached identity from one source-reported compound."""
    return cls(
        preferred_name=compound.preferred_name,
        common_names=_unique(compound.common_names),
        iupac_names=_unique((compound.iupac_name,) if compound.iupac_name else ()),
        cas_names=_unique((compound.cas_name,) if compound.cas_name else ()),
        formulas=_unique((compound.formula,) if compound.formula else ()),
        standard_inchis=_unique((compound.standard_inchi,) if compound.standard_inchi else ()),
        standard_inchi_keys=_unique(
            (compound.standard_inchi_key,) if compound.standard_inchi_key else ()
        ),
        cas_registry_numbers=_unique(
            (compound.cas_registry_number,) if compound.cas_registry_number else ()
        ),
    )

values(kind: IdentifierKind = 'auto') -> tuple[str, ...]

Return exact identifier values considered for one query kind.

Source code in src/thermoml_io/identity.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def values(self, kind: IdentifierKind = "auto") -> tuple[str, ...]:
    """Return exact identifier values considered for one query kind."""
    names = (*self.common_names, *self.iupac_names, *self.cas_names)
    mapping: dict[IdentifierKind, tuple[str, ...]] = {
        "name": names,
        "common": self.common_names,
        "iupac": self.iupac_names,
        "cas-name": self.cas_names,
        "formula": self.formulas,
        "cas": self.cas_registry_numbers,
        "inchi": self.standard_inchis,
        "inchikey": self.standard_inchi_keys,
        "cid": tuple(str(value) for value in self.pubchem_cids),
        "auto": (
            *names,
            *self.formulas,
            *self.cas_registry_numbers,
            *self.standard_inchis,
            *self.standard_inchi_keys,
            *(str(value) for value in self.pubchem_cids),
        ),
    }
    return mapping[kind]

matches(other: ComponentIdentity) -> bool

Return whether other is compatible with this resolved identity.

Shared strong identifiers decide first. If both sides report strong identifiers and none agree, aliases are not allowed to override that chemical conflict. Exact aliases are used only when at least one side lacks strong identity metadata.

Source code in src/thermoml_io/identity.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def matches(self, other: ComponentIdentity) -> bool:
    """Return whether ``other`` is compatible with this resolved identity.

    Shared strong identifiers decide first. If both sides report strong
    identifiers and none agree, aliases are not allowed to override that
    chemical conflict. Exact aliases are used only when at least one side
    lacks strong identity metadata.
    """
    strong_self = self._strong_tokens()
    strong_other = other._strong_tokens()
    if strong_self & strong_other:
        return True
    if strong_self and strong_other:
        return False
    aliases_self = {_normalize(value) for value in self.values("auto")}
    aliases_other = {_normalize(value) for value in other.values("auto")}
    return bool(aliases_self & aliases_other)

ComponentIndex dataclass

Immutable alias index for resolving components without guessing.

Source code in src/thermoml_io/identity.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
@dataclass(frozen=True, slots=True)
class ComponentIndex:
    """Immutable alias index for resolving components without guessing."""

    identities: tuple[ComponentIdentity, ...]
    _lookup: dict[tuple[IdentifierKind, str], tuple[ComponentIdentity, ...]] = field(
        init=False, repr=False, compare=False
    )

    def __post_init__(self) -> None:
        lookup: defaultdict[tuple[IdentifierKind, str], list[ComponentIdentity]] = defaultdict(list)
        kinds: tuple[IdentifierKind, ...] = (
            "auto",
            "name",
            "common",
            "iupac",
            "cas-name",
            "formula",
            "cas",
            "inchi",
            "inchikey",
            "cid",
        )
        for identity in self.identities:
            for kind in kinds:
                for value in identity.values(kind):
                    key = (kind, _normalize(value))
                    if identity not in lookup[key]:
                        lookup[key].append(identity)
        object.__setattr__(
            self,
            "_lookup",
            {
                key: tuple(sorted(values, key=lambda item: item.stable_identifier))
                for key, values in lookup.items()
            },
        )

    @classmethod
    def from_documents(cls, documents: Iterable[ThermoMLDocument]) -> ComponentIndex:
        """Build an index from source compounds across several documents."""
        return cls.from_identities(
            ComponentIdentity.from_compound(compound)
            for document in documents
            for compound in document.compounds
        )

    @classmethod
    def from_identities(cls, identities: Iterable[ComponentIdentity]) -> ComponentIndex:
        """Connect aliases only through shared strong identifiers.

        Records without InChIKey, InChI, or CAS number may join a strong group
        when their aliases identify exactly one such group. If two strong
        identities share a name or formula, they remain separate so resolution
        reports the ambiguity.
        """
        records = tuple(identities)
        if not records:
            return cls(())
        parent = list(range(len(records)))

        def find(index: int) -> int:
            while parent[index] != index:
                parent[index] = parent[parent[index]]
                index = parent[index]
            return index

        def union(left: int, right: int) -> None:
            left_root = find(left)
            right_root = find(right)
            if left_root != right_root:
                parent[right_root] = left_root

        strong_owner: dict[tuple[str, str], int] = {}
        weak_owner: dict[str, int] = {}
        for index, identity in enumerate(records):
            strong = identity._strong_tokens()
            for token in strong:
                owner = strong_owner.setdefault(token, index)
                union(index, owner)
            if not strong:
                owner = weak_owner.setdefault(identity.stable_identifier, index)
                union(index, owner)

        initial_groups: defaultdict[int, list[int]] = defaultdict(list)
        for index in range(len(records)):
            initial_groups[find(index)].append(index)
        strong_groups = {
            root: _merge_identities(records[index] for index in members)
            for root, members in initial_groups.items()
            if any(records[index]._strong_tokens() for index in members)
        }
        strong_aliases = {
            root: {_normalize(value) for value in identity.values("auto")}
            for root, identity in strong_groups.items()
        }
        for root, members in initial_groups.items():
            if root in strong_groups:
                continue
            aliases = {
                _normalize(value) for index in members for value in records[index].values("auto")
            }
            candidates = [
                strong_root
                for strong_root, candidate_aliases in strong_aliases.items()
                if aliases & candidate_aliases
            ]
            if len(candidates) == 1:
                union(root, candidates[0])

        groups: defaultdict[int, list[ComponentIdentity]] = defaultdict(list)
        for index, identity in enumerate(records):
            groups[find(index)].append(identity)
        merged = tuple(
            sorted(
                (_merge_identities(group) for group in groups.values()),
                key=lambda item: item.stable_identifier,
            )
        )
        return cls(merged)

    def resolve(self, query: ComponentQuery) -> ComponentIdentity:
        """Resolve one query or raise an explicit not-found/ambiguity error."""
        if isinstance(query, ComponentIdentity):
            return query
        kind, value = _parse_query(query)
        candidates = self._lookup.get((kind, _normalize(value)), ())
        if not candidates:
            raise ComponentNotFoundError(
                f"No indexed component matches {query!r}. Use an exact reported name, "
                "formula, CAS number, InChI, or InChIKey."
            )
        if len(candidates) > 1:
            labels = ", ".join(
                f"{item.preferred_name} [{item.stable_identifier}]" for item in candidates
            )
            raise AmbiguousComponentError(
                f"Component query {query!r} is ambiguous: {labels}. "
                "Use a CAS number, InChI, or InChIKey."
            )
        return candidates[0]

    def resolve_many(self, queries: Iterable[ComponentQuery]) -> tuple[ComponentIdentity, ...]:
        """Resolve several queries and reject duplicate chemical identities."""
        resolved = tuple(self.resolve(query) for query in queries)
        stable = [item.stable_identifier for item in resolved]
        if len(set(stable)) != len(stable):
            raise ValueError("Component queries resolve to duplicate chemical identities.")
        return resolved

from_documents(documents: Iterable[ThermoMLDocument]) -> ComponentIndex classmethod

Build an index from source compounds across several documents.

Source code in src/thermoml_io/identity.py
290
291
292
293
294
295
296
297
@classmethod
def from_documents(cls, documents: Iterable[ThermoMLDocument]) -> ComponentIndex:
    """Build an index from source compounds across several documents."""
    return cls.from_identities(
        ComponentIdentity.from_compound(compound)
        for document in documents
        for compound in document.compounds
    )

from_identities(identities: Iterable[ComponentIdentity]) -> ComponentIndex classmethod

Connect aliases only through shared strong identifiers.

Records without InChIKey, InChI, or CAS number may join a strong group when their aliases identify exactly one such group. If two strong identities share a name or formula, they remain separate so resolution reports the ambiguity.

Source code in src/thermoml_io/identity.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
@classmethod
def from_identities(cls, identities: Iterable[ComponentIdentity]) -> ComponentIndex:
    """Connect aliases only through shared strong identifiers.

    Records without InChIKey, InChI, or CAS number may join a strong group
    when their aliases identify exactly one such group. If two strong
    identities share a name or formula, they remain separate so resolution
    reports the ambiguity.
    """
    records = tuple(identities)
    if not records:
        return cls(())
    parent = list(range(len(records)))

    def find(index: int) -> int:
        while parent[index] != index:
            parent[index] = parent[parent[index]]
            index = parent[index]
        return index

    def union(left: int, right: int) -> None:
        left_root = find(left)
        right_root = find(right)
        if left_root != right_root:
            parent[right_root] = left_root

    strong_owner: dict[tuple[str, str], int] = {}
    weak_owner: dict[str, int] = {}
    for index, identity in enumerate(records):
        strong = identity._strong_tokens()
        for token in strong:
            owner = strong_owner.setdefault(token, index)
            union(index, owner)
        if not strong:
            owner = weak_owner.setdefault(identity.stable_identifier, index)
            union(index, owner)

    initial_groups: defaultdict[int, list[int]] = defaultdict(list)
    for index in range(len(records)):
        initial_groups[find(index)].append(index)
    strong_groups = {
        root: _merge_identities(records[index] for index in members)
        for root, members in initial_groups.items()
        if any(records[index]._strong_tokens() for index in members)
    }
    strong_aliases = {
        root: {_normalize(value) for value in identity.values("auto")}
        for root, identity in strong_groups.items()
    }
    for root, members in initial_groups.items():
        if root in strong_groups:
            continue
        aliases = {
            _normalize(value) for index in members for value in records[index].values("auto")
        }
        candidates = [
            strong_root
            for strong_root, candidate_aliases in strong_aliases.items()
            if aliases & candidate_aliases
        ]
        if len(candidates) == 1:
            union(root, candidates[0])

    groups: defaultdict[int, list[ComponentIdentity]] = defaultdict(list)
    for index, identity in enumerate(records):
        groups[find(index)].append(identity)
    merged = tuple(
        sorted(
            (_merge_identities(group) for group in groups.values()),
            key=lambda item: item.stable_identifier,
        )
    )
    return cls(merged)

resolve(query: ComponentQuery) -> ComponentIdentity

Resolve one query or raise an explicit not-found/ambiguity error.

Source code in src/thermoml_io/identity.py
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def resolve(self, query: ComponentQuery) -> ComponentIdentity:
    """Resolve one query or raise an explicit not-found/ambiguity error."""
    if isinstance(query, ComponentIdentity):
        return query
    kind, value = _parse_query(query)
    candidates = self._lookup.get((kind, _normalize(value)), ())
    if not candidates:
        raise ComponentNotFoundError(
            f"No indexed component matches {query!r}. Use an exact reported name, "
            "formula, CAS number, InChI, or InChIKey."
        )
    if len(candidates) > 1:
        labels = ", ".join(
            f"{item.preferred_name} [{item.stable_identifier}]" for item in candidates
        )
        raise AmbiguousComponentError(
            f"Component query {query!r} is ambiguous: {labels}. "
            "Use a CAS number, InChI, or InChIKey."
        )
    return candidates[0]

resolve_many(queries: Iterable[ComponentQuery]) -> tuple[ComponentIdentity, ...]

Resolve several queries and reject duplicate chemical identities.

Source code in src/thermoml_io/identity.py
394
395
396
397
398
399
400
def resolve_many(self, queries: Iterable[ComponentQuery]) -> tuple[ComponentIdentity, ...]:
    """Resolve several queries and reject duplicate chemical identities."""
    resolved = tuple(self.resolve(query) for query in queries)
    stable = [item.stable_identifier for item in resolved]
    if len(set(stable)) != len(stable):
        raise ValueError("Component queries resolve to duplicate chemical identities.")
    return resolved

explicit_component_identity(query: str) -> ComponentIdentity | None

Return a detached identity for a self-identifying strong query.

This helper avoids an archive-wide alias scan for explicit CAS, InChI, or InChIKey queries. Names and formulas return None because they require ambiguity checks against the indexed source.

Source code in src/thermoml_io/identity.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def explicit_component_identity(query: str) -> ComponentIdentity | None:
    """Return a detached identity for a self-identifying strong query.

    This helper avoids an archive-wide alias scan for explicit CAS, InChI, or
    InChIKey queries. Names and formulas return ``None`` because they require
    ambiguity checks against the indexed source.
    """
    kind, value = _parse_query(query)
    if kind == "auto":
        if value.casefold().startswith("inchi="):
            kind = "inchi"
        elif re.fullmatch(r"[A-Za-z]{14}-[A-Za-z]{10}-[A-Za-z]", value):
            kind = "inchikey"
        elif re.fullmatch(r"\d{2,7}-\d{2}-\d", value):
            kind = "cas"
    if kind == "inchi":
        return ComponentIdentity(preferred_name=value, standard_inchis=(value,))
    if kind == "inchikey":
        return ComponentIdentity(preferred_name=value, standard_inchi_keys=(value,))
    if kind == "cas":
        return ComponentIdentity(preferred_name=value, cas_registry_numbers=(value,))
    return None

component_query_label(query: ComponentQuery) -> str

Return a stable user-facing representation of a component query.

Source code in src/thermoml_io/identity.py
403
404
405
def component_query_label(query: ComponentQuery) -> str:
    """Return a stable user-facing representation of a component query."""
    return query if isinstance(query, str) else query.stable_identifier

thermoml_io.pubchem

Explicit, optional PubChem PUG REST component resolution.

resolve_pubchem_component(query: str, *, namespace: PubChemNamespace = 'name', timeout: float = 30.0, max_bytes: int = 2 * 1024 * 1024) -> ComponentIdentity

Resolve one component through the official PubChem PUG REST service.

Parameters:

Name Type Description Default
query str

Exact PubChem input in the selected namespace, commonly a familiar chemical name such as "hydrogen".

required
namespace PubChemNamespace

PubChem input namespace. Network resolution is always explicit; this function is never called implicitly by ThermoML search operations.

'name'
timeout float

Network timeout in seconds.

30.0
max_bytes int

Maximum accepted JSON response size.

2 * 1024 * 1024

Returns:

Type Description
ComponentIdentity

Detached identity metadata suitable for a collection or archive query.

Raises:

Type Description
ComponentNotFoundError

If PubChem reports no matching compound.

AmbiguousComponentError

If the input maps to more than one distinct chemical identity.

PubChemResolutionError

If the request or response cannot be processed safely.

Source code in src/thermoml_io/pubchem.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def resolve_pubchem_component(
    query: str,
    *,
    namespace: PubChemNamespace = "name",
    timeout: float = 30.0,
    max_bytes: int = 2 * 1024 * 1024,
) -> ComponentIdentity:
    """Resolve one component through the official PubChem PUG REST service.

    Parameters
    ----------
    query:
        Exact PubChem input in the selected namespace, commonly a familiar
        chemical name such as ``"hydrogen"``.
    namespace:
        PubChem input namespace. Network resolution is always explicit; this
        function is never called implicitly by ThermoML search operations.
    timeout:
        Network timeout in seconds.
    max_bytes:
        Maximum accepted JSON response size.

    Returns
    -------
    ComponentIdentity
        Detached identity metadata suitable for a collection or archive query.

    Raises
    ------
    ComponentNotFoundError
        If PubChem reports no matching compound.
    AmbiguousComponentError
        If the input maps to more than one distinct chemical identity.
    PubChemResolutionError
        If the request or response cannot be processed safely.
    """
    cleaned = query.strip()
    if not cleaned:
        raise ValueError("PubChem component query must not be empty.")
    if namespace not in _NAMESPACES:
        raise ValueError(f"Unsupported PubChem namespace {namespace!r}.")
    if max_bytes <= 0:
        raise ValueError("max_bytes must be positive.")
    encoded = quote(cleaned, safe="")
    url = f"{_BASE_URL}/{namespace}/{encoded}/property/{_PROPERTIES}/JSON"
    request = Request(url, headers={"User-Agent": "thermoml-io/0.1"})
    try:
        with urlopen(request, timeout=timeout) as response:
            data = _read_response(response, max_bytes)
    except HTTPError as exc:
        if exc.code == 404:
            raise ComponentNotFoundError(
                f"PubChem has no {namespace} match for {cleaned!r}."
            ) from exc
        raise PubChemResolutionError(
            f"PubChem request failed with HTTP status {exc.code}."
        ) from exc
    except (URLError, TimeoutError, OSError) as exc:
        raise PubChemResolutionError(f"Could not query PubChem: {exc}") from exc

    try:
        payload = json.loads(data)
    except (JSONDecodeError, UnicodeDecodeError) as exc:
        raise PubChemResolutionError("PubChem returned invalid JSON.") from exc
    if not isinstance(payload, dict):
        raise PubChemResolutionError("PubChem returned an unexpected JSON document.")
    table = payload.get("PropertyTable")
    properties = table.get("Properties") if isinstance(table, dict) else None
    if not isinstance(properties, list) or not properties:
        raise ComponentNotFoundError(f"PubChem has no {namespace} match for {cleaned!r}.")
    records = [item for item in properties if isinstance(item, dict)]
    if len(records) != len(properties):
        raise PubChemResolutionError("PubChem returned malformed property records.")
    index = ComponentIndex.from_identities(
        _identity_from_property(item, query=cleaned, namespace=namespace) for item in records
    )
    if len(index.identities) > 1:
        labels = ", ".join(
            f"{item.preferred_name} [{item.stable_identifier}]" for item in index.identities
        )
        raise AmbiguousComponentError(
            f"PubChem {namespace} query {cleaned!r} is ambiguous: {labels}."
        )
    if not index.identities:  # pragma: no cover - records guarantee one identity
        raise ComponentNotFoundError(f"PubChem has no {namespace} match for {cleaned!r}.")
    return index.identities[0]