From bd9e37a81f43a5348288f4057dfc837b023145f6 Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:17:55 -0700 Subject: [PATCH 01/30] bump emmet-core into next rc segment --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 464081fe..bca8c8d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "typing-extensions>=3.7.4.1", "requests>=2.23.0", "monty>=2024.12.10", - "emmet-core>=0.87.1", + "emmet-core>=0.87.3rc2,<0.87.4", "boto3", "orjson >= 3.10,<4", "pyarrow >= 20.0.0", @@ -50,7 +50,7 @@ contribs = [ ] all = [ "custodian", - "emmet-core[all]>=0.87.1", + "emmet-core[all]>=0.87.3rc2,<0.87.4", "fastmcp", "flask", ] From 423ae5a991fc40f03997f9bb5b5e46f1774e6a5b Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:26:41 -0700 Subject: [PATCH 02/30] handle some warnings in tests --- tests/client/core/test_schemas.py | 24 +- tests/client/materials/test_electrodes.py | 29 +-- tests/client/molecules/test_jcesr.py | 21 +- tests/client/molecules/test_molecules.py | 15 +- tests/client/test_mprester.py | 254 ++++++++++++---------- 5 files changed, 191 insertions(+), 152 deletions(-) diff --git a/tests/client/core/test_schemas.py b/tests/client/core/test_schemas.py index 574e5229..55018a71 100644 --- a/tests/client/core/test_schemas.py +++ b/tests/client/core/test_schemas.py @@ -7,22 +7,22 @@ from mp_api.client.core.schemas import _DictLikeAccess, _convert_to_model -class TestClass(_DictLikeAccess): +class DummyClass(_DictLikeAccess): a: int b: float c: list[str] def test_dict_like_access(): - instance = TestClass(a=1, b=2.0, c=["a", "b", "c"]) + instance = DummyClass(a=1, b=2.0, c=["a", "b", "c"]) assert isinstance(instance, BaseModel) assert all( getattr(instance, field_name) == instance[field_name] and instance[field_name] == instance.get(field_name) - for field_name in TestClass.model_fields + for field_name in DummyClass.model_fields ) - as_str = """TestClass( + as_str = """DummyClass( a (int) : 1 b (float) : 2.0 c (list) : ['a', 'b', 'c'] @@ -30,7 +30,9 @@ def test_dict_like_access(): assert str(instance) == as_str assert repr(instance) == as_str - with pytest.raises(AttributeError, match="'TestClass' object has no attribute 'd'"): + with pytest.raises( + AttributeError, match="'DummyClass' object has no attribute 'd'" + ): instance.d assert instance.get("d", None) == None @@ -53,7 +55,7 @@ def test_model_generation(): {k: v for k, v in doc.items() if k != "b"} for doc in get_data() ), }.items(): - as_models = _convert_to_model(trial_data, TestClass, model_name=test_type) + as_models = _convert_to_model(trial_data, DummyClass, model_name=test_type) assert all(isinstance(doc, BaseModel) for doc in as_models) assert all(doc.__class__.__name__ == test_type for doc in as_models) @@ -73,20 +75,20 @@ def test_model_generation(): else: assert all( getattr(doc, k) and doc.get(k) - for k in TestClass.model_fields + for k in DummyClass.model_fields for doc in as_models ) assert all( substr in str(doc) - for substr in ("Fields not requested", "TestClass", test_type) + for substr in ("Fields not requested", "DummyClass", test_type) for doc in as_models ) # Test requesting unavailable fields as_models = _convert_to_model( [{k: v for k, v in doc.items() if k != "b"} for doc in get_data()], - TestClass, + DummyClass, requested_fields=["b"], ) @@ -96,7 +98,7 @@ def test_model_generation(): # Test accessing fields that weren't requested as_models = _convert_to_model( [{k: v for k, v in doc.items() if k == "b"} for doc in get_data()], - TestClass, + DummyClass, requested_fields=["b"], ) with pytest.raises( @@ -105,4 +107,4 @@ def test_model_generation(): as_models[0].a # Ensure graceful handling of empty iterator input (no docs returned) - assert _convert_to_model(iter([]), TestClass) == [] + assert _convert_to_model(iter([]), DummyClass) == [] diff --git a/tests/client/materials/test_electrodes.py b/tests/client/materials/test_electrodes.py index cb37d486..6e91b7f2 100644 --- a/tests/client/materials/test_electrodes.py +++ b/tests/client/materials/test_electrodes.py @@ -1,4 +1,5 @@ import os +import warnings import pytest from pymatgen.core.periodic_table import Element @@ -74,19 +75,21 @@ def test_insertion_client(insertion_rester): @requires_api_key def test_conversion_client(conversion_rester): - search_method = conversion_rester.search - - excl = ConversionElectrodeRester._exclude_search_fields - client_search_testing( - search_method=search_method, - excluded_params=excluded_params + excl, - alt_name_dict=alt_name_dict, - custom_field_tests={ - "battery_ids": ["mp-1067_Al"], - "working_ion": Element("Li"), - }, - sub_doc_fields=sub_doc_fields, - ) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=UserWarning) + search_method = conversion_rester.search + + excl = ConversionElectrodeRester._exclude_search_fields + client_search_testing( + search_method=search_method, + excluded_params=excluded_params + excl, + alt_name_dict=alt_name_dict, + custom_field_tests={ + "battery_ids": ["mp-1067_Al"], + "working_ion": Element("Li"), + }, + sub_doc_fields=sub_doc_fields, + ) @requires_api_key diff --git a/tests/client/molecules/test_jcesr.py b/tests/client/molecules/test_jcesr.py index cd7dd97b..50866f7a 100644 --- a/tests/client/molecules/test_jcesr.py +++ b/tests/client/molecules/test_jcesr.py @@ -1,4 +1,5 @@ import os +import warnings import pytest from pymatgen.core.periodic_table import Element @@ -15,9 +16,11 @@ @pytest.fixture def rester(): - rester = JcesrMoleculesRester() - yield rester - rester.session.close() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + rester = JcesrMoleculesRester() + yield rester + rester.session.close() excluded_params = [ @@ -64,8 +67,10 @@ def test_warning(): @requires_api_key def test_pagination(): - with JcesrMoleculesRester() as rester: - client_pagination(rester.search, "task_id") + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with JcesrMoleculesRester() as rester: + client_pagination(rester.search, "task_id") @requires_api_key @@ -76,5 +81,7 @@ def test_pagination(): ], ) def test_sort(sort_field): - with JcesrMoleculesRester() as rester: - client_sort(rester.search, sort_field, default_fields=()) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with JcesrMoleculesRester() as rester: + client_sort(rester.search, sort_field, default_fields=()) diff --git a/tests/client/molecules/test_molecules.py b/tests/client/molecules/test_molecules.py index 5d5cd098..88643a8d 100644 --- a/tests/client/molecules/test_molecules.py +++ b/tests/client/molecules/test_molecules.py @@ -4,15 +4,18 @@ point for other resters, therefore we include only basic tests here. """ +import warnings from mp_api.client.routes.molecules.molecules import MoleculeRester from mp_api.client.routes.molecules import MOLECULES_RESTERS def test_molecule_rester(): - with MoleculeRester() as rester: - assert all(sub_rester in dir(rester) for sub_rester in MOLECULES_RESTERS) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with MoleculeRester() as rester: + assert all(sub_rester in dir(rester) for sub_rester in MOLECULES_RESTERS) - assert all( - getattr(rester, k)._class_name == lazy_obj._class_name - for k, lazy_obj in MOLECULES_RESTERS.items() - ) + assert all( + getattr(rester, k)._class_name == lazy_obj._class_name + for k, lazy_obj in MOLECULES_RESTERS.items() + ) diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py index 1b807cd6..3dd0b035 100644 --- a/tests/client/test_mprester.py +++ b/tests/client/test_mprester.py @@ -35,7 +35,9 @@ ConstantEnergyAdjustment, GibbsComputedStructureEntry, ) -from pymatgen.entries.mixing_scheme import MaterialsProjectDFTMixingScheme +from pymatgen.analysis.compatibility.mixing_scheme import ( + MaterialsProjectDFTMixingScheme, +) from pymatgen.io.cif import CifParser from pymatgen.io.vasp import Chgcar @@ -144,46 +146,53 @@ def test_find_structure( _atom_site_occupancy Ne Ne0 1 0.00000000 0.00000000 -0.00000000 1 """ - struct_from_cif = CifParser.from_str(cif_str).parse_structures(primitive=True)[ - 0 - ] - temp_file = NamedTemporaryFile(suffix=".cif") - with open(temp_file.name, "wt") as f: - f.write(cif_str) - f.seek(0) - - for struct_or_path, use_document_model in [ - (temp_file.name, True), - (struct_from_cif, False), - ]: - with MPRester(use_document_model=use_document_model) as mpr: - data = mpr.find_structure(struct_or_path) - assert isinstance(data, str) and data == "mp-111" - f.close() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=UserWarning) + struct_from_cif = CifParser.from_str(cif_str).parse_structures( + primitive=True + )[0] + temp_file = NamedTemporaryFile(suffix=".cif") + with open(temp_file.name, "wt") as f: + f.write(cif_str) + f.seek(0) + + for struct_or_path, use_document_model in [ + (temp_file.name, True), + (struct_from_cif, False), + ]: + with MPRester(use_document_model=use_document_model) as mpr: + data = mpr.find_structure(struct_or_path) + assert isinstance(data, str) and data == "mp-111" + + f.close() - with pytest.raises(MPRestError, match="Provide filename or Structure object."): - mpr.find_structure(struct_from_cif.as_dict()) + with pytest.raises( + MPRestError, match="Provide filename or Structure object." + ): + mpr.find_structure(struct_from_cif.as_dict()) - with pytest.raises(MPRestError, match="`allow_multiple_results` must be a"): - mpr.find_structure(struct_from_cif, allow_multiple_results=1.0) + with pytest.raises(MPRestError, match="`allow_multiple_results` must be a"): + mpr.find_structure(struct_from_cif, allow_multiple_results=1.0) - assert ( - len( - mpr.find_structure( - struct_from_cif.copy().replace_species({"Ne": "K"}), - allow_multiple_results=2, + assert ( + len( + mpr.find_structure( + struct_from_cif.copy().replace_species({"Ne": "K"}), + allow_multiple_results=2, + ) ) + <= 2 ) - <= 2 - ) def test_get_bandstructure_by_material_id(self, mpr): - bs = mpr.get_bandstructure_by_material_id("mp-149") - assert isinstance(bs, BandStructureSymmLine) - bs_uniform = mpr.get_bandstructure_by_material_id("mp-149", line_mode=False) - assert isinstance(bs_uniform, BandStructure) - assert not isinstance(bs_uniform, BandStructureSymmLine) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=UserWarning) + bs = mpr.get_bandstructure_by_material_id("mp-149") + assert isinstance(bs, BandStructureSymmLine) + bs_uniform = mpr.get_bandstructure_by_material_id("mp-149", line_mode=False) + assert isinstance(bs_uniform, BandStructure) + assert not isinstance(bs_uniform, BandStructureSymmLine) def test_get_dos_by_id(self, mpr): dos = mpr.get_dos_by_material_id("mp-149") @@ -282,7 +291,9 @@ def test_get_entries_in_chemsys(self, mpr): MPRestWarning, match="The default thermo type when retrieving entries" ): entries = mpr.get_entries_in_chemsys(syms) - entries2 = mpr.get_entries_in_chemsys(syms2) + entries2 = mpr.get_entries_in_chemsys( + syms2, additional_criteria={"thermo_types": [ThermoType.GGA_GGA_U_R2SCAN]} + ) elements = {Element(sym) for sym in syms} for e in entries: assert isinstance(e, ComputedEntry) @@ -292,7 +303,11 @@ def test_get_entries_in_chemsys(self, mpr): e2 = {i.entry_id for i in entries2} assert e1 == e2 - gibbs_entries = mpr.get_entries_in_chemsys(syms2, use_gibbs=500) + gibbs_entries = mpr.get_entries_in_chemsys( + syms2, + use_gibbs=500, + additional_criteria={"thermo_types": [ThermoType.GGA_GGA_U_R2SCAN]}, + ) for e in gibbs_entries: assert isinstance(e, GibbsComputedStructureEntry) @@ -308,7 +323,10 @@ def test_get_entries_in_chemsys_mixed_hull(self, mpr): chemical system that entry's thermo doc was built for, so pooling the served entries across subsystems put Cs2TiI6 ~4.6 eV/atom above the hull instead of on it. """ - entries = mpr.get_entries_in_chemsys("Cs-Ti-I") + entries = mpr.get_entries_in_chemsys( + "Cs-Ti-I", + additional_criteria={"thermo_types": [ThermoType.GGA_GGA_U_R2SCAN]}, + ) phase_diagram = PhaseDiagram(entries) host = next(e for e in entries if e.composition.reduced_formula == "Cs2TiI6") assert phase_diagram.get_e_above_hull(host) == pytest.approx(0.0, abs=1e-6) @@ -334,7 +352,9 @@ def test_get_entries_in_chemsys_mixed_hull(self, mpr): all_fields=False, fields=["material_id", "energy_above_hull"], ) - entries = mpr.get_entries_in_chemsys("H-O") + entries = mpr.get_entries_in_chemsys( + "H-O", additional_criteria={"thermo_types": [ThermoType.GGA_GGA_U_R2SCAN]} + ) phase_diagram = PhaseDiagram(entries) by_mpid = defaultdict(list) for entry in entries: @@ -352,7 +372,11 @@ def test_get_entries_in_chemsys_mixed_hull(self, mpr): # uncorrected mixed entries cannot be placed on a common scale, so a warning is thrown: with pytest.warns(MPRestWarning, match="common energy scale"): - mpr.get_entries_in_chemsys("Cs-Ti-I", compatible_only=False) + mpr.get_entries_in_chemsys( + "Cs-Ti-I", + compatible_only=False, + additional_criteria={"thermo_types": [ThermoType.GGA_GGA_U_R2SCAN]}, + ) def test_get_entries_in_chemsys_decorated_served_pd(self, mpr): """ @@ -360,9 +384,14 @@ def test_get_entries_in_chemsys_decorated_served_pd(self, mpr): the pre-built phase diagram (and decorated post-hoc), rather than falling back to re-applying the mixing scheme locally (which can differ from MP's hull). """ - entries = mpr.get_entries_in_chemsys("H-O") + entries = mpr.get_entries_in_chemsys( + "H-O", additional_criteria={"thermo_types": [ThermoType.GGA_GGA_U_R2SCAN]} + ) decorated = mpr.get_entries_in_chemsys( - "H-O", property_data=["energy_above_hull"], conventional_unit_cell=True + "H-O", + property_data=["energy_above_hull"], + conventional_unit_cell=True, + additional_criteria={"thermo_types": [ThermoType.GGA_GGA_U_R2SCAN]}, ) served = { str(e.entry_id): ( @@ -662,8 +691,6 @@ def test_get_cohesive_energy(self): with MPRester() as mpr: mpr.get_cohesive_energy("mp-1") - # SOMETHING IS OFF HERE FOR THE MIXING SCHEME - # MIXING SCHEME TEST IS FLAKY, PASSES ROUGHLY 20% OF THE TIME @pytest.mark.parametrize( "thermo_type", ["GGA_GGA+U", ThermoType.GGA_GGA_U_R2SCAN, "r2SCAN"] ) @@ -673,83 +700,80 @@ def test_get_stability(self, thermo_type): to include more diverse chemical environments and thermo types which reflect the scope of the current MP database. """ - if ( - isinstance(thermo_type, ThermoType) - and thermo_type == ThermoType.GGA_GGA_U_R2SCAN - ): - pytest.skip("See comments about flakiness for mixing scheme") - with MPRester() as mpr: - - # No golden test data. Always test on fetched thermo data - chemsys_to_test: set[str] = { - doc.chemsys - for doc in mpr.materials.thermo.search( - thermo_types=[thermo_type], - num_elements=2, - num_chunks=1, - chunk_size=4, - fields=["chemsys"], - ) - } + with warnings.catch_warnings(): + # ignore some common pmg warnings: failed to guess oxi states, discarding entries, etc. + warnings.filterwarnings("ignore", category=UserWarning) - for chemsys in chemsys_to_test: - - # RETURN ORDER NOT DETERMINISTIC - entries = mpr.get_entries_in_chemsys( - chemsys, additional_criteria={"thermo_types": [thermo_type]} - ) - - modified_entries = [ - ComputedEntry( - entry.composition, - entry.uncorrected_energy + 0.01, - parameters=entry.parameters, - entry_id=f"mod_{entry.entry_id}", + with MPRester() as mpr: + # No golden test data. Always test on fetched thermo data + chemsys_to_test: set[str] = { + doc.chemsys + for doc in mpr.materials.thermo.search( + thermo_types=[thermo_type], + num_elements=2, + num_chunks=1, + chunk_size=4, + fields=["chemsys"], ) - for entry in entries - # MIXING SCHEME - ONLY PASSES IF A "GOOD" ENTRY IS RETURNED FIRST?? - if entry.entry_id == entries[0].entry_id - ] - - if ( - all(len(entry.composition.elements) == 1 for entry in entries) - and chemsys.count("-") > 0 - ): - # For a multi-element chemsys with no multinaries, only elementals, - # there should be no phase diagram data available. - with pytest.warns( - MPRestWarning, match="No phase diagram data available" - ): - mpr.get_stability(modified_entries, thermo_type=thermo_type) - return + } - else: - rester_ehulls = mpr.get_stability( - modified_entries, thermo_type=thermo_type + for chemsys in chemsys_to_test: + # RETURN ORDER NOT DETERMINISTIC + entries = mpr.get_entries_in_chemsys( + chemsys, additional_criteria={"thermo_types": [thermo_type]} ) - all_entries = entries + modified_entries - - compat = None - if thermo_type == "GGA_GGA+U": - compat = MaterialsProject2020Compatibility() - elif thermo_type == "GGA_GGA+U_R2SCAN": - compat = MaterialsProjectDFTMixingScheme(run_type_2="r2SCAN") - - if compat: - all_entries = compat.process_entries(all_entries) - - pd = PhaseDiagram(all_entries) - for entry in all_entries: - if str(entry.entry_id).startswith("mod"): - for dct in rester_ehulls: - if dct["entry_id"] == entry.entry_id: - data = dct - break - assert pd.get_e_above_hull(entry) == pytest.approx( - data["e_above_hull"] - ) + modified_entries = [ + ComputedEntry( + entry.composition, + entry.uncorrected_energy + 0.01, + parameters=entry.parameters, + entry_id=f"mod_{entry.entry_id}", + ) + for entry in entries + # MIXING SCHEME - ONLY PASSES IF A "GOOD" ENTRY IS RETURNED FIRST?? + if entry.entry_id == entries[0].entry_id + ] + + if ( + all(len(entry.composition.elements) == 1 for entry in entries) + and chemsys.count("-") > 0 + ): + # For a multi-element chemsys with no multinaries, only elementals, + # there should be no phase diagram data available. + with pytest.warns( + MPRestWarning, match="No phase diagram data available" + ): + mpr.get_stability(modified_entries, thermo_type=thermo_type) + return + + else: + rester_ehulls = mpr.get_stability( + modified_entries, thermo_type=thermo_type + ) + + all_entries = entries + modified_entries + + compat = None + if thermo_type == "GGA_GGA+U": + compat = MaterialsProject2020Compatibility() + elif thermo_type == "GGA_GGA+U_R2SCAN": + compat = MaterialsProjectDFTMixingScheme(run_type_2="r2SCAN") + + if compat: + all_entries = compat.process_entries(all_entries) + + pd = PhaseDiagram(all_entries) + for entry in all_entries: + if str(entry.entry_id).startswith("mod"): + for dct in rester_ehulls: + if dct["entry_id"] == entry.entry_id: + data = dct + break + assert pd.get_e_above_hull(entry) == pytest.approx( + data["e_above_hull"] + ) @pytest.mark.parametrize( "mpid, working_ion, thermo_type", @@ -861,7 +885,7 @@ def test_warnings_exceptions(self): with MPRester() as mpr: with pytest.raises( NotImplementedError, - match="The MPRester\(\).query method has been replaced", + match=r"The MPRester\(\).query method has been replaced", ): mpr.query(some_field=1.0) @@ -872,7 +896,7 @@ def test_warnings_exceptions(self): for attr in mpr._deprecated_attributes: with pytest.warns( - DeprecationWarning, match="Accessing.*data through MPRester\..*" + DeprecationWarning, match=r"Accessing.*data through MPRester\..*" ): getattr(mpr, attr, None) From 9d805195230a053c586982aaa9b967a65c18eb81 Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:48:01 -0700 Subject: [PATCH 03/30] drop 3.11 --- .github/workflows/upgrade_dependencies.yml | 2 +- README.md | 2 +- pyproject.toml | 2 +- .../requirements-ubuntu-latest_py3.11.txt | 179 ----- ...quirements-ubuntu-latest_py3.11_extras.txt | 711 ------------------ 5 files changed, 3 insertions(+), 893 deletions(-) delete mode 100644 requirements/requirements-ubuntu-latest_py3.11.txt delete mode 100644 requirements/requirements-ubuntu-latest_py3.11_extras.txt diff --git a/.github/workflows/upgrade_dependencies.yml b/.github/workflows/upgrade_dependencies.yml index bdc98691..fef5cb45 100644 --- a/.github/workflows/upgrade_dependencies.yml +++ b/.github/workflows/upgrade_dependencies.yml @@ -11,7 +11,7 @@ jobs: matrix: os: ['ubuntu-latest'] package: ["mp-api"] - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 with: diff --git a/README.md b/README.md index 9de791ed..1044e0f9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,6 @@ [![testing](https://github.com/materialsproject/api/actions/workflows/testing.yml/badge.svg?branch=main)](https://github.com/materialsproject/api/actions?query=workflow%3Atesting+branch%3Amain) [![codecov](https://codecov.io/gh/materialsproject/api/branch/main/graph/badge.svg)](https://codecov.io/gh/materialsproject/api) -![python](https://img.shields.io/badge/Python-3.11+-blue.svg?logo=python&logoColor=white) +![python](https://img.shields.io/badge/Python-3.12+-blue.svg?logo=python&logoColor=white) This repository is the development environment for the new Materials Project API. A core client implementation will reside here. For information on how to use the API, please see the updated [documentation](https://docs.materialsproject.org/downloading-data/how-do-i-download-the-materials-project-database). diff --git a/pyproject.toml b/pyproject.toml index bca8c8d0..2b6daa32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ authors = [ ] description = "API Client for the Materials Project" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" license = "BSD-3-Clause-LBNL" classifiers = [ "Programming Language :: Python :: 3", diff --git a/requirements/requirements-ubuntu-latest_py3.11.txt b/requirements/requirements-ubuntu-latest_py3.11.txt deleted file mode 100644 index 0cb98974..00000000 --- a/requirements/requirements-ubuntu-latest_py3.11.txt +++ /dev/null @@ -1,179 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --output-file=requirements/requirements-ubuntu-latest_py3.11.txt pyproject.toml -# -annotated-types==0.8.0 - # via pydantic -arro3-core==0.8.1 - # via deltalake -bibtexparser==1.4.4 - # via pymatgen-core -blake3==1.0.9 - # via emmet-core -boto3==1.43.73 - # via mp-api (pyproject.toml) -botocore==1.43.73 - # via - # boto3 - # s3transfer -certifi==2026.7.22 - # via requests -charset-normalizer==3.5.1 - # via requests -contourpy==1.3.3 - # via matplotlib -cycler==0.12.1 - # via matplotlib -deltalake==1.5.1 - # via mp-api (pyproject.toml) -deprecated==1.3.1 - # via deltalake -emmet-core==0.87.2 - # via mp-api (pyproject.toml) -fonttools==4.63.0 - # via matplotlib -idna==3.18 - # via requests -inflect==7.5.0 - # via emmet-core -jmespath==1.1.0 - # via - # boto3 - # botocore -joblib==1.5.3 - # via pymatgen-core -kiwisolver==1.5.0 - # via matplotlib -latexcodec==3.0.1 - # via pybtex -lxml==6.1.1 - # via pymatgen-core -matplotlib==3.11.1 - # via pymatgen-core -monty==2026.7.16 - # via - # emmet-core - # mp-api (pyproject.toml) - # pymatgen-core -more-itertools==11.1.0 - # via inflect -mpmath==1.3.0 - # via sympy -narwhals==2.24.0 - # via plotly -networkx==3.6.1 - # via pymatgen-core -numpy==2.4.6 - # via - # contourpy - # matplotlib - # monty - # pandas - # pymatgen-core - # pymatgen-io-validation - # scipy - # spglib -orjson==3.12.0 - # via - # mp-api (pyproject.toml) - # pymatgen-core -packaging==26.3 - # via - # matplotlib - # plotly -palettable==3.3.3 - # via pymatgen-core -pandas==3.0.5 - # via pymatgen-core -pillow==12.3.0 - # via matplotlib -plotly==6.9.0 - # via pymatgen-core -pubchempy==1.0.5 - # via emmet-core -pyarrow==25.0.1 - # via mp-api (pyproject.toml) -pybtex==0.26.1 - # via emmet-core -pydantic==2.13.4 - # via - # emmet-core - # pydantic-settings - # pymatgen-io-validation -pydantic-core==2.46.4 - # via pydantic -pydantic-settings==2.15.0 - # via - # emmet-core - # pymatgen-io-validation -pymatgen==2026.5.4 - # via - # emmet-core - # mp-api (pyproject.toml) -pymatgen-core==2026.8.13 - # via - # pymatgen - # pymatgen-io-validation -pymatgen-io-validation==0.1.4 - # via emmet-core -pyparsing==3.3.2 - # via - # bibtexparser - # matplotlib -python-dateutil==2.9.0.post0 - # via - # botocore - # matplotlib - # pandas -python-dotenv==1.2.3 - # via pydantic-settings -pyyaml==6.0.3 - # via pybtex -requests==2.34.2 - # via - # mp-api (pyproject.toml) - # pymatgen-core - # pymatgen-io-validation -ruamel-yaml==0.19.1 - # via monty -s3transfer==0.19.2 - # via boto3 -scipy==1.17.1 - # via pymatgen-core -six==1.17.0 - # via python-dateutil -spglib==2.7.0 - # via pymatgen-core -sympy==1.14.0 - # via pymatgen-core -tabulate==0.10.0 - # via pymatgen-core -tqdm==4.70.0 - # via pymatgen-core -typeguard==4.6.0 - # via inflect -typing-extensions==4.16.0 - # via - # arro3-core - # blake3 - # emmet-core - # mp-api (pyproject.toml) - # pydantic - # pydantic-core - # spglib - # typeguard - # typing-inspection -typing-inspection==0.4.4 - # via - # pydantic - # pydantic-settings -uncertainties==3.2.3 - # via pymatgen-core -urllib3==2.8.0 - # via - # botocore - # requests -wrapt==2.3.0 - # via deprecated diff --git a/requirements/requirements-ubuntu-latest_py3.11_extras.txt b/requirements/requirements-ubuntu-latest_py3.11_extras.txt deleted file mode 100644 index 0a6920c3..00000000 --- a/requirements/requirements-ubuntu-latest_py3.11_extras.txt +++ /dev/null @@ -1,711 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --all-extras --output-file=requirements/requirements-ubuntu-latest_py3.11_extras.txt pyproject.toml -# -aiofile==3.12.3 - # via py-key-value-aio -alabaster==1.0.0 - # via sphinx -annotated-types==0.8.0 - # via pydantic -anyio==4.14.2 - # via - # httpx - # mcp - # py-key-value-aio - # sse-starlette - # starlette - # watchfiles -arro3-core==0.8.1 - # via deltalake -arrow==1.4.0 - # via isoduration -ase==3.29.0 - # via pymatgen-analysis-diffusion -ast-serialize==0.8.0 - # via mypy -asttokens==3.0.2 - # via stack-data -attrs==26.1.0 - # via - # cyclopts - # jsonschema - # jsonschema-path - # referencing -authlib==1.7.2 - # via fastmcp-slim -babel==2.18.0 - # via sphinx -backports-tarfile==1.2.0 - # via jaraco-context -beartype==0.22.9 - # via py-key-value-aio -bibtexparser==1.4.4 - # via pymatgen-core -blake3==1.0.9 - # via emmet-core -blinker==1.9.0 - # via flask -boltons==26.1.0 - # via mp-api (pyproject.toml) -boto3==1.43.73 - # via mp-api (pyproject.toml) -botocore==1.43.73 - # via - # boto3 - # s3transfer -bravado==12.0.1 - # via mp-api (pyproject.toml) -bravado-core==6.4.1 - # via bravado -cachetools==7.1.7 - # via - # mp-api (pyproject.toml) - # py-key-value-aio -caio==0.12.2 - # via aiofile -certifi==2026.7.22 - # via - # httpcore - # httpx - # requests -cffi==2.1.1 - # via cryptography -cfgv==3.5.0 - # via pre-commit -charset-normalizer==3.5.1 - # via requests -click==8.4.2 - # via - # flask - # uvicorn -contourpy==1.3.3 - # via matplotlib -coverage[toml]==7.15.4 - # via pytest-cov -cryptography==50.0.0 - # via - # authlib - # joserfc - # pyjwt - # secretstorage -custodian==2025.12.14 - # via mp-api (pyproject.toml) -cycler==0.12.1 - # via matplotlib -cyclopts==4.23.0 - # via fastmcp-slim -deltalake==1.5.1 - # via mp-api (pyproject.toml) -deprecated==1.3.1 - # via deltalake -distlib==0.4.3 - # via virtualenv -dnspython==2.8.0 - # via - # email-validator - # pyisemail - # pymongo -docstring-parser==0.18.0 - # via cyclopts -docutils==0.22.4 - # via sphinx -email-validator==2.3.0 - # via pydantic -emmet-core[all]==0.87.2 - # via mp-api (pyproject.toml) -exceptiongroup==1.3.1 - # via fastmcp-slim -execnet==2.1.2 - # via pytest-xdist -executing==2.2.1 - # via stack-data -fastmcp==3.4.7 - # via mp-api (pyproject.toml) -fastmcp-slim[client,server]==3.4.7 - # via fastmcp -filelock==3.32.3 - # via - # python-discovery - # virtualenv -filetype==1.2.0 - # via mp-api (pyproject.toml) -flake8==7.3.0 - # via mp-api (pyproject.toml) -flask==3.1.3 - # via mp-api (pyproject.toml) -flexcache==0.3 - # via pint -flexparser==0.4 - # via pint -fonttools==4.63.0 - # via matplotlib -fqdn==1.5.1 - # via jsonschema -griffelib==2.2.0 - # via fastmcp-slim -h11==0.16.0 - # via - # httpcore - # uvicorn -h5py==3.16.0 - # via phonopy -httpcore==1.0.9 - # via httpx -httpx==0.28.1 - # via - # fastmcp-slim - # mcp -httpx-sse==0.4.3 - # via mcp -identify==2.6.19 - # via pre-commit -idna==3.18 - # via - # anyio - # email-validator - # httpx - # jsonschema - # requests -imageio==2.37.4 - # via scikit-image -imagesize==2.0.0 - # via sphinx -importlib-metadata==9.0.0 - # via keyring -importlib-resources==7.1.0 - # via swagger-spec-validator -inflect==7.5.0 - # via emmet-core -iniconfig==2.3.0 - # via pytest -ipython==9.16.1 - # via mp-api (pyproject.toml) -ipython-pygments-lexers==1.1.1 - # via ipython -isoduration==20.11.0 - # via jsonschema -itsdangerous==2.2.0 - # via flask -jaraco-classes==3.4.0 - # via keyring -jaraco-context==6.1.2 - # via keyring -jaraco-functools==4.6.0 - # via keyring -jedi==0.20.0 - # via ipython -jeepney==0.9.0 - # via - # keyring - # secretstorage -jinja2==3.1.6 - # via - # flask - # sphinx -jmespath==1.1.0 - # via - # boto3 - # botocore -joblib==1.5.3 - # via - # pymatgen-analysis-diffusion - # pymatgen-core -joserfc==1.7.4 - # via - # authlib - # fastmcp-slim -json2html==1.3.0 - # via mp-api (pyproject.toml) -jsonpointer==3.1.1 - # via jsonschema -jsonref==1.1.0 - # via - # bravado-core - # fastmcp-slim -jsonschema[format-nongpl]==4.26.0 - # via - # bravado-core - # mcp - # swagger-spec-validator -jsonschema-path==0.5.0 - # via fastmcp-slim -jsonschema-specifications==2025.9.1 - # via jsonschema -keyring==25.7.0 - # via py-key-value-aio -kiwisolver==1.5.0 - # via matplotlib -lark==1.3.1 - # via rfc3987-syntax -latexcodec==3.0.1 - # via pybtex -lazy-loader==0.5 - # via scikit-image -librt==0.15.0 - # via mypy -lobsterpy==0.6.1 - # via emmet-core -lxml==6.1.1 - # via pymatgen-core -markdown-it-py==4.2.0 - # via rich -markupsafe==3.0.3 - # via - # flask - # jinja2 - # werkzeug -matplotlib==3.11.1 - # via - # ase - # phonopy - # pymatgen-core - # seaborn -matplotlib-inline==0.2.2 - # via ipython -mccabe==0.7.0 - # via flake8 -mcp==1.29.0 - # via fastmcp-slim -mdurl==0.1.2 - # via markdown-it-py -monotonic==1.6 - # via bravado -monty==2026.7.16 - # via - # custodian - # emmet-core - # mp-api (pyproject.toml) - # pymatgen-core -more-itertools==11.1.0 - # via - # inflect - # jaraco-classes - # jaraco-functools -mp-pyrho==0.5.1 - # via pymatgen-analysis-defects -mpmath==1.3.0 - # via sympy -msgpack==1.2.1 - # via - # bravado - # bravado-core -mypy==2.3.1 - # via mp-api (pyproject.toml) -mypy-extensions==1.1.0 - # via - # mp-api (pyproject.toml) - # mypy -narwhals==2.24.0 - # via plotly -networkx==3.6.1 - # via - # pymatgen-core - # scikit-image -nodeenv==1.10.0 - # via pre-commit -numpy==2.4.6 - # via - # ase - # contourpy - # h5py - # imageio - # lobsterpy - # matplotlib - # monty - # pandas - # phonopy - # pymatgen-analysis-defects - # pymatgen-analysis-diffusion - # pymatgen-core - # pymatgen-io-validation - # scikit-image - # scipy - # seaborn - # seekpath - # shapely - # spglib - # symfc - # tifffile -openapi-pydantic==0.5.1 - # via fastmcp-slim -opentelemetry-api==1.44.0 - # via fastmcp-slim -orjson==3.12.0 - # via - # mp-api (pyproject.toml) - # pymatgen-core -packaging==26.3 - # via - # fastmcp-slim - # lazy-loader - # matplotlib - # plotly - # pytest - # scikit-image - # sphinx -palettable==3.3.3 - # via pymatgen-core -pandas==3.0.5 - # via - # pymatgen-core - # seaborn -parso==0.8.7 - # via jedi -pathable==0.6.0 - # via jsonschema-path -pathspec==1.1.1 - # via mypy -pexpect==4.9.0 - # via ipython -phonopy==4.4.0 - # via emmet-core -phonors==0.3.0 - # via phonopy -pillow==12.3.0 - # via - # imageio - # matplotlib - # scikit-image -pint==0.25.3 - # via mp-api (pyproject.toml) -platformdirs==4.11.3 - # via - # fastmcp-slim - # pint - # virtualenv -plotly==6.9.0 - # via pymatgen-core -pluggy==1.6.0 - # via - # pytest - # pytest-cov -pre-commit==4.6.2 - # via mp-api (pyproject.toml) -prompt-toolkit==3.0.53 - # via ipython -psutil==7.2.2 - # via - # custodian - # ipython -ptyprocess==0.7.0 - # via pexpect -pubchempy==1.0.5 - # via emmet-core -pure-eval==0.2.3 - # via stack-data -py-key-value-aio[filetree,keyring,memory]==0.4.5 - # via fastmcp-slim -pyarrow==25.0.1 - # via - # emmet-core - # mp-api (pyproject.toml) -pybtex==0.26.1 - # via emmet-core -pycodcif==3.0.1 - # via emmet-core -pycodestyle==2.14.0 - # via - # flake8 - # mp-api (pyproject.toml) -pycparser==3.0 - # via cffi -pydantic[email]==2.13.4 - # via - # emmet-core - # fastmcp-slim - # mcp - # openapi-pydantic - # pydantic-settings - # pymatgen-io-validation -pydantic-core==2.46.4 - # via pydantic -pydantic-settings==2.15.0 - # via - # emmet-core - # fastmcp-slim - # mcp - # pymatgen-io-validation -pyflakes==3.4.0 - # via flake8 -pygments==2.21.0 - # via - # ipython - # ipython-pygments-lexers - # pytest - # rich - # rich-rst - # sphinx -pyisemail==2.0.1 - # via mp-api (pyproject.toml) -pyjwt[crypto]==2.15.1 - # via mcp -pymatgen==2026.5.4 - # via - # emmet-core - # lobsterpy - # mp-api (pyproject.toml) - # mp-pyrho - # pymatgen-analysis-alloys - # pymatgen-analysis-defects - # pymatgen-analysis-diffusion -pymatgen-analysis-alloys==0.0.9 - # via emmet-core -pymatgen-analysis-defects==2026.3.20 - # via emmet-core -pymatgen-analysis-diffusion==2025.11.15 - # via emmet-core -pymatgen-core==2026.7.16 - # via - # emmet-core - # pymatgen - # pymatgen-io-validation -pymatgen-io-validation==0.1.4 - # via emmet-core -pymongo==4.17.0 - # via - # emmet-core - # mp-api (pyproject.toml) -pyparsing==3.3.2 - # via - # bibtexparser - # matplotlib -pyperclip==1.11.0 - # via fastmcp-slim -pytest==9.1.1 - # via - # mp-api (pyproject.toml) - # pytest-asyncio - # pytest-cov - # pytest-mock - # pytest-xdist -pytest-asyncio==1.4.0 - # via mp-api (pyproject.toml) -pytest-cov==7.1.0 - # via mp-api (pyproject.toml) -pytest-mock==3.15.1 - # via mp-api (pyproject.toml) -pytest-xdist==3.8.0 - # via mp-api (pyproject.toml) -python-dateutil==2.9.0.post0 - # via - # arrow - # botocore - # bravado - # bravado-core - # matplotlib - # pandas -python-discovery==1.5.2 - # via virtualenv -python-dotenv==1.2.3 - # via - # fastmcp-slim - # pydantic-settings -python-multipart==0.0.32 - # via - # fastmcp-slim - # mcp -pytz==2026.3.post1 - # via bravado-core -pyyaml==6.0.3 - # via - # bravado - # bravado-core - # fastmcp-slim - # jsonschema-path - # phonopy - # pre-commit - # pybtex - # swagger-spec-validator -referencing==0.37.0 - # via - # jsonschema - # jsonschema-path - # jsonschema-specifications -requests==2.34.2 - # via - # bravado - # bravado-core - # mp-api (pyproject.toml) - # pymatgen-core - # pymatgen-io-validation - # requests-futures - # sphinx -requests-futures==1.0.2 - # via mp-api (pyproject.toml) -rfc3339-validator==0.1.4 - # via jsonschema -rfc3986-validator==0.1.1 - # via jsonschema -rfc3987-syntax==1.1.0 - # via jsonschema -rich==15.0.0 - # via - # cyclopts - # fastmcp-slim - # rich-rst -rich-rst==2.1.0 - # via cyclopts -roman-numerals==4.1.0 - # via sphinx -rpds-py==2026.6.3 - # via - # jsonschema - # referencing -ruamel-yaml==0.19.1 - # via - # custodian - # monty -s3transfer==0.19.2 - # via boto3 -scikit-image==0.26.0 - # via pymatgen-analysis-defects -scipy==1.17.1 - # via - # ase - # pymatgen-core - # scikit-image - # symfc -seaborn==0.13.2 - # via pymatgen-analysis-diffusion -secretstorage==3.5.0 - # via keyring -seekpath==2.2.1 - # via emmet-core -shapely==2.1.2 - # via pymatgen-analysis-alloys -simplejson==4.1.1 - # via - # bravado - # bravado-core -six==1.17.0 - # via - # bravado - # bravado-core - # python-dateutil - # rfc3339-validator -snowballstemmer==3.1.1 - # via sphinx -spglib==2.7.0 - # via - # phonopy - # pymatgen-core - # seekpath - # symfc -sphinx==9.0.4 - # via mp-api (pyproject.toml) -sphinxcontrib-applehelp==2.0.0 - # via sphinx -sphinxcontrib-devhelp==2.0.0 - # via sphinx -sphinxcontrib-htmlhelp==2.1.0 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==2.0.0 - # via sphinx -sphinxcontrib-serializinghtml==2.0.0 - # via sphinx -sse-starlette==3.4.8 - # via mcp -stack-data==0.6.3 - # via ipython -starlette==1.6.0 - # via - # fastmcp-slim - # mcp - # sse-starlette -swagger-spec-validator==3.0.4 - # via - # bravado-core - # mp-api (pyproject.toml) -symfc==1.7.3 - # via phonopy -sympy==1.14.0 - # via pymatgen-core -tabulate==0.10.0 - # via pymatgen-core -tifffile==2026.3.3 - # via scikit-image -tqdm==4.70.0 - # via pymatgen-core -traitlets==5.16.1 - # via - # ipython - # matplotlib-inline -typeguard==4.6.0 - # via inflect -types-requests==2.33.0.20260712 - # via mp-api (pyproject.toml) -types-setuptools==84.0.0.20260812 - # via mp-api (pyproject.toml) -types-tqdm==4.70.0.20260805 - # via mp-api (pyproject.toml) -typing-extensions==4.16.0 - # via - # anyio - # arro3-core - # ase - # blake3 - # bravado - # emmet-core - # exceptiongroup - # fastmcp-slim - # flexcache - # flexparser - # ipython - # mcp - # mp-api (pyproject.toml) - # mypy - # opentelemetry-api - # pint - # py-key-value-aio - # pydantic - # pydantic-core - # pytest-asyncio - # referencing - # spglib - # starlette - # swagger-spec-validator - # typeguard - # typing-inspection -typing-inspection==0.4.4 - # via - # mcp - # pydantic - # pydantic-settings -tzdata==2026.3 - # via arrow -uncalled-for==0.4.0 - # via fastmcp-slim -uncertainties==3.2.3 - # via pymatgen-core -uri-template==1.3.0 - # via jsonschema -urllib3==2.8.0 - # via - # botocore - # requests - # types-requests -uvicorn==0.52.3 - # via - # fastmcp-slim - # mcp -virtualenv==21.7.13 - # via pre-commit -watchfiles==1.2.0 - # via fastmcp-slim -wcwidth==0.8.2 - # via prompt-toolkit -webcolors==25.10.0 - # via jsonschema -websockets==17.0.1 - # via fastmcp-slim -werkzeug==3.1.8 - # via flask -wrapt==2.3.0 - # via deprecated -zipp==4.1.0 - # via importlib-metadata From b3915cff049249da1a290d86b27d667f13df3100 Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:02:23 -0700 Subject: [PATCH 04/30] setuptools -> hatchling --- pyproject.toml | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2b6daa32..5bbf0739 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,31 @@ [build-system] -requires = ["setuptools", "setuptools-scm"] -build-backend = "setuptools.build_meta" +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.hatch.build.targets.wheel] +only-include = ["mp_api"] +dev-mode-dirs = ["."] + +[tool.hatch.build.targets.sdist] +include = [ + "mp_api", + "tests", + "pyproject.toml", +] [project] name = "mp-api" +dynamic = ["version"] +requires-python = ">=3.12" +description = "API Client for the Materials Project" +license = "BSD-3-Clause-LBNL" authors = [ { name = "The Materials Project", email = "feedback@materialsproject.org" }, ] -description = "API Client for the Materials Project" -readme = "README.md" -requires-python = ">=3.12" -license = "BSD-3-Clause-LBNL" + classifiers = [ "Programming Language :: Python :: 3", "Development Status :: 4 - Beta", @@ -19,6 +34,7 @@ classifiers = [ "Operating System :: OS Independent", "Topic :: Scientific/Engineering", ] + dependencies = [ "pymatgen>2024.2.20", "typing-extensions>=3.7.4.1", @@ -30,7 +46,6 @@ dependencies = [ "pyarrow >= 20.0.0", "deltalake>=1.4.0,<1.6.0", ] -dynamic = ["version"] [project.optional-dependencies] mcp = ["fastmcp"] @@ -75,12 +90,6 @@ docs = ["sphinx"] [project.scripts] mpmcp = "mp_api.mcp.server:_run_mp_mcp_server" -[tool.setuptools.packages.find] -include = ["mp_api*"] -namespaces = true - -[tool.setuptools_scm] - [tool.pytest.ini_options] minversion = "6.0" addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] From c63ebed1b11fd38ce8c703449d5bd97faa4ab9a5 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 2 Oct 2026 21:54:16 +0000 Subject: [PATCH 05/30] auto dependency upgrades --- .../requirements-ubuntu-latest_py3.12.txt | 61 ++--- ...quirements-ubuntu-latest_py3.12_extras.txt | 213 +++++++++--------- .../requirements-ubuntu-latest_py3.13.txt | 61 ++--- ...quirements-ubuntu-latest_py3.13_extras.txt | 213 +++++++++--------- .../requirements-ubuntu-latest_py3.14.txt | 61 ++--- ...quirements-ubuntu-latest_py3.14_extras.txt | 213 +++++++++--------- 6 files changed, 432 insertions(+), 390 deletions(-) diff --git a/requirements/requirements-ubuntu-latest_py3.12.txt b/requirements/requirements-ubuntu-latest_py3.12.txt index 7dfa7c17..e353464f 100644 --- a/requirements/requirements-ubuntu-latest_py3.12.txt +++ b/requirements/requirements-ubuntu-latest_py3.12.txt @@ -2,39 +2,41 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --output-file=requirements/requirements-ubuntu-latest_py3.12.txt pyproject.toml +# pip-compile --no-index --output-file=requirements/requirements-ubuntu-latest_py3.12.txt pyproject.toml # annotated-types==0.8.0 # via pydantic -arro3-core==0.8.1 +arro3-core==0.9.0 # via deltalake bibtexparser==1.4.4 # via pymatgen-core -blake3==1.0.9 +blake3==1.0.10 # via emmet-core -boto3==1.43.73 +boto3==1.43.108 # via mp-api (pyproject.toml) -botocore==1.43.73 +botocore==1.43.108 # via # boto3 # s3transfer certifi==2026.7.22 # via requests -charset-normalizer==3.5.1 +charset-normalizer==3.5.2 # via requests -contourpy==1.3.3 +cloudpickle==3.1.2 + # via joblib +contourpy==1.4.0 # via matplotlib cycler==0.12.1 # via matplotlib deltalake==1.5.1 # via mp-api (pyproject.toml) -deprecated==1.3.1 +deprecated==3.0.0 # via deltalake -emmet-core==0.87.2 +emmet-core==0.87.3rc2 # via mp-api (pyproject.toml) -fonttools==4.63.0 +fonttools==4.66.1 # via matplotlib -idna==3.18 +idna==3.20 # via requests inflect==7.5.0 # via emmet-core @@ -42,15 +44,15 @@ jmespath==1.1.0 # via # boto3 # botocore -joblib==1.5.3 +joblib==1.6.0 # via pymatgen-core -kiwisolver==1.5.0 +kiwisolver==1.5.1 # via matplotlib latexcodec==3.0.1 # via pybtex -lxml==6.1.1 +lxml==6.1.3 # via pymatgen-core -matplotlib==3.11.1 +matplotlib==3.11.2 # via pymatgen-core monty==2026.7.16 # via @@ -61,11 +63,11 @@ more-itertools==11.1.0 # via inflect mpmath==1.3.0 # via sympy -narwhals==2.24.0 +narwhals==2.26.0 # via plotly -networkx==3.6.1 +networkx==3.7 # via pymatgen-core -numpy==2.5.2 +numpy==2.5.3 # via # contourpy # matplotlib @@ -85,11 +87,11 @@ packaging==26.3 # plotly palettable==3.3.3 # via pymatgen-core -pandas==3.0.5 +pandas==3.0.6 # via pymatgen-core pillow==12.3.0 # via matplotlib -plotly==6.9.0 +plotly==7.1.0 # via pymatgen-core pubchempy==1.0.5 # via emmet-core @@ -97,28 +99,29 @@ pyarrow==25.0.1 # via mp-api (pyproject.toml) pybtex==0.26.1 # via emmet-core -pydantic==2.13.4 +pydantic==2.13.5 # via # emmet-core # pydantic-settings # pymatgen-io-validation -pydantic-core==2.46.4 +pydantic-core==2.46.5 # via pydantic pydantic-settings==2.15.0 # via # emmet-core # pymatgen-io-validation -pymatgen==2026.5.4 +pymatgen==2026.9.24 # via # emmet-core # mp-api (pyproject.toml) -pymatgen-core==2026.8.13 +pymatgen-core==2026.10.2 # via + # emmet-core # pymatgen # pymatgen-io-validation pymatgen-io-validation==0.1.4 # via emmet-core -pyparsing==3.3.2 +pyparsing==3.3.3 # via # bibtexparser # matplotlib @@ -127,7 +130,7 @@ python-dateutil==2.9.0.post0 # botocore # matplotlib # pandas -python-dotenv==1.2.3 +python-dotenv==1.2.4 # via pydantic-settings pyyaml==6.0.3 # via pybtex @@ -140,7 +143,7 @@ ruamel-yaml==0.19.1 # via monty s3transfer==0.19.2 # via boto3 -scipy==1.18.0 +scipy==1.18.1 # via pymatgen-core six==1.17.0 # via python-dateutil @@ -150,7 +153,7 @@ sympy==1.14.0 # via pymatgen-core tabulate==0.10.0 # via pymatgen-core -tqdm==4.70.0 +tqdm==4.70.1 # via pymatgen-core typeguard==4.6.0 # via inflect @@ -173,5 +176,5 @@ urllib3==2.8.0 # via # botocore # requests -wrapt==2.3.0 +wrapt==2.5.0 # via deprecated diff --git a/requirements/requirements-ubuntu-latest_py3.12_extras.txt b/requirements/requirements-ubuntu-latest_py3.12_extras.txt index ee4c38ff..711b9139 100644 --- a/requirements/requirements-ubuntu-latest_py3.12_extras.txt +++ b/requirements/requirements-ubuntu-latest_py3.12_extras.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --all-extras --output-file=requirements/requirements-ubuntu-latest_py3.12_extras.txt pyproject.toml +# pip-compile --all-extras --no-index --output-file=requirements/requirements-ubuntu-latest_py3.12_extras.txt pyproject.toml # aiofile==3.12.3 # via py-key-value-aio @@ -10,21 +10,21 @@ alabaster==1.0.0 # via sphinx annotated-types==0.8.0 # via pydantic -anyio==4.14.2 +anyio==4.15.1 # via - # httpx + # httpx2 # mcp # py-key-value-aio # sse-starlette # starlette # watchfiles -arro3-core==0.8.1 +arro3-core==0.9.0 # via deltalake arrow==1.4.0 # via isoduration ase==3.29.0 # via pymatgen-analysis-diffusion -ast-serialize==0.8.0 +ast-serialize==0.12.0 # via mypy asttokens==3.0.2 # via stack-data @@ -34,7 +34,7 @@ attrs==26.1.0 # jsonschema # jsonschema-path # referencing -authlib==1.7.2 +authlib==1.8.0 # via fastmcp-slim babel==2.18.0 # via sphinx @@ -42,15 +42,15 @@ beartype==0.22.9 # via py-key-value-aio bibtexparser==1.4.4 # via pymatgen-core -blake3==1.0.9 +blake3==1.0.10 # via emmet-core blinker==1.9.0 # via flask -boltons==26.1.0 +boltons==26.2.0 # via mp-api (pyproject.toml) -boto3==1.43.73 +boto3==1.43.108 # via mp-api (pyproject.toml) -botocore==1.43.73 +botocore==1.43.108 # via # boto3 # s3transfer @@ -58,32 +58,31 @@ bravado==12.0.1 # via mp-api (pyproject.toml) bravado-core==6.4.1 # via bravado -cachetools==7.1.7 +cachetools==7.2.0 # via # mp-api (pyproject.toml) # py-key-value-aio -caio==0.12.2 +caio==0.12.9 # via aiofile certifi==2026.7.22 - # via - # httpcore - # httpx - # requests + # via requests cffi==2.1.1 # via cryptography cfgv==3.5.0 # via pre-commit -charset-normalizer==3.5.1 +charset-normalizer==3.5.2 # via requests -click==8.4.2 +click==8.5.0 # via # flask # uvicorn -contourpy==1.3.3 +cloudpickle==3.1.2 + # via joblib +contourpy==1.4.0 # via matplotlib -coverage[toml]==7.15.4 +coverage[toml]==7.16.2 # via pytest-cov -cryptography==50.0.0 +cryptography==50.0.2 # via # authlib # joserfc @@ -93,11 +92,11 @@ custodian==2025.12.14 # via mp-api (pyproject.toml) cycler==0.12.1 # via matplotlib -cyclopts==4.23.0 +cyclopts==5.1.1 # via fastmcp-slim deltalake==1.5.1 # via mp-api (pyproject.toml) -deprecated==1.3.1 +deprecated==3.0.0 # via deltalake distlib==0.4.3 # via virtualenv @@ -112,7 +111,7 @@ docutils==0.22.4 # via sphinx email-validator==2.3.0 # via pydantic -emmet-core[all]==0.87.2 +emmet-core[all]==0.87.3rc2 # via mp-api (pyproject.toml) exceptiongroup==1.3.1 # via fastmcp-slim @@ -120,17 +119,17 @@ execnet==2.1.2 # via pytest-xdist executing==2.2.1 # via stack-data -fastmcp==3.4.7 +fastmcp==4.0.10 # via mp-api (pyproject.toml) -fastmcp-slim[client,server]==3.4.7 +fastmcp-slim[client,server]==4.0.10 # via fastmcp -filelock==3.32.3 +filelock==4.0.9 # via # python-discovery # virtualenv filetype==1.2.0 # via mp-api (pyproject.toml) -flake8==7.3.0 +flake8==7.4.1 # via mp-api (pyproject.toml) flask==3.1.3 # via mp-api (pyproject.toml) @@ -138,38 +137,36 @@ flexcache==0.3 # via pint flexparser==0.4 # via pint -fonttools==4.63.0 +fonttools==4.66.1 # via matplotlib -fqdn==1.5.1 +fqdn==1.6.0 # via jsonschema -griffelib==2.2.0 +griffelib==2.3.0 # via fastmcp-slim h11==0.16.0 # via - # httpcore + # httpcore2 # uvicorn h5py==3.16.0 # via phonopy -httpcore==1.0.9 - # via httpx -httpx==0.28.1 +httpcore2==2.13.1 + # via httpx2 +httpx2==2.13.1 # via # fastmcp-slim # mcp -httpx-sse==0.4.3 - # via mcp -identify==2.6.19 +identify==2.6.20 # via pre-commit -idna==3.18 +idna==3.20 # via # anyio # email-validator - # httpx + # httpx2 # jsonschema # requests -imageio==2.37.4 +imageio==2.38.0 # via scikit-image -imagesize==2.0.0 +imagesize==2.0.1 # via sphinx importlib-resources==7.1.0 # via swagger-spec-validator @@ -177,7 +174,7 @@ inflect==7.5.0 # via emmet-core iniconfig==2.3.0 # via pytest -ipython==9.16.1 +ipython==9.17.1 # via mp-api (pyproject.toml) ipython-pygments-lexers==1.1.1 # via ipython @@ -205,11 +202,11 @@ jmespath==1.1.0 # via # boto3 # botocore -joblib==1.5.3 +joblib==1.6.0 # via # pymatgen-analysis-diffusion # pymatgen-core -joserfc==1.7.4 +joserfc==1.7.5 # via # authlib # fastmcp-slim @@ -232,19 +229,19 @@ jsonschema-specifications==2025.9.1 # via jsonschema keyring==25.7.0 # via py-key-value-aio -kiwisolver==1.5.0 +kiwisolver==1.5.1 # via matplotlib lark==1.3.1 # via rfc3987-syntax latexcodec==3.0.1 # via pybtex -lazy-loader==0.5 +lazy-loader==0.6 # via scikit-image -librt==0.15.0 +librt==0.16.0 # via mypy lobsterpy==0.6.1 # via emmet-core -lxml==6.1.1 +lxml==6.1.3 # via pymatgen-core markdown-it-py==4.2.0 # via rich @@ -253,7 +250,7 @@ markupsafe==3.0.3 # flask # jinja2 # werkzeug -matplotlib==3.11.1 +matplotlib==3.11.2 # via # ase # phonopy @@ -263,8 +260,12 @@ matplotlib-inline==0.2.2 # via ipython mccabe==0.7.0 # via flake8 -mcp==1.29.0 +mcp==2.2.0 # via fastmcp-slim +mcp-types==2.2.0 + # via + # fastmcp-slim + # mcp mdurl==0.1.2 # via markdown-it-py monotonic==1.6 @@ -284,25 +285,25 @@ mp-pyrho==0.5.1 # via pymatgen-analysis-defects mpmath==1.3.0 # via sympy -msgpack==1.2.1 +msgpack==1.2.3 # via # bravado # bravado-core -mypy==2.3.1 +mypy==2.4.0 # via mp-api (pyproject.toml) mypy-extensions==1.1.0 # via # mp-api (pyproject.toml) # mypy -narwhals==2.24.0 +narwhals==2.26.0 # via plotly -networkx==3.6.1 +networkx==3.7 # via # pymatgen-core # scikit-image -nodeenv==1.10.0 +nodeenv==1.11.0 # via pre-commit -numpy==2.5.2 +numpy==2.5.3 # via # ase # contourpy @@ -325,10 +326,12 @@ numpy==2.5.2 # spglib # symfc # tifffile -openapi-pydantic==0.5.1 - # via fastmcp-slim -opentelemetry-api==1.44.0 +openapi-pydantic==0.6.0 # via fastmcp-slim +opentelemetry-api==1.45.0 + # via + # fastmcp-slim + # mcp orjson==3.12.0 # via # mp-api (pyproject.toml) @@ -341,10 +344,12 @@ packaging==26.3 # plotly # pytest # scikit-image + # seekpath # sphinx + # virtualenv palettable==3.3.3 # via pymatgen-core -pandas==3.0.5 +pandas==3.0.6 # via # pymatgen-core # seaborn @@ -356,23 +361,23 @@ pathspec==1.1.1 # via mypy pexpect==4.9.0 # via ipython -phonopy==4.4.0 +phonopy==4.8.0 # via emmet-core -phonors==0.3.0 +phonors==0.6.0 # via phonopy pillow==12.3.0 # via # imageio # matplotlib # scikit-image -pint==0.25.3 +pint==0.26.1 # via mp-api (pyproject.toml) -platformdirs==4.11.3 +platformdirs==4.12.2 # via # fastmcp-slim # pint # virtualenv -plotly==6.9.0 +plotly==7.1.0 # via pymatgen-core pluggy==1.6.0 # via @@ -390,9 +395,9 @@ ptyprocess==0.7.0 # via pexpect pubchempy==1.0.5 # via emmet-core -pure-eval==0.2.3 +pure-eval==0.2.4 # via stack-data -py-key-value-aio[filetree,keyring,memory]==0.4.5 +py-key-value-aio[filetree,keyring,memory]==0.4.6 # via fastmcp-slim pyarrow==25.0.1 # via @@ -402,29 +407,29 @@ pybtex==0.26.1 # via emmet-core pycodcif==3.0.1 # via emmet-core -pycodestyle==2.14.0 +pycodestyle==2.15.0 # via # flake8 # mp-api (pyproject.toml) pycparser==3.0 # via cffi -pydantic[email]==2.13.4 +pydantic[email]==2.13.5 # via # emmet-core # fastmcp-slim # mcp + # mcp-types # openapi-pydantic # pydantic-settings # pymatgen-io-validation -pydantic-core==2.46.4 +pydantic-core==2.46.5 # via pydantic pydantic-settings==2.15.0 # via # emmet-core # fastmcp-slim - # mcp # pymatgen-io-validation -pyflakes==3.4.0 +pyflakes==4.0.1 # via flake8 pygments==2.21.0 # via @@ -438,7 +443,7 @@ pyisemail==2.0.1 # via mp-api (pyproject.toml) pyjwt[crypto]==2.15.1 # via mcp -pymatgen==2026.5.4 +pymatgen==2026.9.24 # via # emmet-core # lobsterpy @@ -451,20 +456,20 @@ pymatgen-analysis-alloys==0.0.9 # via emmet-core pymatgen-analysis-defects==2026.3.20 # via emmet-core -pymatgen-analysis-diffusion==2025.11.15 +pymatgen-analysis-diffusion==2026.9.23 # via emmet-core -pymatgen-core==2026.7.16 +pymatgen-core==2026.10.2 # via # emmet-core # pymatgen # pymatgen-io-validation pymatgen-io-validation==0.1.4 # via emmet-core -pymongo==4.17.0 +pymongo==4.18.2 # via # emmet-core # mp-api (pyproject.toml) -pyparsing==3.3.2 +pyparsing==3.3.3 # via # bibtexparser # matplotlib @@ -481,7 +486,7 @@ pytest-asyncio==1.4.0 # via mp-api (pyproject.toml) pytest-cov==7.1.0 # via mp-api (pyproject.toml) -pytest-mock==3.15.1 +pytest-mock==3.16.0 # via mp-api (pyproject.toml) pytest-xdist==3.8.0 # via mp-api (pyproject.toml) @@ -493,9 +498,9 @@ python-dateutil==2.9.0.post0 # bravado-core # matplotlib # pandas -python-discovery==1.5.2 +python-discovery==1.6.1 # via virtualenv -python-dotenv==1.2.3 +python-dotenv==1.2.4 # via # fastmcp-slim # pydantic-settings @@ -503,7 +508,7 @@ python-multipart==0.0.32 # via # fastmcp-slim # mcp -pytz==2026.3.post1 +pytz==2026.4 # via bravado-core pyyaml==6.0.3 # via @@ -529,7 +534,7 @@ requests==2.34.2 # pymatgen-io-validation # requests-futures # sphinx -requests-futures==1.0.2 +requests-futures==1.1.0 # via mp-api (pyproject.toml) rfc3339-validator==0.1.4 # via jsonschema @@ -542,7 +547,7 @@ rich==15.0.0 # cyclopts # fastmcp-slim # rich-rst -rich-rst==2.1.0 +rich-rst==2.2.0 # via cyclopts roman-numerals==4.1.0 # via sphinx @@ -558,7 +563,7 @@ s3transfer==0.19.2 # via boto3 scikit-image==0.26.0 # via pymatgen-analysis-defects -scipy==1.18.0 +scipy==1.18.1 # via # ase # pymatgen-core @@ -568,11 +573,11 @@ seaborn==0.13.2 # via pymatgen-analysis-diffusion secretstorage==3.5.0 # via keyring -seekpath==2.2.1 +seekpath==2.2.2 # via emmet-core shapely==2.1.2 # via pymatgen-analysis-alloys -simplejson==4.1.1 +simplejson==4.1.2 # via # bravado # bravado-core @@ -604,11 +609,11 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sse-starlette==3.4.8 +sse-starlette==3.5.0 # via mcp stack-data==0.6.3 # via ipython -starlette==1.6.0 +starlette==1.7.0 # via # fastmcp-slim # mcp @@ -623,21 +628,25 @@ sympy==1.14.0 # via pymatgen-core tabulate==0.10.0 # via pymatgen-core -tifffile==2026.8.16 +tifffile==2026.9.20 # via scikit-image -tqdm==4.70.0 +tqdm==4.70.1 # via pymatgen-core traitlets==5.16.1 # via # ipython # matplotlib-inline +truststore==0.10.4 + # via + # httpcore2 + # httpx2 typeguard==4.6.0 # via inflect -types-requests==2.33.0.20260712 +types-requests==2.33.0.20260906 # via mp-api (pyproject.toml) types-setuptools==84.0.0.20260812 # via mp-api (pyproject.toml) -types-tqdm==4.70.0.20260805 +types-tqdm==4.70.0.20260906 # via mp-api (pyproject.toml) typing-extensions==4.16.0 # via @@ -649,7 +658,9 @@ typing-extensions==4.16.0 # fastmcp-slim # flexcache # flexparser + # httpx2 # mcp + # mcp-types # mp-api (pyproject.toml) # mypy # opentelemetry-api @@ -669,7 +680,7 @@ typing-inspection==0.4.4 # mcp # pydantic # pydantic-settings -tzdata==2026.3 +tzdata==2026.4 # via arrow uncalled-for==0.4.0 # via fastmcp-slim @@ -682,21 +693,21 @@ urllib3==2.8.0 # botocore # requests # types-requests -uvicorn==0.52.3 +uvicorn==0.54.0 # via # fastmcp-slim # mcp -virtualenv==21.7.13 +virtualenv==21.14.5 # via pre-commit -watchfiles==1.2.0 +watchfiles==1.3.0 # via fastmcp-slim -wcwidth==0.8.2 +wcwidth==0.9.1 # via prompt-toolkit webcolors==25.10.0 # via jsonschema -websockets==17.0.1 +websockets==17.1 # via fastmcp-slim -werkzeug==3.1.8 +werkzeug==3.1.9 # via flask -wrapt==2.3.0 +wrapt==2.5.0 # via deprecated diff --git a/requirements/requirements-ubuntu-latest_py3.13.txt b/requirements/requirements-ubuntu-latest_py3.13.txt index 049b4955..742dd107 100644 --- a/requirements/requirements-ubuntu-latest_py3.13.txt +++ b/requirements/requirements-ubuntu-latest_py3.13.txt @@ -2,39 +2,41 @@ # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # -# pip-compile --output-file=requirements/requirements-ubuntu-latest_py3.13.txt pyproject.toml +# pip-compile --no-index --output-file=requirements/requirements-ubuntu-latest_py3.13.txt pyproject.toml # annotated-types==0.8.0 # via pydantic -arro3-core==0.8.1 +arro3-core==0.9.0 # via deltalake bibtexparser==1.4.4 # via pymatgen-core -blake3==1.0.9 +blake3==1.0.10 # via emmet-core -boto3==1.43.73 +boto3==1.43.108 # via mp-api (pyproject.toml) -botocore==1.43.73 +botocore==1.43.108 # via # boto3 # s3transfer certifi==2026.7.22 # via requests -charset-normalizer==3.5.1 +charset-normalizer==3.5.2 # via requests -contourpy==1.3.3 +cloudpickle==3.1.2 + # via joblib +contourpy==1.4.0 # via matplotlib cycler==0.12.1 # via matplotlib deltalake==1.5.1 # via mp-api (pyproject.toml) -deprecated==1.3.1 +deprecated==3.0.0 # via deltalake -emmet-core==0.87.2 +emmet-core==0.87.3rc2 # via mp-api (pyproject.toml) -fonttools==4.63.0 +fonttools==4.66.1 # via matplotlib -idna==3.18 +idna==3.20 # via requests inflect==7.5.0 # via emmet-core @@ -42,15 +44,15 @@ jmespath==1.1.0 # via # boto3 # botocore -joblib==1.5.3 +joblib==1.6.0 # via pymatgen-core -kiwisolver==1.5.0 +kiwisolver==1.5.1 # via matplotlib latexcodec==3.0.1 # via pybtex -lxml==6.1.1 +lxml==6.1.3 # via pymatgen-core -matplotlib==3.11.1 +matplotlib==3.11.2 # via pymatgen-core monty==2026.7.16 # via @@ -61,11 +63,11 @@ more-itertools==11.1.0 # via inflect mpmath==1.3.0 # via sympy -narwhals==2.24.0 +narwhals==2.26.0 # via plotly -networkx==3.6.1 +networkx==3.7 # via pymatgen-core -numpy==2.5.2 +numpy==2.5.3 # via # contourpy # matplotlib @@ -85,11 +87,11 @@ packaging==26.3 # plotly palettable==3.3.3 # via pymatgen-core -pandas==3.0.5 +pandas==3.0.6 # via pymatgen-core pillow==12.3.0 # via matplotlib -plotly==6.9.0 +plotly==7.1.0 # via pymatgen-core pubchempy==1.0.5 # via emmet-core @@ -97,28 +99,29 @@ pyarrow==25.0.1 # via mp-api (pyproject.toml) pybtex==0.26.1 # via emmet-core -pydantic==2.13.4 +pydantic==2.13.5 # via # emmet-core # pydantic-settings # pymatgen-io-validation -pydantic-core==2.46.4 +pydantic-core==2.46.5 # via pydantic pydantic-settings==2.15.0 # via # emmet-core # pymatgen-io-validation -pymatgen==2026.5.4 +pymatgen==2026.9.24 # via # emmet-core # mp-api (pyproject.toml) -pymatgen-core==2026.8.13 +pymatgen-core==2026.10.2 # via + # emmet-core # pymatgen # pymatgen-io-validation pymatgen-io-validation==0.1.4 # via emmet-core -pyparsing==3.3.2 +pyparsing==3.3.3 # via # bibtexparser # matplotlib @@ -127,7 +130,7 @@ python-dateutil==2.9.0.post0 # botocore # matplotlib # pandas -python-dotenv==1.2.3 +python-dotenv==1.2.4 # via pydantic-settings pyyaml==6.0.3 # via pybtex @@ -140,7 +143,7 @@ ruamel-yaml==0.19.1 # via monty s3transfer==0.19.2 # via boto3 -scipy==1.18.0 +scipy==1.18.1 # via pymatgen-core six==1.17.0 # via python-dateutil @@ -150,7 +153,7 @@ sympy==1.14.0 # via pymatgen-core tabulate==0.10.0 # via pymatgen-core -tqdm==4.70.0 +tqdm==4.70.1 # via pymatgen-core typeguard==4.6.0 # via inflect @@ -172,5 +175,5 @@ urllib3==2.8.0 # via # botocore # requests -wrapt==2.3.0 +wrapt==2.5.0 # via deprecated diff --git a/requirements/requirements-ubuntu-latest_py3.13_extras.txt b/requirements/requirements-ubuntu-latest_py3.13_extras.txt index 811be2b6..a1c03312 100644 --- a/requirements/requirements-ubuntu-latest_py3.13_extras.txt +++ b/requirements/requirements-ubuntu-latest_py3.13_extras.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # -# pip-compile --all-extras --output-file=requirements/requirements-ubuntu-latest_py3.13_extras.txt pyproject.toml +# pip-compile --all-extras --no-index --output-file=requirements/requirements-ubuntu-latest_py3.13_extras.txt pyproject.toml # aiofile==3.12.3 # via py-key-value-aio @@ -10,21 +10,21 @@ alabaster==1.0.0 # via sphinx annotated-types==0.8.0 # via pydantic -anyio==4.14.2 +anyio==4.15.1 # via - # httpx + # httpx2 # mcp # py-key-value-aio # sse-starlette # starlette # watchfiles -arro3-core==0.8.1 +arro3-core==0.9.0 # via deltalake arrow==1.4.0 # via isoduration ase==3.29.0 # via pymatgen-analysis-diffusion -ast-serialize==0.8.0 +ast-serialize==0.12.0 # via mypy asttokens==3.0.2 # via stack-data @@ -34,7 +34,7 @@ attrs==26.1.0 # jsonschema # jsonschema-path # referencing -authlib==1.7.2 +authlib==1.8.0 # via fastmcp-slim babel==2.18.0 # via sphinx @@ -42,15 +42,15 @@ beartype==0.22.9 # via py-key-value-aio bibtexparser==1.4.4 # via pymatgen-core -blake3==1.0.9 +blake3==1.0.10 # via emmet-core blinker==1.9.0 # via flask -boltons==26.1.0 +boltons==26.2.0 # via mp-api (pyproject.toml) -boto3==1.43.73 +boto3==1.43.108 # via mp-api (pyproject.toml) -botocore==1.43.73 +botocore==1.43.108 # via # boto3 # s3transfer @@ -58,32 +58,31 @@ bravado==12.0.1 # via mp-api (pyproject.toml) bravado-core==6.4.1 # via bravado -cachetools==7.1.7 +cachetools==7.2.0 # via # mp-api (pyproject.toml) # py-key-value-aio -caio==0.12.2 +caio==0.12.9 # via aiofile certifi==2026.7.22 - # via - # httpcore - # httpx - # requests + # via requests cffi==2.1.1 # via cryptography cfgv==3.5.0 # via pre-commit -charset-normalizer==3.5.1 +charset-normalizer==3.5.2 # via requests -click==8.4.2 +click==8.5.0 # via # flask # uvicorn -contourpy==1.3.3 +cloudpickle==3.1.2 + # via joblib +contourpy==1.4.0 # via matplotlib -coverage[toml]==7.15.4 +coverage[toml]==7.16.2 # via pytest-cov -cryptography==50.0.0 +cryptography==50.0.2 # via # authlib # joserfc @@ -93,11 +92,11 @@ custodian==2025.12.14 # via mp-api (pyproject.toml) cycler==0.12.1 # via matplotlib -cyclopts==4.23.0 +cyclopts==5.1.1 # via fastmcp-slim deltalake==1.5.1 # via mp-api (pyproject.toml) -deprecated==1.3.1 +deprecated==3.0.0 # via deltalake distlib==0.4.3 # via virtualenv @@ -112,7 +111,7 @@ docutils==0.22.4 # via sphinx email-validator==2.3.0 # via pydantic -emmet-core[all]==0.87.2 +emmet-core[all]==0.87.3rc2 # via mp-api (pyproject.toml) exceptiongroup==1.3.1 # via fastmcp-slim @@ -120,17 +119,17 @@ execnet==2.1.2 # via pytest-xdist executing==2.2.1 # via stack-data -fastmcp==3.4.7 +fastmcp==4.0.10 # via mp-api (pyproject.toml) -fastmcp-slim[client,server]==3.4.7 +fastmcp-slim[client,server]==4.0.10 # via fastmcp -filelock==3.32.3 +filelock==4.0.9 # via # python-discovery # virtualenv filetype==1.2.0 # via mp-api (pyproject.toml) -flake8==7.3.0 +flake8==7.4.1 # via mp-api (pyproject.toml) flask==3.1.3 # via mp-api (pyproject.toml) @@ -138,38 +137,36 @@ flexcache==0.3 # via pint flexparser==0.4 # via pint -fonttools==4.63.0 +fonttools==4.66.1 # via matplotlib -fqdn==1.5.1 +fqdn==1.6.0 # via jsonschema -griffelib==2.2.0 +griffelib==2.3.0 # via fastmcp-slim h11==0.16.0 # via - # httpcore + # httpcore2 # uvicorn h5py==3.16.0 # via phonopy -httpcore==1.0.9 - # via httpx -httpx==0.28.1 +httpcore2==2.13.1 + # via httpx2 +httpx2==2.13.1 # via # fastmcp-slim # mcp -httpx-sse==0.4.3 - # via mcp -identify==2.6.19 +identify==2.6.20 # via pre-commit -idna==3.18 +idna==3.20 # via # anyio # email-validator - # httpx + # httpx2 # jsonschema # requests -imageio==2.37.4 +imageio==2.38.0 # via scikit-image -imagesize==2.0.0 +imagesize==2.0.1 # via sphinx importlib-resources==7.1.0 # via swagger-spec-validator @@ -177,7 +174,7 @@ inflect==7.5.0 # via emmet-core iniconfig==2.3.0 # via pytest -ipython==9.16.1 +ipython==9.17.1 # via mp-api (pyproject.toml) ipython-pygments-lexers==1.1.1 # via ipython @@ -205,11 +202,11 @@ jmespath==1.1.0 # via # boto3 # botocore -joblib==1.5.3 +joblib==1.6.0 # via # pymatgen-analysis-diffusion # pymatgen-core -joserfc==1.7.4 +joserfc==1.7.5 # via # authlib # fastmcp-slim @@ -232,19 +229,19 @@ jsonschema-specifications==2025.9.1 # via jsonschema keyring==25.7.0 # via py-key-value-aio -kiwisolver==1.5.0 +kiwisolver==1.5.1 # via matplotlib lark==1.3.1 # via rfc3987-syntax latexcodec==3.0.1 # via pybtex -lazy-loader==0.5 +lazy-loader==0.6 # via scikit-image -librt==0.15.0 +librt==0.16.0 # via mypy lobsterpy==0.6.1 # via emmet-core -lxml==6.1.1 +lxml==6.1.3 # via pymatgen-core markdown-it-py==4.2.0 # via rich @@ -253,7 +250,7 @@ markupsafe==3.0.3 # flask # jinja2 # werkzeug -matplotlib==3.11.1 +matplotlib==3.11.2 # via # ase # phonopy @@ -263,8 +260,12 @@ matplotlib-inline==0.2.2 # via ipython mccabe==0.7.0 # via flake8 -mcp==1.29.0 +mcp==2.2.0 # via fastmcp-slim +mcp-types==2.2.0 + # via + # fastmcp-slim + # mcp mdurl==0.1.2 # via markdown-it-py monotonic==1.6 @@ -284,25 +285,25 @@ mp-pyrho==0.5.1 # via pymatgen-analysis-defects mpmath==1.3.0 # via sympy -msgpack==1.2.1 +msgpack==1.2.3 # via # bravado # bravado-core -mypy==2.3.1 +mypy==2.4.0 # via mp-api (pyproject.toml) mypy-extensions==1.1.0 # via # mp-api (pyproject.toml) # mypy -narwhals==2.24.0 +narwhals==2.26.0 # via plotly -networkx==3.6.1 +networkx==3.7 # via # pymatgen-core # scikit-image -nodeenv==1.10.0 +nodeenv==1.11.0 # via pre-commit -numpy==2.5.2 +numpy==2.5.3 # via # ase # contourpy @@ -325,10 +326,12 @@ numpy==2.5.2 # spglib # symfc # tifffile -openapi-pydantic==0.5.1 - # via fastmcp-slim -opentelemetry-api==1.44.0 +openapi-pydantic==0.6.0 # via fastmcp-slim +opentelemetry-api==1.45.0 + # via + # fastmcp-slim + # mcp orjson==3.12.0 # via # mp-api (pyproject.toml) @@ -341,10 +344,12 @@ packaging==26.3 # plotly # pytest # scikit-image + # seekpath # sphinx + # virtualenv palettable==3.3.3 # via pymatgen-core -pandas==3.0.5 +pandas==3.0.6 # via # pymatgen-core # seaborn @@ -356,23 +361,23 @@ pathspec==1.1.1 # via mypy pexpect==4.9.0 # via ipython -phonopy==4.4.0 +phonopy==4.8.0 # via emmet-core -phonors==0.3.0 +phonors==0.6.0 # via phonopy pillow==12.3.0 # via # imageio # matplotlib # scikit-image -pint==0.25.3 +pint==0.26.1 # via mp-api (pyproject.toml) -platformdirs==4.11.3 +platformdirs==4.12.2 # via # fastmcp-slim # pint # virtualenv -plotly==6.9.0 +plotly==7.1.0 # via pymatgen-core pluggy==1.6.0 # via @@ -390,9 +395,9 @@ ptyprocess==0.7.0 # via pexpect pubchempy==1.0.5 # via emmet-core -pure-eval==0.2.3 +pure-eval==0.2.4 # via stack-data -py-key-value-aio[filetree,keyring,memory]==0.4.5 +py-key-value-aio[filetree,keyring,memory]==0.4.6 # via fastmcp-slim pyarrow==25.0.1 # via @@ -402,29 +407,29 @@ pybtex==0.26.1 # via emmet-core pycodcif==3.0.1 # via emmet-core -pycodestyle==2.14.0 +pycodestyle==2.15.0 # via # flake8 # mp-api (pyproject.toml) pycparser==3.0 # via cffi -pydantic[email]==2.13.4 +pydantic[email]==2.13.5 # via # emmet-core # fastmcp-slim # mcp + # mcp-types # openapi-pydantic # pydantic-settings # pymatgen-io-validation -pydantic-core==2.46.4 +pydantic-core==2.46.5 # via pydantic pydantic-settings==2.15.0 # via # emmet-core # fastmcp-slim - # mcp # pymatgen-io-validation -pyflakes==3.4.0 +pyflakes==4.0.1 # via flake8 pygments==2.21.0 # via @@ -438,7 +443,7 @@ pyisemail==2.0.1 # via mp-api (pyproject.toml) pyjwt[crypto]==2.15.1 # via mcp -pymatgen==2026.5.4 +pymatgen==2026.9.24 # via # emmet-core # lobsterpy @@ -451,20 +456,20 @@ pymatgen-analysis-alloys==0.0.9 # via emmet-core pymatgen-analysis-defects==2026.3.20 # via emmet-core -pymatgen-analysis-diffusion==2025.11.15 +pymatgen-analysis-diffusion==2026.9.23 # via emmet-core -pymatgen-core==2026.7.16 +pymatgen-core==2026.10.2 # via # emmet-core # pymatgen # pymatgen-io-validation pymatgen-io-validation==0.1.4 # via emmet-core -pymongo==4.17.0 +pymongo==4.18.2 # via # emmet-core # mp-api (pyproject.toml) -pyparsing==3.3.2 +pyparsing==3.3.3 # via # bibtexparser # matplotlib @@ -481,7 +486,7 @@ pytest-asyncio==1.4.0 # via mp-api (pyproject.toml) pytest-cov==7.1.0 # via mp-api (pyproject.toml) -pytest-mock==3.15.1 +pytest-mock==3.16.0 # via mp-api (pyproject.toml) pytest-xdist==3.8.0 # via mp-api (pyproject.toml) @@ -493,9 +498,9 @@ python-dateutil==2.9.0.post0 # bravado-core # matplotlib # pandas -python-discovery==1.5.2 +python-discovery==1.6.1 # via virtualenv -python-dotenv==1.2.3 +python-dotenv==1.2.4 # via # fastmcp-slim # pydantic-settings @@ -503,7 +508,7 @@ python-multipart==0.0.32 # via # fastmcp-slim # mcp -pytz==2026.3.post1 +pytz==2026.4 # via bravado-core pyyaml==6.0.3 # via @@ -529,7 +534,7 @@ requests==2.34.2 # pymatgen-io-validation # requests-futures # sphinx -requests-futures==1.0.2 +requests-futures==1.1.0 # via mp-api (pyproject.toml) rfc3339-validator==0.1.4 # via jsonschema @@ -542,7 +547,7 @@ rich==15.0.0 # cyclopts # fastmcp-slim # rich-rst -rich-rst==2.1.0 +rich-rst==2.2.0 # via cyclopts roman-numerals==4.1.0 # via sphinx @@ -558,7 +563,7 @@ s3transfer==0.19.2 # via boto3 scikit-image==0.26.0 # via pymatgen-analysis-defects -scipy==1.18.0 +scipy==1.18.1 # via # ase # pymatgen-core @@ -568,11 +573,11 @@ seaborn==0.13.2 # via pymatgen-analysis-diffusion secretstorage==3.5.0 # via keyring -seekpath==2.2.1 +seekpath==2.2.2 # via emmet-core shapely==2.1.2 # via pymatgen-analysis-alloys -simplejson==4.1.1 +simplejson==4.1.2 # via # bravado # bravado-core @@ -604,11 +609,11 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sse-starlette==3.4.8 +sse-starlette==3.5.0 # via mcp stack-data==0.6.3 # via ipython -starlette==1.6.0 +starlette==1.7.0 # via # fastmcp-slim # mcp @@ -623,24 +628,29 @@ sympy==1.14.0 # via pymatgen-core tabulate==0.10.0 # via pymatgen-core -tifffile==2026.8.16 +tifffile==2026.9.20 # via scikit-image -tqdm==4.70.0 +tqdm==4.70.1 # via pymatgen-core traitlets==5.16.1 # via # ipython # matplotlib-inline +truststore==0.10.4 + # via + # httpcore2 + # httpx2 typeguard==4.6.0 # via inflect -types-requests==2.33.0.20260712 +types-requests==2.33.0.20260906 # via mp-api (pyproject.toml) types-setuptools==84.0.0.20260812 # via mp-api (pyproject.toml) -types-tqdm==4.70.0.20260805 +types-tqdm==4.70.0.20260906 # via mp-api (pyproject.toml) typing-extensions==4.16.0 # via + # anyio # ase # bravado # emmet-core @@ -648,6 +658,7 @@ typing-extensions==4.16.0 # flexcache # flexparser # mcp + # mcp-types # mp-api (pyproject.toml) # mypy # opentelemetry-api @@ -663,7 +674,7 @@ typing-inspection==0.4.4 # mcp # pydantic # pydantic-settings -tzdata==2026.3 +tzdata==2026.4 # via arrow uncalled-for==0.4.0 # via fastmcp-slim @@ -676,21 +687,21 @@ urllib3==2.8.0 # botocore # requests # types-requests -uvicorn==0.52.3 +uvicorn==0.54.0 # via # fastmcp-slim # mcp -virtualenv==21.7.13 +virtualenv==21.14.5 # via pre-commit -watchfiles==1.2.0 +watchfiles==1.3.0 # via fastmcp-slim -wcwidth==0.8.2 +wcwidth==0.9.1 # via prompt-toolkit webcolors==25.10.0 # via jsonschema -websockets==17.0.1 +websockets==17.1 # via fastmcp-slim -werkzeug==3.1.8 +werkzeug==3.1.9 # via flask -wrapt==2.3.0 +wrapt==2.5.0 # via deprecated diff --git a/requirements/requirements-ubuntu-latest_py3.14.txt b/requirements/requirements-ubuntu-latest_py3.14.txt index 17664e28..c1e1d61f 100644 --- a/requirements/requirements-ubuntu-latest_py3.14.txt +++ b/requirements/requirements-ubuntu-latest_py3.14.txt @@ -2,39 +2,41 @@ # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --output-file=requirements/requirements-ubuntu-latest_py3.14.txt pyproject.toml +# pip-compile --no-index --output-file=requirements/requirements-ubuntu-latest_py3.14.txt pyproject.toml # annotated-types==0.8.0 # via pydantic -arro3-core==0.8.1 +arro3-core==0.9.0 # via deltalake bibtexparser==1.4.4 # via pymatgen-core -blake3==1.0.9 +blake3==1.0.10 # via emmet-core -boto3==1.43.73 +boto3==1.43.108 # via mp-api (pyproject.toml) -botocore==1.43.73 +botocore==1.43.108 # via # boto3 # s3transfer certifi==2026.7.22 # via requests -charset-normalizer==3.5.1 +charset-normalizer==3.5.2 # via requests -contourpy==1.3.3 +cloudpickle==3.1.2 + # via joblib +contourpy==1.4.0 # via matplotlib cycler==0.12.1 # via matplotlib deltalake==1.5.1 # via mp-api (pyproject.toml) -deprecated==1.3.1 +deprecated==3.0.0 # via deltalake -emmet-core==0.87.2 +emmet-core==0.87.3rc2 # via mp-api (pyproject.toml) -fonttools==4.63.0 +fonttools==4.66.1 # via matplotlib -idna==3.18 +idna==3.20 # via requests inflect==7.5.0 # via emmet-core @@ -42,15 +44,15 @@ jmespath==1.1.0 # via # boto3 # botocore -joblib==1.5.3 +joblib==1.6.0 # via pymatgen-core -kiwisolver==1.5.0 +kiwisolver==1.5.1 # via matplotlib latexcodec==3.0.1 # via pybtex -lxml==6.1.1 +lxml==6.1.3 # via pymatgen-core -matplotlib==3.11.1 +matplotlib==3.11.2 # via pymatgen-core monty==2026.7.16 # via @@ -61,11 +63,11 @@ more-itertools==11.1.0 # via inflect mpmath==1.3.0 # via sympy -narwhals==2.24.0 +narwhals==2.26.0 # via plotly -networkx==3.6.1 +networkx==3.7 # via pymatgen-core -numpy==2.5.2 +numpy==2.5.3 # via # contourpy # matplotlib @@ -85,11 +87,11 @@ packaging==26.3 # plotly palettable==3.3.3 # via pymatgen-core -pandas==3.0.5 +pandas==3.0.6 # via pymatgen-core pillow==12.3.0 # via matplotlib -plotly==6.9.0 +plotly==7.1.0 # via pymatgen-core pubchempy==1.0.5 # via emmet-core @@ -97,28 +99,29 @@ pyarrow==25.0.1 # via mp-api (pyproject.toml) pybtex==0.26.1 # via emmet-core -pydantic==2.13.4 +pydantic==2.13.5 # via # emmet-core # pydantic-settings # pymatgen-io-validation -pydantic-core==2.46.4 +pydantic-core==2.46.5 # via pydantic pydantic-settings==2.15.0 # via # emmet-core # pymatgen-io-validation -pymatgen==2026.5.4 +pymatgen==2026.9.24 # via # emmet-core # mp-api (pyproject.toml) -pymatgen-core==2026.8.13 +pymatgen-core==2026.10.2 # via + # emmet-core # pymatgen # pymatgen-io-validation pymatgen-io-validation==0.1.4 # via emmet-core -pyparsing==3.3.2 +pyparsing==3.3.3 # via # bibtexparser # matplotlib @@ -127,7 +130,7 @@ python-dateutil==2.9.0.post0 # botocore # matplotlib # pandas -python-dotenv==1.2.3 +python-dotenv==1.2.4 # via pydantic-settings pyyaml==6.0.3 # via pybtex @@ -140,7 +143,7 @@ ruamel-yaml==0.19.1 # via monty s3transfer==0.19.2 # via boto3 -scipy==1.18.0 +scipy==1.18.1 # via pymatgen-core six==1.17.0 # via python-dateutil @@ -150,7 +153,7 @@ sympy==1.14.0 # via pymatgen-core tabulate==0.10.0 # via pymatgen-core -tqdm==4.70.0 +tqdm==4.70.1 # via pymatgen-core typeguard==4.6.0 # via inflect @@ -172,5 +175,5 @@ urllib3==2.8.0 # via # botocore # requests -wrapt==2.3.0 +wrapt==2.5.0 # via deprecated diff --git a/requirements/requirements-ubuntu-latest_py3.14_extras.txt b/requirements/requirements-ubuntu-latest_py3.14_extras.txt index aecebaea..5db9a741 100644 --- a/requirements/requirements-ubuntu-latest_py3.14_extras.txt +++ b/requirements/requirements-ubuntu-latest_py3.14_extras.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --all-extras --output-file=requirements/requirements-ubuntu-latest_py3.14_extras.txt pyproject.toml +# pip-compile --all-extras --no-index --output-file=requirements/requirements-ubuntu-latest_py3.14_extras.txt pyproject.toml # aiofile==3.12.3 # via py-key-value-aio @@ -10,21 +10,21 @@ alabaster==1.0.0 # via sphinx annotated-types==0.8.0 # via pydantic -anyio==4.14.2 +anyio==4.15.1 # via - # httpx + # httpx2 # mcp # py-key-value-aio # sse-starlette # starlette # watchfiles -arro3-core==0.8.1 +arro3-core==0.9.0 # via deltalake arrow==1.4.0 # via isoduration ase==3.29.0 # via pymatgen-analysis-diffusion -ast-serialize==0.8.0 +ast-serialize==0.12.0 # via mypy asttokens==3.0.2 # via stack-data @@ -34,7 +34,7 @@ attrs==26.1.0 # jsonschema # jsonschema-path # referencing -authlib==1.7.2 +authlib==1.8.0 # via fastmcp-slim babel==2.18.0 # via sphinx @@ -42,15 +42,15 @@ beartype==0.22.9 # via py-key-value-aio bibtexparser==1.4.4 # via pymatgen-core -blake3==1.0.9 +blake3==1.0.10 # via emmet-core blinker==1.9.0 # via flask -boltons==26.1.0 +boltons==26.2.0 # via mp-api (pyproject.toml) -boto3==1.43.73 +boto3==1.43.108 # via mp-api (pyproject.toml) -botocore==1.43.73 +botocore==1.43.108 # via # boto3 # s3transfer @@ -58,32 +58,31 @@ bravado==12.0.1 # via mp-api (pyproject.toml) bravado-core==6.4.1 # via bravado -cachetools==7.1.7 +cachetools==7.2.0 # via # mp-api (pyproject.toml) # py-key-value-aio -caio==0.12.2 +caio==0.12.9 # via aiofile certifi==2026.7.22 - # via - # httpcore - # httpx - # requests + # via requests cffi==2.1.1 # via cryptography cfgv==3.5.0 # via pre-commit -charset-normalizer==3.5.1 +charset-normalizer==3.5.2 # via requests -click==8.4.2 +click==8.5.0 # via # flask # uvicorn -contourpy==1.3.3 +cloudpickle==3.1.2 + # via joblib +contourpy==1.4.0 # via matplotlib -coverage[toml]==7.15.4 +coverage[toml]==7.16.2 # via pytest-cov -cryptography==50.0.0 +cryptography==50.0.2 # via # authlib # joserfc @@ -93,11 +92,11 @@ custodian==2025.12.14 # via mp-api (pyproject.toml) cycler==0.12.1 # via matplotlib -cyclopts==4.23.0 +cyclopts==5.1.1 # via fastmcp-slim deltalake==1.5.1 # via mp-api (pyproject.toml) -deprecated==1.3.1 +deprecated==3.0.0 # via deltalake distlib==0.4.3 # via virtualenv @@ -112,7 +111,7 @@ docutils==0.22.4 # via sphinx email-validator==2.3.0 # via pydantic -emmet-core[all]==0.87.2 +emmet-core[all]==0.87.3rc2 # via mp-api (pyproject.toml) exceptiongroup==1.3.1 # via fastmcp-slim @@ -120,17 +119,17 @@ execnet==2.1.2 # via pytest-xdist executing==2.2.1 # via stack-data -fastmcp==3.4.7 +fastmcp==4.0.10 # via mp-api (pyproject.toml) -fastmcp-slim[client,server]==3.4.7 +fastmcp-slim[client,server]==4.0.10 # via fastmcp -filelock==3.32.3 +filelock==4.0.9 # via # python-discovery # virtualenv filetype==1.2.0 # via mp-api (pyproject.toml) -flake8==7.3.0 +flake8==7.4.1 # via mp-api (pyproject.toml) flask==3.1.3 # via mp-api (pyproject.toml) @@ -138,38 +137,36 @@ flexcache==0.3 # via pint flexparser==0.4 # via pint -fonttools==4.63.0 +fonttools==4.66.1 # via matplotlib -fqdn==1.5.1 +fqdn==1.6.0 # via jsonschema -griffelib==2.2.0 +griffelib==2.3.0 # via fastmcp-slim h11==0.16.0 # via - # httpcore + # httpcore2 # uvicorn h5py==3.16.0 # via phonopy -httpcore==1.0.9 - # via httpx -httpx==0.28.1 +httpcore2==2.13.1 + # via httpx2 +httpx2==2.13.1 # via # fastmcp-slim # mcp -httpx-sse==0.4.3 - # via mcp -identify==2.6.19 +identify==2.6.20 # via pre-commit -idna==3.18 +idna==3.20 # via # anyio # email-validator - # httpx + # httpx2 # jsonschema # requests -imageio==2.37.4 +imageio==2.38.0 # via scikit-image -imagesize==2.0.0 +imagesize==2.0.1 # via sphinx importlib-resources==7.1.0 # via swagger-spec-validator @@ -177,7 +174,7 @@ inflect==7.5.0 # via emmet-core iniconfig==2.3.0 # via pytest -ipython==9.16.1 +ipython==9.17.1 # via mp-api (pyproject.toml) ipython-pygments-lexers==1.1.1 # via ipython @@ -205,11 +202,11 @@ jmespath==1.1.0 # via # boto3 # botocore -joblib==1.5.3 +joblib==1.6.0 # via # pymatgen-analysis-diffusion # pymatgen-core -joserfc==1.7.4 +joserfc==1.7.5 # via # authlib # fastmcp-slim @@ -232,19 +229,19 @@ jsonschema-specifications==2025.9.1 # via jsonschema keyring==25.7.0 # via py-key-value-aio -kiwisolver==1.5.0 +kiwisolver==1.5.1 # via matplotlib lark==1.3.1 # via rfc3987-syntax latexcodec==3.0.1 # via pybtex -lazy-loader==0.5 +lazy-loader==0.6 # via scikit-image -librt==0.15.0 +librt==0.16.0 # via mypy lobsterpy==0.6.1 # via emmet-core -lxml==6.1.1 +lxml==6.1.3 # via pymatgen-core markdown-it-py==4.2.0 # via rich @@ -253,7 +250,7 @@ markupsafe==3.0.3 # flask # jinja2 # werkzeug -matplotlib==3.11.1 +matplotlib==3.11.2 # via # ase # phonopy @@ -263,8 +260,12 @@ matplotlib-inline==0.2.2 # via ipython mccabe==0.7.0 # via flake8 -mcp==1.29.0 +mcp==2.2.0 # via fastmcp-slim +mcp-types==2.2.0 + # via + # fastmcp-slim + # mcp mdurl==0.1.2 # via markdown-it-py monotonic==1.6 @@ -284,25 +285,25 @@ mp-pyrho==0.5.1 # via pymatgen-analysis-defects mpmath==1.3.0 # via sympy -msgpack==1.2.1 +msgpack==1.2.3 # via # bravado # bravado-core -mypy==2.3.1 +mypy==2.4.0 # via mp-api (pyproject.toml) mypy-extensions==1.1.0 # via # mp-api (pyproject.toml) # mypy -narwhals==2.24.0 +narwhals==2.26.0 # via plotly -networkx==3.6.1 +networkx==3.7 # via # pymatgen-core # scikit-image -nodeenv==1.10.0 +nodeenv==1.11.0 # via pre-commit -numpy==2.5.2 +numpy==2.5.3 # via # ase # contourpy @@ -325,10 +326,12 @@ numpy==2.5.2 # spglib # symfc # tifffile -openapi-pydantic==0.5.1 - # via fastmcp-slim -opentelemetry-api==1.44.0 +openapi-pydantic==0.6.0 # via fastmcp-slim +opentelemetry-api==1.45.0 + # via + # fastmcp-slim + # mcp orjson==3.12.0 # via # mp-api (pyproject.toml) @@ -341,10 +344,12 @@ packaging==26.3 # plotly # pytest # scikit-image + # seekpath # sphinx + # virtualenv palettable==3.3.3 # via pymatgen-core -pandas==3.0.5 +pandas==3.0.6 # via # pymatgen-core # seaborn @@ -356,23 +361,23 @@ pathspec==1.1.1 # via mypy pexpect==4.9.0 # via ipython -phonopy==4.4.0 +phonopy==4.8.0 # via emmet-core -phonors==0.3.0 +phonors==0.6.0 # via phonopy pillow==12.3.0 # via # imageio # matplotlib # scikit-image -pint==0.25.3 +pint==0.26.1 # via mp-api (pyproject.toml) -platformdirs==4.11.3 +platformdirs==4.12.2 # via # fastmcp-slim # pint # virtualenv -plotly==6.9.0 +plotly==7.1.0 # via pymatgen-core pluggy==1.6.0 # via @@ -390,9 +395,9 @@ ptyprocess==0.7.0 # via pexpect pubchempy==1.0.5 # via emmet-core -pure-eval==0.2.3 +pure-eval==0.2.4 # via stack-data -py-key-value-aio[filetree,keyring,memory]==0.4.5 +py-key-value-aio[filetree,keyring,memory]==0.4.6 # via fastmcp-slim pyarrow==25.0.1 # via @@ -402,29 +407,29 @@ pybtex==0.26.1 # via emmet-core pycodcif==3.0.1 # via emmet-core -pycodestyle==2.14.0 +pycodestyle==2.15.0 # via # flake8 # mp-api (pyproject.toml) pycparser==3.0 # via cffi -pydantic[email]==2.13.4 +pydantic[email]==2.13.5 # via # emmet-core # fastmcp-slim # mcp + # mcp-types # openapi-pydantic # pydantic-settings # pymatgen-io-validation -pydantic-core==2.46.4 +pydantic-core==2.46.5 # via pydantic pydantic-settings==2.15.0 # via # emmet-core # fastmcp-slim - # mcp # pymatgen-io-validation -pyflakes==3.4.0 +pyflakes==4.0.1 # via flake8 pygments==2.21.0 # via @@ -438,7 +443,7 @@ pyisemail==2.0.1 # via mp-api (pyproject.toml) pyjwt[crypto]==2.15.1 # via mcp -pymatgen==2026.5.4 +pymatgen==2026.9.24 # via # emmet-core # lobsterpy @@ -451,20 +456,20 @@ pymatgen-analysis-alloys==0.0.9 # via emmet-core pymatgen-analysis-defects==2026.3.20 # via emmet-core -pymatgen-analysis-diffusion==2025.11.15 +pymatgen-analysis-diffusion==2026.9.23 # via emmet-core -pymatgen-core==2026.7.16 +pymatgen-core==2026.10.2 # via # emmet-core # pymatgen # pymatgen-io-validation pymatgen-io-validation==0.1.4 # via emmet-core -pymongo==4.17.0 +pymongo==4.18.2 # via # emmet-core # mp-api (pyproject.toml) -pyparsing==3.3.2 +pyparsing==3.3.3 # via # bibtexparser # matplotlib @@ -481,7 +486,7 @@ pytest-asyncio==1.4.0 # via mp-api (pyproject.toml) pytest-cov==7.1.0 # via mp-api (pyproject.toml) -pytest-mock==3.15.1 +pytest-mock==3.16.0 # via mp-api (pyproject.toml) pytest-xdist==3.8.0 # via mp-api (pyproject.toml) @@ -493,9 +498,9 @@ python-dateutil==2.9.0.post0 # bravado-core # matplotlib # pandas -python-discovery==1.5.2 +python-discovery==1.6.1 # via virtualenv -python-dotenv==1.2.3 +python-dotenv==1.2.4 # via # fastmcp-slim # pydantic-settings @@ -503,7 +508,7 @@ python-multipart==0.0.32 # via # fastmcp-slim # mcp -pytz==2026.3.post1 +pytz==2026.4 # via bravado-core pyyaml==6.0.3 # via @@ -529,7 +534,7 @@ requests==2.34.2 # pymatgen-io-validation # requests-futures # sphinx -requests-futures==1.0.2 +requests-futures==1.1.0 # via mp-api (pyproject.toml) rfc3339-validator==0.1.4 # via jsonschema @@ -542,7 +547,7 @@ rich==15.0.0 # cyclopts # fastmcp-slim # rich-rst -rich-rst==2.1.0 +rich-rst==2.2.0 # via cyclopts roman-numerals==4.1.0 # via sphinx @@ -558,7 +563,7 @@ s3transfer==0.19.2 # via boto3 scikit-image==0.26.0 # via pymatgen-analysis-defects -scipy==1.18.0 +scipy==1.18.1 # via # ase # pymatgen-core @@ -568,11 +573,11 @@ seaborn==0.13.2 # via pymatgen-analysis-diffusion secretstorage==3.5.0 # via keyring -seekpath==2.2.1 +seekpath==2.2.2 # via emmet-core shapely==2.1.2 # via pymatgen-analysis-alloys -simplejson==4.1.1 +simplejson==4.1.2 # via # bravado # bravado-core @@ -604,11 +609,11 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sse-starlette==3.4.8 +sse-starlette==3.5.0 # via mcp stack-data==0.6.3 # via ipython -starlette==1.6.0 +starlette==1.7.0 # via # fastmcp-slim # mcp @@ -623,24 +628,29 @@ sympy==1.14.0 # via pymatgen-core tabulate==0.10.0 # via pymatgen-core -tifffile==2026.8.16 +tifffile==2026.9.20 # via scikit-image -tqdm==4.70.0 +tqdm==4.70.1 # via pymatgen-core traitlets==5.16.1 # via # ipython # matplotlib-inline +truststore==0.10.4 + # via + # httpcore2 + # httpx2 typeguard==4.6.0 # via inflect -types-requests==2.33.0.20260712 +types-requests==2.33.0.20260906 # via mp-api (pyproject.toml) types-setuptools==84.0.0.20260812 # via mp-api (pyproject.toml) -types-tqdm==4.70.0.20260805 +types-tqdm==4.70.0.20260906 # via mp-api (pyproject.toml) typing-extensions==4.16.0 # via + # anyio # ase # bravado # emmet-core @@ -648,6 +658,7 @@ typing-extensions==4.16.0 # flexcache # flexparser # mcp + # mcp-types # mp-api (pyproject.toml) # mypy # opentelemetry-api @@ -663,7 +674,7 @@ typing-inspection==0.4.4 # mcp # pydantic # pydantic-settings -tzdata==2026.3 +tzdata==2026.4 # via arrow uncalled-for==0.4.0 # via fastmcp-slim @@ -676,21 +687,21 @@ urllib3==2.8.0 # botocore # requests # types-requests -uvicorn==0.52.3 +uvicorn==0.54.0 # via # fastmcp-slim # mcp -virtualenv==21.7.13 +virtualenv==21.14.5 # via pre-commit -watchfiles==1.2.0 +watchfiles==1.3.0 # via fastmcp-slim -wcwidth==0.8.2 +wcwidth==0.9.1 # via prompt-toolkit webcolors==25.10.0 # via jsonschema -websockets==17.0.1 +websockets==17.1 # via fastmcp-slim -werkzeug==3.1.8 +werkzeug==3.1.9 # via flask -wrapt==2.3.0 +wrapt==2.5.0 # via deprecated From fa79aa3f864894476e58cd3696e355ad80856ad8 Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:23:10 -0700 Subject: [PATCH 06/30] make client more forwards compatible warn once on schema version discrepancies --- mp_api/_test_utils.py | 1 - mp_api/client/core/schemas.py | 38 ++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/mp_api/_test_utils.py b/mp_api/_test_utils.py index c11b0ea0..332eaef7 100644 --- a/mp_api/_test_utils.py +++ b/mp_api/_test_utils.py @@ -139,7 +139,6 @@ def _normalize(doc, field: str): if k not in ("_page", "_sort_fields", "chunk_size", "fields") } for sort_field in [sort_fields] if isinstance(sort_fields, str) else sort_fields: - asc = search_method( _page=1, _sort_fields=sort_field, diff --git a/mp_api/client/core/schemas.py b/mp_api/client/core/schemas.py index d721ef69..9a886b28 100644 --- a/mp_api/client/core/schemas.py +++ b/mp_api/client/core/schemas.py @@ -2,13 +2,20 @@ from __future__ import annotations -from functools import cached_property +import logging +from functools import cached_property, lru_cache from importlib import import_module +from importlib.metadata import version from itertools import chain from typing import TYPE_CHECKING, ForwardRef, get_args from emmet.core.utils import jsanitize -from pydantic import BaseModel, create_model +from pydantic import ( + BaseModel, + ValidationError, + create_model, + field_validator, +) if TYPE_CHECKING: from collections.abc import Iterator @@ -16,6 +23,13 @@ from pydantic.fields import FieldInfo +logger = logging.getLogger(__name__) + + +@lru_cache(20) +def _warn_failed_field_validation(logger: logging.Logger, msg: str): + logger.warning(msg) + class _DictLikeAccess(BaseModel): """Define a pydantic mix-in which permits dict-like access to model fields.""" @@ -47,7 +61,7 @@ def __str__(self) -> str: return ( f"{self.__class__.__name__}(\n" + "\n".join( - f" {k} ({annos[k]}) : {getattr(self,k)}" for k in populated_fields + f" {k} ({annos[k]}) : {getattr(self, k)}" for k in populated_fields ) + "\n)" ) @@ -56,6 +70,24 @@ def __repr__(self) -> str: """Match output of str().""" return self.__str__() + @field_validator("*", mode="wrap") + @classmethod + def ignore_invalid(cls, value, default_validator, info) -> Any: + try: + return default_validator(value) + except ValidationError: + emmet_version = version("emmet-core") + annotation = cls.model_fields[info.field_name].annotation + _warn_failed_field_validation( + logger, + f"Failed validation on field: '{info.field_name}', received type: '{type(value)}', expected type: '{annotation}'. " + "Field value set to 'None', re-run query with 'document_model=False' to skip validation. " + f"Local 'emmet-core' version is: '{emmet_version}', run 'mpr = MPRester(); mpr.get_emmet_version(mpr.endpoint)' to " + "check the live API server for schema version mismatches and upgrade 'emmet-core' if needed.", + ) + + return None + def _generate_returned_model( doc: dict[str, Any], From 32c3a00ecd4a4104243b734d9eba373fcbaba7ed Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:31:36 -0700 Subject: [PATCH 07/30] rm logging handler + formatter --- mp_api/client/__init__.py | 3 ++- mp_api/client/core/client.py | 6 ------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/mp_api/client/__init__.py b/mp_api/client/__init__.py index 3eb2b879..75307bf6 100644 --- a/mp_api/client/__init__.py +++ b/mp_api/client/__init__.py @@ -16,4 +16,5 @@ except PackageNotFoundError: # pragma: no cover __version__ = os.getenv("SETUPTOOLS_SCM_PRETEND_VERSION", "") -logging.getLogger(__name__).addHandler(logging.NullHandler()) +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index 647f32ec..f1f20236 100644 --- a/mp_api/client/core/client.py +++ b/mp_api/client/core/client.py @@ -88,13 +88,7 @@ "thermo", ] -hdlr = logging.StreamHandler() -fmt = logging.Formatter("%(name)s - %(levelname)s - %(message)s") -hdlr.setFormatter(fmt) - logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) -logger.addHandler(hdlr) def _batched(iterable: Iterable, n: int) -> Iterator: From 764ce53896106ba9ecf06a3075247f10f7553b0e Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:09:07 -0700 Subject: [PATCH 08/30] reimplement deltalake integration --- mp_api/client/core/client.py | 178 +++--- mp_api/client/core/delta.py | 390 ++++++++++++++ mp_api/client/mprester.py | 12 +- .../routes/materials/electronic_structure.py | 9 +- mp_api/client/routes/materials/phonon.py | 7 +- mp_api/client/routes/materials/tasks.py | 4 +- mp_api/client/routes/materials/thermo.py | 2 +- tests/client/core/test_delta.py | 505 ++++++++++++++++++ 8 files changed, 1033 insertions(+), 74 deletions(-) create mode 100644 mp_api/client/core/delta.py create mode 100644 tests/client/core/test_delta.py diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index f1f20236..38853eb3 100644 --- a/mp_api/client/core/client.py +++ b/mp_api/client/core/client.py @@ -43,6 +43,7 @@ from urllib3.util.retry import Retry from mp_api.client._server_utils import get_consumer, get_user_api_key, is_dev_env +from mp_api.client.core.delta import DeltaCatalog from mp_api.client.core.exceptions import ( MPRestError, MPRestWarning, @@ -61,6 +62,8 @@ from collections.abc import Callable, Iterable, Iterator from typing import Any + from arro3.core import RecordBatchReader + from mp_api.client.core.utils import LazyImport try: @@ -100,28 +103,42 @@ def _batched(iterable: Iterable, n: int) -> Iterator: class QueryBuilderWithCache(QueryBuilder): + def __init__(self, catalog: DeltaCatalog | None = None, _warn: bool = True) -> None: + """Deprecated: use `mp_api.client.core.delta.DeltaCatalog`. - def __init__(self) -> None: - """Extend deltalake.QueryBuilder with stored DeltaTables. - - The deltalake.QueryBuilder class does not permit introspection - of registered DeltaTables through the python API. + Kept for backwards compatibility. Tables registered here, and + queries run through it, are delegated to a `DeltaCatalog`. Resters + given this object via `query_builder=` share its catalog. - Re-registering a DeltaTable - (1) wastes time by reading its metadata - (2) raises an exception because a table is already registered - - This class simply allows for caching the DeltaTable instances - and table names on the QueryBuilder class. + Args: + catalog (DeltaCatalog or None) : catalog to delegate to. + A new one is created if None. + _warn (bool) : internal, whether to emit a DeprecationWarning """ - # Dict of table names (labels) to DeltaTable instances - self._delta_tables: dict[str, DeltaTable] = {} + if _warn: + warnings.warn( + "QueryBuilderWithCache is deprecated and will be removed in a future " + "release. Pass `delta_catalog=DeltaCatalog()` " + "(from mp_api.client.core.delta) to MPRester instead.", + category=DeprecationWarning, + stacklevel=2, + ) + self.catalog: DeltaCatalog = catalog if catalog is not None else DeltaCatalog() super().__init__() + @property + def _delta_tables(self) -> dict[str, DeltaTable]: + """Map of table names (labels) to DeltaTable instances.""" + return self.catalog.tables + def register(self, table_name: str, delta_table: DeltaTable) -> QueryBuilder: - """Register and cache a DeltaTable.""" - self._delta_tables[table_name] = delta_table - return super().register(table_name, delta_table) + """Register a DeltaTable in the underlying catalog.""" + self.catalog.add(table_name, delta_table) + return self + + def execute(self, sql: str) -> RecordBatchReader: + """Execute SQL against the tables in the underlying catalog.""" + return self.catalog.execute_stream(sql) class _Rester: @@ -142,6 +159,7 @@ def __init__( ) = MAPI_CLIENT_SETTINGS.LOCAL_DATASET_CACHE, force_renew: bool = False, query_builder: QueryBuilderWithCache | None = None, + delta_catalog: DeltaCatalog | None = None, **kwargs, ) -> None: """Initialize a RESTer. @@ -176,8 +194,12 @@ def __init__( local_dataset_cache: Target directory for downloading full datasets. Defaults to 'mp_datasets' in the user's home directory force_renew: Option to overwrite existing local dataset - query_builder : Instance of QueryBuilderWithCache to use in querying delta tables + query_builder : DEPRECATED, use `delta_catalog`. Instance of QueryBuilderWithCache + whose catalog is used for querying delta tables. NOTE: Must be a QueryBuilderWithCache, a deltalake.QueryBuilder will be ignored. + delta_catalog : Instance of DeltaCatalog to use for querying delta tables. + Share one instance across resters (e.g. one per web-server worker) to + reuse loaded table snapshots. Takes precedence over `query_builder`. **kwargs: access to legacy kwargs that may be in the process of being deprecated """ self.api_key = get_user_api_key(api_key=api_key) @@ -204,6 +226,9 @@ def __init__( self._query_builder = ( query_builder if isinstance(query_builder, QueryBuilderWithCache) else None ) + if self._query_builder is not None and delta_catalog is None: + delta_catalog = self._query_builder.catalog + self._delta_catalog: DeltaCatalog | None = delta_catalog if "monty_decode" in kwargs: # Pop to not repeatedly trigger warning to the user @@ -224,9 +249,24 @@ def session(self) -> requests.Session: return self._session @property - def query_builder(self): - if not self._query_builder: - self._query_builder = QueryBuilderWithCache() + def delta_catalog(self) -> DeltaCatalog: + """The DeltaCatalog used for delta-backed queries, created on first use.""" + if self._delta_catalog is None: + self._delta_catalog = DeltaCatalog() + return self._delta_catalog + + @property + def query_builder(self) -> QueryBuilderWithCache: + """Deprecated: use `delta_catalog`.""" + warnings.warn( + "`query_builder` is deprecated, use `delta_catalog` instead.", + category=DeprecationWarning, + stacklevel=2, + ) + if self._query_builder is None: + self._query_builder = QueryBuilderWithCache( + catalog=self.delta_catalog, _warn=False + ) return self._query_builder @staticmethod @@ -330,6 +370,7 @@ def __init__( ) = MAPI_CLIENT_SETTINGS.LOCAL_DATASET_CACHE, force_renew: bool = False, query_builder: QueryBuilderWithCache | None = None, + delta_catalog: DeltaCatalog | None = None, s3_client: Any | None = None, timeout: int = 20, **kwargs, @@ -369,9 +410,11 @@ def __init__( local_dataset_cache: Target directory for downloading full datasets. Defaults to 'mp_datasets' in the user's home directory force_renew: Option to overwrite existing local dataset - query_builder : Instance of QueryBuilderWithCache to use in querying delta tables + query_builder : DEPRECATED, use `delta_catalog`. Instance of QueryBuilderWithCache + whose catalog is used for querying delta tables. NOTE: Must be a QueryBuilderWithCache, a deltalake.QueryBuilder will be ignored. - s3_client: boto3 S3 client object with which to connect to the object stores.ct to the object stores.ct to the object stores. + delta_catalog : Instance of DeltaCatalog to use for querying delta tables. + s3_client: boto3 S3 client object with which to connect to the object stores. timeout: Time in seconds to wait until a request timeout error is thrown **kwargs: access to legacy kwargs that may be in the process of being deprecated """ @@ -387,6 +430,7 @@ def __init__( local_dataset_cache=local_dataset_cache, force_renew=force_renew, query_builder=query_builder, + delta_catalog=delta_catalog, **kwargs, ) @@ -588,22 +632,24 @@ def _get_delta_table( prefix: str, connector: str = "s3a", label: str | None = None, + refresh: bool = False, ) -> tuple[str, DeltaTable]: """Either create a new DeltaTable, or retrieve a cached one. - If creating a new DeltaTable, will also register in self.query_builder + If creating a new DeltaTable, will also register it in self.delta_catalog Args: bucket (str) : name of the bucket in S3 prefix (str) : name of the prefix in S3 connector (str) : s3, s3n, s3a (default), or other valid Hadoop connector string. - label (str or None) : optional label for the table in the - cached query builder - If `None`, will be gleaned from the URI + label (str or None) : optional label (SQL table name) for the + table in the catalog. If `None`, will be gleaned from the URI + refresh (bool) : if the table is already cached, reload its + snapshot to the latest version first Returns: - str : the table name in the stored query builder + str : the table name in the catalog DeltaTable : If one exists at the specified bucket / prefix, will retrieve the cached instance. """ @@ -615,31 +661,22 @@ def _get_delta_table( if not uri.endswith("/"): uri += "/" - try: - stored_label, delta_table = next( - (_label, _table) - for _label, _table in self.query_builder._delta_tables.items() - if _table.table_uri == uri - ) - except StopIteration: - stored_label = None - - if stored_label is None: - delta_table = DeltaTable( - uri, - storage_options={ - "AWS_SKIP_SIGNATURE": "true", - "AWS_REGION": "us-east-1", - "timeout": delta_timeout, - "connect_timeout": delta_timeout, - "pool_idle_timeout": delta_timeout, - "retry_delay": "3", - "max_retries": f"{MAPI_CLIENT_SETTINGS.MAX_RETRIES}", - }, - ) - self.query_builder.register(qb_label, delta_table) + stored_label, delta_table = self.delta_catalog.get_table( + uri, + qb_label, + storage_options={ + "AWS_SKIP_SIGNATURE": "true", + "AWS_REGION": "us-east-1", + "timeout": delta_timeout, + "connect_timeout": delta_timeout, + "pool_idle_timeout": delta_timeout, + "retry_delay": "3", + "max_retries": f"{MAPI_CLIENT_SETTINGS.MAX_RETRIES}", + }, + refresh=refresh, + ) - elif stored_label != qb_label: + if stored_label != qb_label: warnings.warn( f"DeltaTable with URI {uri} already found with different label: " f"Stored label = {stored_label}; submitted label {qb_label}. " @@ -647,13 +684,16 @@ def _get_delta_table( category=MPRestWarning, stacklevel=2, ) - return stored_label, delta_table - return qb_label, delta_table + return stored_label, delta_table - def _query_delta_single(self, query: str) -> pa.Table: + def _query_delta_single(self, query: str, label: str | None = None) -> pa.Table: """Execute a SQL query against a registered Delta table. + If `label` is given and the query fails because a file in the cached + snapshot no longer exists (e.g. the remote table was vacuumed), only + that table is reloaded and the query is retried once. + Wraps the query execution in a try/except to provide a more actionable error message when the underlying Delta query engine fails (e.g., due to network timeouts, missing tables, or @@ -662,6 +702,8 @@ def _query_delta_single(self, query: str) -> pa.Table: Args: query (str): A SQL query string compatible with the QueryBuilder engine. + label (str or None): The registered table the query reads from, + as returned by `_get_delta_table`. Required for retries. Returns: pa.Table: The query result as a PyArrow Table. @@ -673,13 +715,20 @@ def _query_delta_single(self, query: str) -> pa.Table: the underlying cause. """ try: - return pa.table(self.query_builder.execute(query).read_all()) + return self.delta_catalog.execute(query, label=label) except Exception as e: - raise MPRestError( - f"Failed to retrieve object due to: {e}. " - f"If this is a timeout error, try increasing the 'timeout' " - f"parameter on MPRester (current value: {self.timeout}s)." - ) from e + refreshed = any( + "after refreshing" in note for note in getattr(e, "__notes__", []) + ) + hint = ( + f"The DeltaTable '{label}' was refreshed and the query retried once." + if refreshed + else ( + "If this is a timeout error, try increasing the 'timeout' " + f"parameter on MPRester (current value: {self.timeout}s)." + ) + ) + raise MPRestError(f"Failed to retrieve object due to: {e}. {hint}") from e def _query_delta_backed( self, @@ -758,7 +807,8 @@ def _query_delta_backed( ) } - tbl_lbl, tbl = self._get_delta_table(bucket, prefix, label=label) + # Full downloads are one-off, always start from the latest snapshot + tbl_lbl, tbl = self._get_delta_table(bucket, prefix, label=label, refresh=True) controlled_batch_str = ",".join( [f"'{tag}'" for tag in self.access_controlled_batch_ids] @@ -804,7 +854,9 @@ def _query_delta_backed( else None ) - iterator = self.query_builder.execute(f"SELECT * FROM {tbl_lbl} {predicate}") + iterator = self.delta_catalog.execute_stream( + f"SELECT * FROM {tbl_lbl} {predicate}" + ) file_options = ds.ParquetFileFormat().make_write_options(compression="zstd") @@ -1743,7 +1795,7 @@ def __getattr__(self, v: str): db_version=self.db_version, local_dataset_cache=self.local_dataset_cache, force_renew=self.force_renew, - query_builder=self._query_builder, + delta_catalog=self.delta_catalog, ) return self.sub_resters[v] raise AttributeError(f"{self.__class__} has no attribute {v}") diff --git a/mp_api/client/core/delta.py b/mp_api/client/core/delta.py new file mode 100644 index 00000000..69428590 --- /dev/null +++ b/mp_api/client/core/delta.py @@ -0,0 +1,390 @@ +"""Manage DeltaTable snapshots and SQL execution for delta-backed routes. + +`deltalake.QueryBuilder.register` copies a table's snapshot (its list of +data files) into a DataFusion session at registration time. The snapshot +then can't be updated or deregistered, and registering the same name again +raises. + +If the remote table is vacuumed after an update, an old snapshot can point +to data files that no longer exist. The only way to recover is to load the +table again and register it on a new QueryBuilder. + +`DeltaCatalog` owns the DeltaTables and the QueryBuilder they are registered +on. When a query fails because an object is missing, it reloads only the +table named in that query, swaps in a new QueryBuilder with every table +registered on it, and retries the query once. + +Blocking native calls can be sent to a pluggable runner (e.g. a threadpool), +see `DeltaCatalog`. +""" + +from __future__ import annotations + +import logging +import re +import threading +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING, Any + +import pyarrow as pa +from deltalake import DeltaTable, QueryBuilder + +if TYPE_CHECKING: + from collections.abc import Callable + + from arro3.core import RecordBatchReader + + # Takes a zero-argument function, runs it, returns its result + Runner = Callable[[Callable[[], Any]], Any] + +logger = logging.getLogger(__name__) + + +def _call_directly(fn: Callable[[], Any]) -> Any: + """Default runner: run `fn` in the calling thread.""" + return fn() + + +# Messages that mean a data or log file referenced by the current snapshot +# is gone (e.g. removed by a vacuum on the remote table). +# DataFusion errors reach Python as a generic `DeltaError`, so the message +# has to be matched as well as the exception type. Example: +# "Failed to fetch metadata for file ...: Parquet error: External: +# Object at location ... not found: No such file or directory (os error 2)" +_MISSING_OBJECT_PATTERN = re.compile( + r"object at location .* not found" + r"|notfound" + r"|not found" + r"|no such file or directory" + r"|status code: 404", + re.IGNORECASE, +) + + +def _is_missing_object(exc: BaseException) -> bool: + """Whether an exception means a file in the snapshot no longer exists. + + Args: + exc (BaseException) : exception raised by deltalake / DataFusion + + Returns: + bool + """ + if isinstance(exc, FileNotFoundError): + return True + return bool(_MISSING_OBJECT_PATTERN.search(str(exc))) + + +@dataclass +class _Entry: + """A DeltaTable registered under a label, plus what's needed to reload it.""" + + label: str + uri: str + table: DeltaTable + storage_options: dict[str, str] | None = None + # Increases each time this table is reloaded. Used to tell whether + # another thread already refreshed it. + generation: int = 0 + + +class DeltaCatalog: + """Thread-safe cache of DeltaTables and the QueryBuilder they are registered on. + + One catalog can be shared by every rester in a process. + Snapshots stay fixed until a query fails because a + referenced object is missing, then only that table is reloaded. + + Blocking native work (loading a table's snapshot from storage, and + running a query) can be sent to a `runner`. A runner is any callable + that takes a zero-argument function, runs it, and returns its result + or raises its exception. Under gevent, for example, running native calls + in a real thread stops them from blocking the event loop: + + ```python + import gevent + + catalog = DeltaCatalog(runner=gevent.get_hub().threadpool.apply) + ``` + + or, with a standard executor: + + ```python + from concurrent.futures import ThreadPoolExecutor + + pool = ThreadPoolExecutor(8) + catalog = DeltaCatalog(runner=lambda fn: pool.submit(fn).result()) + ``` + + The catalog's own locking and bookkeeping always run in the calling + thread / greenlet, never inside the runner. The catalog-wide lock is + never held during a load or query, so a slow load of one table doesn't + block queries on other tables. + """ + + def __init__(self, runner: Runner | None = None) -> None: + """Create an empty catalog. + + Args: + runner (callable or None) : runs blocking native calls, see the + class docstring. If None, they run directly in the caller. + """ + self._runner: Runner = runner or _call_directly + self._entries: dict[str, _Entry] = {} # keyed by URI + self._labels: dict[str, str] = {} # label -> URI + self._qb: QueryBuilder = QueryBuilder() + # Guards _entries, _labels, _qb and _load_locks. Only held for + # in-memory work, never across a runner call. + self._lock = threading.RLock() + # One lock per URI, held while that table is (re)loaded so concurrent + # loads of the same table collapse into one. + self._load_locks: dict[str, threading.Lock] = {} + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(tables={self.labels})" + + @property + def runner(self) -> Runner: + """The callable used to run blocking native calls.""" + return self._runner + + @property + def labels(self) -> list[str]: + """Labels (SQL table names) of all registered tables.""" + with self._lock: + return list(self._labels) + + @property + def tables(self) -> dict[str, DeltaTable]: + """Map of label to currently registered DeltaTable.""" + with self._lock: + return { + label: self._entries[uri].table for label, uri in self._labels.items() + } + + def __contains__(self, label: str) -> bool: + with self._lock: + return label in self._labels + + def __len__(self) -> int: + with self._lock: + return len(self._entries) + + def add( + self, + label: str, + table: DeltaTable, + storage_options: dict[str, str] | None = None, + uri: str | None = None, + ) -> tuple[str, DeltaTable]: + """Register an already-constructed DeltaTable. + + If a table with the same URI is already registered, the stored + label and table are returned and `table` is discarded. + + Args: + label (str) : SQL table name + table (DeltaTable) : the table to register + storage_options (dict or None) : storage options used to reload + the table. Defaults to the options the table was created with. + uri (str or None) : cache key. Defaults to `table.table_uri`. + + Returns: + tuple of the label the table is registered under and the DeltaTable. + + Raises: + ValueError: if `label` is already used by a table with a different URI. + """ + uri = uri or table.table_uri + with self._lock: + if (existing := self._entries.get(uri)) is not None: + return existing.label, existing.table + self._check_label_free(label, uri) + self._qb.register(label, table) + self._entries[uri] = _Entry( + label=label, + uri=uri, + table=table, + storage_options=( + storage_options + if storage_options is not None + else getattr(table, "_storage_options", None) + ), + ) + self._labels[label] = uri + return label, table + + def get_table( + self, + uri: str, + label: str, + storage_options: dict[str, str] | None = None, + refresh: bool = False, + ) -> tuple[str, DeltaTable]: + """Get a cached DeltaTable, or load and register it. + + Args: + uri (str) : table URI, also the cache key + label (str) : SQL table name to register a new table under + storage_options (dict or None) : storage options for a newly loaded table + refresh (bool) : if the table is already cached, reload it first. + Skipped if another caller reloaded it while this one waited. + + Returns: + tuple of the label the table is registered under (may differ from + `label` if the URI was registered earlier under another name) and + the DeltaTable. + + Raises: + ValueError: if `label` is already used by a table with a different URI. + """ + with self._lock: + entry = self._entries.get(uri) + if entry is not None and not refresh: + return entry.label, entry.table + if entry is None: + # fail fast, before any network I/O + self._check_label_free(label, uri) + seen_generation = entry.generation if entry is not None else None + load_lock = self._load_lock(uri) + + with load_lock: + with self._lock: + entry = self._entries.get(uri) + + if entry is None: + table = self._load(uri, storage_options) + return self.add(label, table, storage_options=storage_options, uri=uri) + + # Reload only if nobody else loaded / reloaded it while we waited + if seen_generation is not None and entry.generation == seen_generation: + self._reload(entry) + return entry.label, entry.table + + def execute(self, sql: str, label: str | None = None) -> pa.Table: + """Run a SQL query and return the full result. + + If `label` is given and the query fails because an object is missing, + that table is reloaded and the query is retried once. Without + `label`, errors are raised as-is. + + Args: + sql (str) : SQL query + label (str or None) : the table the query reads from + + Returns: + pyarrow.Table + + Raises: + Whatever deltalake / DataFusion / pyarrow raises. If the retry + also fails, its exception has a note saying the table was refreshed. + """ + with self._lock: + qb = self._qb + entry = self._entries.get(self._labels.get(label, "")) if label else None + seen_generation = entry.generation if entry else None + + try: + return self._runner(partial(self._run, qb, sql)) + except Exception as exc: + if entry is None or not _is_missing_object(exc): + raise + logger.warning( + f"Query on DeltaTable '{label}' referenced a missing object, " + f"refreshing snapshot and retrying: {exc}" + ) + + qb = self._refresh(entry, seen_generation) # type: ignore[arg-type] + try: + return self._runner(partial(self._run, qb, sql)) + except Exception as exc: + exc.add_note( + f"Query failed again after refreshing DeltaTable '{label}' ({entry.uri})." + ) + raise + + def execute_stream(self, sql: str) -> RecordBatchReader: + """Run a SQL query and stream the results, without retrying. + + Used for full-dataset downloads, where batches may already have been + written to disk before an error, so a transparent retry isn't safe. + Call `get_table(..., refresh=True)` first to start from the latest snapshot. + + Does not use the runner: batches are fetched lazily as the returned + reader is consumed, in the consuming thread. + + Args: + sql (str) : SQL query + + Returns: + arro3 RecordBatchReader + """ + with self._lock: + qb = self._qb + return qb.execute(sql) + + @staticmethod + def _run(qb: QueryBuilder, sql: str) -> pa.Table: + # Read via pyarrow's C stream interface rather than arro3's + # `read_all()`: arro3 holds the GIL while it fetches every batch, + # pyarrow releases it, so other threads (and, with a threadpool + # runner, the gevent loop) keep running during the fetch. + # A missing-file error can surface while planning (execute) or while + # reading batches; both happen inside the caller's try. + return pa.table(qb.execute(sql)) + + def _load(self, uri: str, storage_options: dict[str, str] | None) -> DeltaTable: + """Load a table snapshot from storage via the runner. Caller holds the URI's load lock.""" + return self._runner(partial(DeltaTable, uri, storage_options=storage_options)) + + def _load_lock(self, uri: str) -> threading.Lock: + """Get or create the load lock for a URI. Caller holds the catalog lock.""" + if (lock := self._load_locks.get(uri)) is None: + lock = self._load_locks[uri] = threading.Lock() + return lock + + def _check_label_free(self, label: str, uri: str) -> None: + if (other := self._labels.get(label)) is not None and other != uri: + raise ValueError( + f"Label '{label}' is already registered for DeltaTable {other}, " + f"cannot register it for {uri}." + ) + + def _refresh(self, entry: _Entry, seen_generation: int) -> QueryBuilder: + """Reload `entry` unless another caller already did, and return the current QueryBuilder.""" + with self._lock: + load_lock = self._load_lock(entry.uri) + with load_lock: + if entry.generation == seen_generation: + self._reload(entry) + with self._lock: + return self._qb + + def _reload(self, entry: _Entry) -> None: + """Reload one table's snapshot and swap in a new QueryBuilder. + + Caller holds the entry's load lock, but not the catalog lock: the + load runs without it, then the catalog lock is taken briefly to swap. + + Builds a new DeltaTable instead of calling `update_incremental()`, in + case the table was rebuilt rather than appended to. Other tables keep + their snapshots; registering them again only copies in-memory state. + Queries already running keep using the old QueryBuilder. + """ + table = self._load(entry.uri, entry.storage_options) + + with self._lock: + # Rebuild from the entries as they are now, so a concurrent + # reload of a different table isn't lost. + qb = QueryBuilder() + for other in self._entries.values(): + qb.register(other.label, table if other is entry else other.table) + entry.table = table + entry.generation += 1 + self._qb = qb + + logger.info( + f"Refreshed DeltaTable '{entry.label}' ({entry.uri}) " + f"to version {table.version()}." + ) diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index ebb191bc..ced3f9c7 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -64,6 +64,7 @@ from pymatgen.util.typing import SpeciesLike from mp_api.client.core.client import QueryBuilderWithCache + from mp_api.client.core.delta import DeltaCatalog from mp_api.client.core.schemas import _DictLikeAccess @@ -120,6 +121,7 @@ def __init__( ) = MAPI_CLIENT_SETTINGS.LOCAL_DATASET_CACHE, force_renew: bool = False, query_builder: QueryBuilderWithCache | None = None, + delta_catalog: DeltaCatalog | None = None, notify_db_version: bool = False, **kwargs, ): @@ -154,8 +156,13 @@ def __init__( local_dataset_cache: Target directory for downloading full datasets. Defaults to "mp_datasets" in the user's home directory force_renew: Option to overwrite existing local dataset - query_builder : Instance of QueryBuilderWithCache to use in querying delta tables + query_builder : DEPRECATED, use `delta_catalog`. Instance of QueryBuilderWithCache + whose catalog is used for querying delta tables. NOTE: Must be a QueryBuilderWithCache, a deltalake.QueryBuilder will be ignored. + delta_catalog : Instance of DeltaCatalog to use for querying delta tables. + Share one instance across MPRester instances (e.g. one per web-server + worker) to reuse loaded table snapshots. If None, one is created and + shared by all resters under this MPRester. notify_db_version (bool): If True, the current MP database version will be retrieved and logged locally in the ~/.mprester.log.yaml. If the database version changes, you will be notified. The current database version is @@ -177,6 +184,7 @@ def __init__( local_dataset_cache=local_dataset_cache, force_renew=force_renew, query_builder=query_builder, + delta_catalog=delta_catalog, **kwargs, ) @@ -269,7 +277,7 @@ def __init__( db_version=self.db_version, local_dataset_cache=self.local_dataset_cache, force_renew=self.force_renew, - query_builder=self._query_builder, + delta_catalog=self.delta_catalog, ), ) diff --git a/mp_api/client/routes/materials/electronic_structure.py b/mp_api/client/routes/materials/electronic_structure.py index ea42b99f..1c009408 100644 --- a/mp_api/client/routes/materials/electronic_structure.py +++ b/mp_api/client/routes/materials/electronic_structure.py @@ -164,6 +164,7 @@ def es_rester(self) -> ElectronicStructureRester: use_document_model=self.use_document_model, headers=self.headers, mute_progress_bars=self.mute_progress_bars, + delta_catalog=self.delta_catalog, ) return self._es_rester @@ -298,7 +299,7 @@ def get_bandstructure_from_task_id( if path_type: query += f"\nAND path_convention='{path_type}'" - table = self._query_delta_single(query) + table = self._query_delta_single(query, label=bs_lbl) if len(deser := table.to_pylist(maps_as_pydicts="strict")) > 0: if load_projections: proj_bs_label, _ = self._get_delta_table( @@ -307,7 +308,7 @@ def get_bandstructure_from_task_id( label="bandstructure_projections", ) proj_table = self._query_delta_single( - query.replace(bs_lbl, proj_bs_label) + query.replace(bs_lbl, proj_bs_label), label=proj_bs_label ) if ( len(deser_proj := proj_table.to_pylist(maps_as_pydicts="strict")) @@ -553,7 +554,7 @@ def get_dos_from_task_id( rt = RunType(run_type) if isinstance(run_type, str) else run_type query += f"\nAND run_type='{rt.value}'" - table = self._query_delta_single(query) + table = self._query_delta_single(query, label=dos_lbl) if len(deser := table.to_pylist(maps_as_pydicts="strict")) > 0: if load_projections: proj_dos_label, _ = self._get_delta_table( @@ -562,7 +563,7 @@ def get_dos_from_task_id( label="dos_projections", ) proj_table = self._query_delta_single( - query.replace(dos_lbl, proj_dos_label) + query.replace(dos_lbl, proj_dos_label), label=proj_dos_label ) if ( len(deser_proj := proj_table.to_pylist(maps_as_pydicts="strict")) diff --git a/mp_api/client/routes/materials/phonon.py b/mp_api/client/routes/materials/phonon.py index 4dc778a1..81f39122 100644 --- a/mp_api/client/routes/materials/phonon.py +++ b/mp_api/client/routes/materials/phonon.py @@ -34,6 +34,7 @@ def summary_rester(self) -> SummaryRester: use_document_model=self.use_document_model, headers=self.headers, mute_progress_bars=self.mute_progress_bars, + delta_catalog=self.delta_catalog, ) return self._summary_rester @@ -175,7 +176,7 @@ def get_bandstructure_from_phonon_id( if path_type: query += f"\nAND path_convention='{path_type}'" - table = self._query_delta_single(query) + table = self._query_delta_single(query, label=ph_bs_lbl) deser = table.to_pylist(maps_as_pydicts="strict") if deser and deser[0].get("bandstructure") is not None: bs = deser[0]["bandstructure"] @@ -249,7 +250,7 @@ def get_dos_from_phonon_id( AND phonon_method='{phonon_method}' """ - table = self._query_delta_single(query) + table = self._query_delta_single(query, label=ph_dos_lbl) deser = table.to_pylist(maps_as_pydicts="strict") if deser and deser[0].get("dos") is not None: dos = deser[0]["dos"] @@ -314,7 +315,7 @@ def get_forceconstants_from_phonon_id( AND phonon_method='{phonon_method}' """ - table = self._query_delta_single(query) + table = self._query_delta_single(query, label=ph_fc_lbl) deser = table.to_pylist(maps_as_pydicts="strict") if deser and deser[0].get("force_constants") is not None: return deser[0]["force_constants"] diff --git a/mp_api/client/routes/materials/tasks.py b/mp_api/client/routes/materials/tasks.py index 1496adbd..8343b767 100644 --- a/mp_api/client/routes/materials/tasks.py +++ b/mp_api/client/routes/materials/tasks.py @@ -54,7 +54,9 @@ def get_trajectory( {predicate}; """ - traj_data = self._query_delta_single(query).to_pylist(maps_as_pydicts="strict") + traj_data = self._query_delta_single(query, label=traj_lbl).to_pylist( + maps_as_pydicts="strict" + ) if not traj_data: raise MPRestError(f"No trajectory data for {task_id} found") diff --git a/mp_api/client/routes/materials/thermo.py b/mp_api/client/routes/materials/thermo.py index 64de38cc..ec7c9069 100644 --- a/mp_api/client/routes/materials/thermo.py +++ b/mp_api/client/routes/materials/thermo.py @@ -214,7 +214,7 @@ def get_phase_diagram_from_chemsys( AND version='{version}' AND thermo_type='{validated_thermo_type}' """ - table = self._query_delta_single(query) + table = self._query_delta_single(query, label=pd_lbl) as_py = table["phase_diagram"].to_pylist(maps_as_pydicts="strict") pd: PhaseDiagram | None = None diff --git a/tests/client/core/test_delta.py b/tests/client/core/test_delta.py new file mode 100644 index 00000000..a6f201d8 --- /dev/null +++ b/tests/client/core/test_delta.py @@ -0,0 +1,505 @@ +"""Tests for DeltaCatalog snapshot caching and refresh-on-missing-object retry. + +deltalake's DeltaTable and QueryBuilder are replaced with in-memory fakes, so +these tests need no network access, API key, or files on disk. +""" + +import threading +import warnings + +import pyarrow as pa +import pytest +from deltalake.exceptions import DeltaError + +import mp_api.client.core.delta as delta_mod +from mp_api.client.core.client import BaseRester, QueryBuilderWithCache, _Rester +from mp_api.client.core.delta import DeltaCatalog, _is_missing_object +from mp_api.client.core.exceptions import MPRestError, MPRestWarning + +REAL_ERROR = ( + "Failed to fetch metadata for file Users/tsmathis/mp_datasets/build/collections/" + "materials/version=2026-04-13/group-4-part-0.zstd.parquet: Parquet error: External: " + "Object at location /Users/tsmathis/mp_datasets/build/collections/materials/" + "version=2026-04-13/group-4-part-0.zstd.parquet not found: No such file or " + "directory (os error 2)" +) + + +class FakeDeltaTable: + """Stand-in for deltalake.DeltaTable; each construction is a new 'snapshot'.""" + + loads: list[str] = [] + + def __init__(self, uri, storage_options=None, **kwargs): + self.table_uri = uri + self._storage_options = storage_options + FakeDeltaTable.loads.append(uri) + self.snapshot = FakeDeltaTable.loads.count(uri) + + def version(self): + return self.snapshot + + +class FakeReader: + """Arrow C-stream producer, like the arro3 reader returned by QueryBuilder.execute.""" + + def __init__(self, result): + self._result = result + + def __arrow_c_stream__(self, requested_schema=None): + if isinstance(self._result, Exception): + # error surfacing while batches are read + raise self._result + return self._result.__arrow_c_stream__(requested_schema) + + +class FakeQueryBuilder: + """Stand-in for deltalake.QueryBuilder that can be told to fail.""" + + instances: list["FakeQueryBuilder"] = [] + # Queue of exceptions raised by successive execute() calls (any builder) + failures: list[Exception] = [] + # Raise failures while reading batches instead of from execute() + fail_on_read: bool = False + + def __init__(self): + self.registered: dict[str, FakeDeltaTable] = {} + FakeQueryBuilder.instances.append(self) + + def register(self, table_name, delta_table): + if table_name in self.registered: + raise DeltaError(f"The table {table_name} already exists") + self.registered[table_name] = delta_table + return self + + def execute(self, sql): + result = pa.table({"sql": [sql]}) + if FakeQueryBuilder.failures: + exc = FakeQueryBuilder.failures.pop(0) + if not FakeQueryBuilder.fail_on_read: + raise exc + result = exc + return FakeReader(result) + + +@pytest.fixture(autouse=True) +def fake_deltalake(monkeypatch): + FakeDeltaTable.loads = [] + FakeQueryBuilder.instances = [] + FakeQueryBuilder.failures = [] + FakeQueryBuilder.fail_on_read = False + monkeypatch.setattr(delta_mod, "DeltaTable", FakeDeltaTable) + monkeypatch.setattr(delta_mod, "QueryBuilder", FakeQueryBuilder) + # QueryBuilderWithCache subclasses the real QueryBuilder; skip its native init + monkeypatch.setattr( + "deltalake.query.QueryBuilder.__init__", lambda self: None, raising=True + ) + + +@pytest.fixture +def catalog(): + cat = DeltaCatalog() + cat.get_table("s3a://bucket/a/", "a") + cat.get_table("s3a://bucket/b/", "b") + return cat + + +# --- _is_missing_object -------------------------------------------------------- + + +@pytest.mark.parametrize( + "exc", + [ + DeltaError(REAL_ERROR), + OSError(REAL_ERROR), + FileNotFoundError("gone"), + DeltaError("Generic S3 error: ... status code: 404 Not Found"), + DeltaError("Object Store error: NotFound { path: ... }"), + ], +) +def test_is_missing_object_matches(exc): + assert _is_missing_object(exc) + + +@pytest.mark.parametrize( + "exc", + [ + DeltaError('SQL error: ParserError("Expected: an expression")'), + DeltaError("Schema error: No field named foo"), + OSError("Generic S3 error: request timed out"), + ], +) +def test_is_missing_object_ignores_other_errors(exc): + assert not _is_missing_object(exc) + + +# --- caching ------------------------------------------------------------------- + + +def test_get_table_caches_by_uri(catalog): + label, table = catalog.get_table("s3a://bucket/a/", "a") + assert label == "a" + assert FakeDeltaTable.loads.count("s3a://bucket/a/") == 1 + assert catalog.labels == ["a", "b"] + assert "a" in catalog and len(catalog) == 2 + assert catalog.tables["a"] is table + + +def test_get_table_returns_stored_label_for_known_uri(catalog): + label, _ = catalog.get_table("s3a://bucket/a/", "other_name") + assert label == "a" + assert "other_name" not in catalog + + +def test_label_collision_with_different_uri_raises(catalog): + with pytest.raises(ValueError, match="already registered"): + catalog.get_table("s3a://bucket/c/", "a") + + +def test_get_table_refresh_reloads_only_that_table(catalog): + _, old_b = catalog.get_table("s3a://bucket/b/", "b") + old_qb = catalog._qb + + _, new_a = catalog.get_table("s3a://bucket/a/", "a", refresh=True) + + assert new_a.snapshot == 2 + assert catalog._qb is not old_qb + # new builder has every table, with untouched tables carried over as-is + assert catalog._qb.registered == {"a": new_a, "b": old_b} + assert FakeDeltaTable.loads.count("s3a://bucket/b/") == 1 + + +# --- execute / retry ----------------------------------------------------------- + + +def test_execute_success_no_refresh(catalog): + result = catalog.execute("SELECT * FROM a", label="a") + assert result["sql"].to_pylist() == ["SELECT * FROM a"] + assert len(FakeQueryBuilder.instances) == 1 + + +@pytest.mark.parametrize("fail_on_read", [False, True]) +def test_missing_object_refreshes_named_table_and_retries(catalog, fail_on_read): + FakeQueryBuilder.fail_on_read = fail_on_read + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR)] + _, old_b = catalog.get_table("s3a://bucket/b/", "b") + + result = catalog.execute("SELECT * FROM a", label="a") + + assert result.num_rows == 1 + assert FakeDeltaTable.loads.count("s3a://bucket/a/") == 2 + assert FakeDeltaTable.loads.count("s3a://bucket/b/") == 1 + assert catalog.tables["b"] is old_b + assert catalog.tables["a"].snapshot == 2 + assert set(catalog._qb.registered) == {"a", "b"} + + +def test_non_missing_error_is_not_retried(catalog): + FakeQueryBuilder.failures = [DeltaError("SQL error: bad syntax")] + with pytest.raises(DeltaError, match="bad syntax"): + catalog.execute("SELECT * FROM a", label="a") + assert FakeDeltaTable.loads.count("s3a://bucket/a/") == 1 + + +def test_no_label_is_not_retried(catalog): + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR)] + with pytest.raises(DeltaError): + catalog.execute("SELECT * FROM a") + assert FakeDeltaTable.loads.count("s3a://bucket/a/") == 1 + + +def test_second_failure_raises_with_note(catalog): + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR), DeltaError(REAL_ERROR)] + with pytest.raises(DeltaError) as excinfo: + catalog.execute("SELECT * FROM a", label="a") + assert any("after refreshing" in n for n in excinfo.value.__notes__) + # exactly one refresh + assert FakeDeltaTable.loads.count("s3a://bucket/a/") == 2 + + +def test_concurrent_refresh_is_not_repeated(catalog): + """A refresh by another thread between failure and retry isn't redone.""" + entry = catalog._entries["s3a://bucket/a/"] + seen = entry.generation + catalog.get_table("s3a://bucket/a/", "a", refresh=True) # "other thread" + loads_before = len(FakeDeltaTable.loads) + + qb = catalog._refresh(entry, seen) + + assert len(FakeDeltaTable.loads) == loads_before + assert qb is catalog._qb + + +# --- runner hook --------------------------------------------------------------- + + +class RecordingRunner: + def __init__(self): + self.calls = [] + + def __call__(self, fn): + name = getattr(getattr(fn, "func", fn), "__name__", repr(fn)) + self.calls.append(name) + return fn() + + +def test_default_runner_calls_directly(): + cat = DeltaCatalog() + assert cat.runner(lambda: 42) == 42 + + +def test_runner_wraps_loads_queries_and_reloads(): + runner = RecordingRunner() + cat = DeltaCatalog(runner=runner) + + cat.get_table("s3a://bucket/a/", "a") + assert runner.calls == ["FakeDeltaTable"] + + cat.execute("SELECT * FROM a", label="a") + assert runner.calls[-1] == "_run" + + runner.calls.clear() + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR)] + assert cat.execute("SELECT * FROM a", label="a").num_rows == 1 + # failed query, reload, retried query + assert runner.calls == ["_run", "FakeDeltaTable", "_run"] + + runner.calls.clear() + cat.get_table("s3a://bucket/a/", "a", refresh=True) + assert runner.calls == ["FakeDeltaTable"] + + # cache hit: no native work + runner.calls.clear() + cat.get_table("s3a://bucket/a/", "a") + assert runner.calls == [] + + +def test_runner_exceptions_propagate(): + def runner(fn): + return fn() + + cat = DeltaCatalog(runner=runner) + cat.get_table("s3a://bucket/a/", "a") + FakeQueryBuilder.failures = [DeltaError("SQL error: bad syntax")] + with pytest.raises(DeltaError, match="bad syntax"): + cat.execute("SELECT * FROM a", label="a") + + +def test_threadpool_runner(): + from concurrent.futures import ThreadPoolExecutor + + main = threading.get_ident() + seen = [] + + with ThreadPoolExecutor(2) as pool: + + def runner(fn): + def wrapped(): + seen.append(threading.get_ident()) + return fn() + + return pool.submit(wrapped).result() + + cat = DeltaCatalog(runner=runner) + cat.get_table("s3a://bucket/a/", "a") + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR)] + assert cat.execute("SELECT * FROM a", label="a").num_rows == 1 + + assert seen and main not in seen + + +# --- concurrency --------------------------------------------------------------- + + +class BlockingLoads: + """Patch FakeDeltaTable so loads of one URI block until released.""" + + def __init__(self, monkeypatch, uri): + self.uri = uri + self.started = threading.Event() + self.release = threading.Event() + original = FakeDeltaTable.__init__ + + def init(table, table_uri, storage_options=None, **kwargs): + if table_uri == self.uri: + self.started.set() + assert self.release.wait(5), "test timed out waiting for release" + original(table, table_uri, storage_options=storage_options, **kwargs) + + monkeypatch.setattr(FakeDeltaTable, "__init__", init) + + +def _in_thread(fn, *args, **kwargs): + result = {} + + def target(): + try: + result["value"] = fn(*args, **kwargs) + except Exception as exc: # pragma: no cover - surfaced via assert + result["error"] = exc + + th = threading.Thread(target=target, daemon=True) + th.start() + return th, result + + +def test_reload_does_not_block_queries_on_other_tables(catalog, monkeypatch): + blocker = BlockingLoads(monkeypatch, "s3a://bucket/a/") + + th, res = _in_thread(catalog.get_table, "s3a://bucket/a/", "a", refresh=True) + assert blocker.started.wait(5) + + # While a's reload is stuck in the network call, b is still queryable + # and a new table can be added. + assert catalog.execute("SELECT * FROM b", label="b").num_rows == 1 + catalog.get_table("s3a://bucket/c/", "c") + + blocker.release.set() + th.join(5) + assert "error" not in res + # reload swapped in a builder holding all three tables + assert set(catalog._qb.registered) == {"a", "b", "c"} + assert catalog.tables["a"].snapshot == 2 + + +def test_concurrent_first_loads_of_same_uri_load_once(monkeypatch): + cat = DeltaCatalog() + blocker = BlockingLoads(monkeypatch, "s3a://bucket/a/") + + t1, r1 = _in_thread(cat.get_table, "s3a://bucket/a/", "a") + assert blocker.started.wait(5) + t2, r2 = _in_thread(cat.get_table, "s3a://bucket/a/", "a") + + blocker.release.set() + t1.join(5) + t2.join(5) + + assert FakeDeltaTable.loads.count("s3a://bucket/a/") == 1 + assert r1["value"][1] is r2["value"][1] + + +def test_concurrent_refreshes_of_same_table_reload_once(catalog, monkeypatch): + blocker = BlockingLoads(monkeypatch, "s3a://bucket/a/") + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR), DeltaError(REAL_ERROR)] + + t1, r1 = _in_thread(catalog.execute, "SELECT * FROM a", label="a") + assert blocker.started.wait(5) + t2, r2 = _in_thread(catalog.execute, "SELECT * FROM a", label="a") + + blocker.release.set() + t1.join(5) + t2.join(5) + + assert "error" not in r1 and "error" not in r2 + # initial load + exactly one reload + assert FakeDeltaTable.loads.count("s3a://bucket/a/") == 2 + + +def test_failed_load_is_not_registered(monkeypatch): + cat = DeltaCatalog() + + def boom(table, table_uri, **kwargs): + raise OSError("connection refused") + + monkeypatch.setattr(FakeDeltaTable, "__init__", boom) + with pytest.raises(OSError): + cat.get_table("s3a://bucket/a/", "a") + assert "a" not in cat and len(cat) == 0 + + +def test_failed_reload_keeps_old_snapshot(catalog, monkeypatch): + old_a = catalog.tables["a"] + old_qb = catalog._qb + + def boom(table, table_uri, **kwargs): + raise OSError("connection refused") + + monkeypatch.setattr(FakeDeltaTable, "__init__", boom) + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR)] + with pytest.raises(OSError): + catalog.execute("SELECT * FROM a", label="a") + assert catalog.tables["a"] is old_a + assert catalog._qb is old_qb + + +# --- rester integration -------------------------------------------------------- + + +@pytest.fixture +def no_heartbeat(monkeypatch): + monkeypatch.setattr( + BaseRester, "_get_heartbeat_info", staticmethod(lambda endpoint: ("v", [])) + ) + + +def test_rester_creates_catalog_lazily(): + rester = _Rester(api_key="a" * 32) + assert rester._delta_catalog is None + assert isinstance(rester.delta_catalog, DeltaCatalog) + assert rester.delta_catalog is rester.delta_catalog + + +def test_rester_uses_provided_catalog(): + cat = DeltaCatalog() + assert _Rester(api_key="a" * 32, delta_catalog=cat).delta_catalog is cat + + +def test_query_builder_with_cache_is_deprecated_but_shared(): + with pytest.warns(DeprecationWarning, match="QueryBuilderWithCache"): + qb = QueryBuilderWithCache() + r1 = _Rester(api_key="a" * 32, query_builder=qb) + r2 = _Rester(api_key="a" * 32, query_builder=qb) + assert r1.delta_catalog is r2.delta_catalog is qb.catalog + + +def test_query_builder_with_cache_register_and_introspect(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + qb = QueryBuilderWithCache() + table = FakeDeltaTable("s3a://bucket/x/") + assert qb.register("x", table) is qb + assert qb._delta_tables == {"x": table} + assert "x" in qb.catalog + + +def test_query_builder_property_is_deprecated(): + rester = _Rester(api_key="a" * 32) + with pytest.warns(DeprecationWarning, match="delta_catalog"): + qb = rester.query_builder + assert qb.catalog is rester.delta_catalog + + +def test_sub_resters_share_parent_catalog(no_heartbeat): + from mp_api.client.routes.materials.materials import MaterialsRester + + cat = DeltaCatalog() + materials = MaterialsRester(api_key="a" * 32, delta_catalog=cat) + assert materials.thermo.delta_catalog is cat + assert materials.tasks.delta_catalog is cat + assert materials.phonon.summary_rester.delta_catalog is cat + + +def test_get_delta_table_warns_on_label_mismatch(no_heartbeat): + rester = BaseRester(api_key="a" * 32) + lbl, _ = rester._get_delta_table("bucket", "some/prefix", label="first") + assert lbl == "first" + assert rester.delta_catalog.tables["first"].table_uri == "s3a://bucket/some/prefix/" + with pytest.warns(MPRestWarning, match="different label"): + lbl, _ = rester._get_delta_table("bucket", "some/prefix/", label="second") + assert lbl == "first" + + +def test_query_delta_single_retries_and_wraps_errors(no_heartbeat): + rester = BaseRester(api_key="a" * 32) + lbl, _ = rester._get_delta_table("bucket", "prefix", label="tbl") + + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR)] + assert rester._query_delta_single("SELECT 1", label=lbl).num_rows == 1 + + FakeQueryBuilder.failures = [DeltaError(REAL_ERROR), DeltaError(REAL_ERROR)] + with pytest.raises(MPRestError, match="refreshed and the query retried"): + rester._query_delta_single("SELECT 1", label=lbl) + + FakeQueryBuilder.failures = [DeltaError("request timed out")] + with pytest.raises(MPRestError, match="increasing the 'timeout'"): + rester._query_delta_single("SELECT 1", label=lbl) From 40f27780b38070962343af2f37aab0da4b300a5f Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:41:47 -0700 Subject: [PATCH 09/30] some 3.12 cleanups --- mp_api/_test_utils.py | 4 +- mp_api/client/contribs/client.py | 23 +----- mp_api/client/core/client.py | 16 +--- mp_api/client/core/utils.py | 2 +- mp_api/client/mprester.py | 8 +- mp_api/client/routes/_server.py | 4 +- mp_api/client/routes/materials/absorption.py | 4 +- mp_api/client/routes/materials/alloys.py | 6 +- mp_api/client/routes/materials/bonds.py | 14 ++-- mp_api/client/routes/materials/chemenv.py | 28 +++---- mp_api/client/routes/materials/dielectric.py | 12 +-- mp_api/client/routes/materials/doi.py | 4 +- mp_api/client/routes/materials/elasticity.py | 20 ++--- mp_api/client/routes/materials/electrodes.py | 36 ++++----- .../routes/materials/electronic_structure.py | 32 ++++---- mp_api/client/routes/materials/eos.py | 8 +- .../routes/materials/grain_boundaries.py | 14 ++-- mp_api/client/routes/materials/magnetism.py | 14 ++-- mp_api/client/routes/materials/materials.py | 26 +++--- .../routes/materials/oxidation_states.py | 10 +-- mp_api/client/routes/materials/phonon.py | 6 +- mp_api/client/routes/materials/piezo.py | 6 +- mp_api/client/routes/materials/provenance.py | 4 +- mp_api/client/routes/materials/robocrys.py | 12 +-- mp_api/client/routes/materials/similarity.py | 4 +- mp_api/client/routes/materials/substrates.py | 10 +-- mp_api/client/routes/materials/summary.py | 80 +++++++++---------- .../routes/materials/surface_properties.py | 12 +-- mp_api/client/routes/materials/synthesis.py | 28 +++---- mp_api/client/routes/materials/tasks.py | 10 +-- mp_api/client/routes/materials/thermo.py | 26 +++--- mp_api/client/routes/materials/xas.py | 10 +-- mp_api/client/routes/molecules/jcesr.py | 15 ++-- mp_api/client/routes/molecules/summary.py | 12 +-- pyproject.toml | 9 +-- 35 files changed, 250 insertions(+), 279 deletions(-) diff --git a/mp_api/_test_utils.py b/mp_api/_test_utils.py index 332eaef7..85a9727d 100644 --- a/mp_api/_test_utils.py +++ b/mp_api/_test_utils.py @@ -46,8 +46,8 @@ def client_search_testing( alt_name_dict (dict[str, str]): Alternative names for parameters used in the projection and subsequent data checking custom_field_tests (dict[str, Any]): Custom queries for specific fields. sub_doc_fields (list[str]): Prefixes for fields to check in resulting data. Useful when data to be tested is nested. - int_bounds (tuple[int,int]) : integer bounds to use in testing int-type query arguments - float_bounds (tuple[float,float]) : float bounds to use in testing float-type query arguments + int_bounds (tuple[int, int]) : integer bounds to use in testing int-type query arguments + float_bounds (tuple[float, float]) : float bounds to use in testing float-type query arguments """ if search_method is None: return diff --git a/mp_api/client/contribs/client.py b/mp_api/client/contribs/client.py index c91611aa..438184bc 100644 --- a/mp_api/client/contribs/client.py +++ b/mp_api/client/contribs/client.py @@ -69,7 +69,7 @@ from mp_api.client.core.schemas import _convert_to_model if TYPE_CHECKING: - from collections.abc import Generator, Iterable, Sequence + from collections.abc import Iterable, Sequence from typing import Any from mp_api.client.contribs._types import ( @@ -165,25 +165,6 @@ def validate_url( bravado_config_dict["bravado"] = bravado_config -# https://stackoverflow.com/a/8991553 -def grouper(n: int, iterable: Iterable) -> Generator: - """Collect data into non-overlapping fixed-length chunks or blocks. - - Args: - n (int) : Maximum number of elements per block - iterable (Iterable) : object to divide into blocks - - Returns: - Generator of input iterable divided into blocks - """ - it = iter(iterable) - while True: - chunk = tuple(itertools.islice(it, n)) - if not chunk: - return - yield chunk - - def get_session(session: requests.Session | None = None) -> FuturesSession: """Start a futures session. @@ -647,7 +628,7 @@ def _split_query( line_len = len(",".join(vv).encode("utf-8")) if len(v) > per_page: - for chunk in grouper(per_page, v): + for chunk in itertools.batched(v, per_page): queries.append({k: list(chunk)}) query["per_page"] = per_page diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index 38853eb3..be5968d4 100644 --- a/mp_api/client/core/client.py +++ b/mp_api/client/core/client.py @@ -19,7 +19,7 @@ from functools import cache from importlib.metadata import PackageNotFoundError, version from io import BytesIO -from itertools import chain, islice +from itertools import batched, chain from json import JSONDecodeError from math import ceil from pathlib import Path @@ -59,7 +59,7 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Iterator + from collections.abc import Callable from typing import Any from arro3.core import RecordBatchReader @@ -94,14 +94,6 @@ logger = logging.getLogger(__name__) -def _batched(iterable: Iterable, n: int) -> Iterator: - if n < 1: - raise ValueError("n must be at least one") - iterator = iter(iterable) - while batch := tuple(islice(iterator, n)): - yield batch - - class QueryBuilderWithCache(QueryBuilder): def __init__(self, catalog: DeltaCatalog | None = None, _warn: bool = True) -> None: """Deprecated: use `mp_api.client.core.delta.DeltaCatalog`. @@ -1255,7 +1247,7 @@ def _submit_requests( else None ) - for batch in _batched(split_values, batch_size): + for batch in batched(split_values, batch_size): split_criteria = copy(criteria) split_criteria[split_param] = ",".join(batch) @@ -1613,7 +1605,7 @@ def _search( significantly speed up data retrieval for large queries and help us by reducing load on the Materials Project servers. Set to True by default to reduce confusion, unless "fields" are set, in which case all_fields will be set to False. - fields (List[str]): List of fields to project. When searching, it is better to only ask for + fields (list[str]): List of fields to project. When searching, it is better to only ask for the specific fields of interest to reduce the time taken to retrieve the documents. See the available_fields property to see a list of fields to choose from. kwargs: Supported search terms, e.g. nelements_max=3 for the "materials" search API. diff --git a/mp_api/client/core/utils.py b/mp_api/client/core/utils.py index 116b7d1e..3e305378 100644 --- a/mp_api/client/core/utils.py +++ b/mp_api/client/core/utils.py @@ -103,7 +103,7 @@ def validate_ids(id_list: list[str]) -> list[str]: """Function to validate material and task IDs. Args: - id_list (List[str]): List of material or task IDs. + id_list (list[str]): List of material or task IDs. Raises: MPRestError: If at least one ID is not formatted correctly. diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index ced3f9c7..985238ea 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -494,7 +494,7 @@ def get_material_ids( """Get all materials ids for a formula or chemsys. Args: - chemsys_formula (str, List[str]): A chemical system, list of chemical systems + chemsys_formula (str | list[str]): A chemical system, list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]), or single formula (e.g., Fe2O3, Si*). Returns: @@ -521,7 +521,7 @@ def get_structures( """Get a list of Structures corresponding to a chemical system or formula. Args: - chemsys_formula (str, List[str]): A chemical system, list of chemical systems + chemsys_formula (str | list[str]): A chemical system, list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]), or single formula (e.g., Fe2O3, Si*). final (bool): Whether to get the final structure, or the list of initial (pre-relaxation) structures. Defaults to True. @@ -609,7 +609,7 @@ def get_entries( entry is also returned. Args: - chemsys_formula_mpids (str, List[str]): A chemical system, list of chemical systems + chemsys_formula_mpids (str | list[str]): A chemical system, list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]), formula, list of formulas (e.g., Fe2O3, Si*, [SiO2, BiFeO3]), Materials Project ID, or list of Materials Project IDs (e.g., mp-22526, [mp-22526, mp-149]). @@ -1325,7 +1325,7 @@ def get_bandstructure_by_material_id( bandstructure, if available. Returns: - bandstructure (Union[BandStructure, BandStructureSymmLine]): BandStructure or BandStructureSymmLine object + bandstructure (BandStructure | BandStructureSymmLine): BandStructure or BandStructureSymmLine object """ return self.materials.electronic_structure_bandstructure.get_bandstructure_from_material_id( # type: ignore material_id=material_id, diff --git a/mp_api/client/routes/_server.py b/mp_api/client/routes/_server.py index 4bced809..58bee875 100644 --- a/mp_api/client/routes/_server.py +++ b/mp_api/client/routes/_server.py @@ -98,11 +98,11 @@ def get_messages( Args: last_updated (datetime): Datetime to use to query for newer messages - sort_fields (List[str]): Fields used to sort results. Prefix with '-' to sort in descending order. + sort_fields (list[str]): Fields used to sort results. Prefix with '-' to sort in descending order. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields to project. + fields (list[str]): List of fields to project. Returns: Dictionary with messages data diff --git a/mp_api/client/routes/materials/absorption.py b/mp_api/client/routes/materials/absorption.py index cc02a28f..6fd86ce7 100644 --- a/mp_api/client/routes/materials/absorption.py +++ b/mp_api/client/routes/materials/absorption.py @@ -29,7 +29,7 @@ def search( """Query for optical absorption spectra data. Arguments: - material_ids (str, List[str]): + material_ids (str | list[str]): Search for optical absorption data associated with the specified Material ID(s) num_sites (int, tuple[int, int]): @@ -52,7 +52,7 @@ def search( num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in AbsorptionDoc to return data for. + fields (list[str]): List of fields in AbsorptionDoc to return data for. Returns: ([AbsorptionDoc], [dict]) List of optical absorption documents or dictionaries. diff --git a/mp_api/client/routes/materials/alloys.py b/mp_api/client/routes/materials/alloys.py index 9e5996ea..95cae74f 100644 --- a/mp_api/client/routes/materials/alloys.py +++ b/mp_api/client/routes/materials/alloys.py @@ -30,12 +30,12 @@ def search( endpoint is useful. Arguments: - material_ids (str, List[str]): Search for alloys containing the specified Material IDs - formulae (List[str]): Search for alloys containing the specified formulae + material_ids (str | list[str]): Search for alloys containing the specified Material IDs + formulae (list[str]): Search for alloys containing the specified formulae num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in AlloyPairDoc to return data for. + fields (list[str]): List of fields in AlloyPairDoc to return data for. Returns: ([AlloyPairDoc], [dict]) List of alloy pair documents or dictionaries. diff --git a/mp_api/client/routes/materials/bonds.py b/mp_api/client/routes/materials/bonds.py index eaf826a5..17516e7e 100644 --- a/mp_api/client/routes/materials/bonds.py +++ b/mp_api/client/routes/materials/bonds.py @@ -29,20 +29,20 @@ def search( """Query bonding docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): Search for bonding data for the specified Material IDs - coordination_envs (List[str]): List of coordination environments to consider (e.g. ['Mo-S(6)', 'S-Mo(3)']). - coordination_envs_anonymous (List[str]): List of anonymous coordination environments to consider + material_ids (str | list[str]): Search for bonding data for the specified Material IDs + coordination_envs (list[str]): List of coordination environments to consider (e.g. ['Mo-S(6)', 'S-Mo(3)']). + coordination_envs_anonymous (list[str]): List of anonymous coordination environments to consider (e.g. ['A-B(6)', 'A-B(3)']). - max_bond_length (Tuple[float,float]): Minimum and maximum value for the maximum bond length + max_bond_length (tuple[float, float]): Minimum and maximum value for the maximum bond length in the structure to consider. - mean_bond_length (Tuple[float,float]): Minimum and maximum value for the mean bond length + mean_bond_length (tuple[float, float]): Minimum and maximum value for the mean bond length in the structure to consider. - min_bond_length (Tuple[float,float]): Minimum and maximum value for the minimum bond length + min_bond_length (tuple[float, float]): Minimum and maximum value for the minimum bond length in the structure to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in DielectricDoc to return data for. + fields (list[str]): List of fields in DielectricDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/chemenv.py b/mp_api/client/routes/materials/chemenv.py index 5c80690c..4edc64d3 100644 --- a/mp_api/client/routes/materials/chemenv.py +++ b/mp_api/client/routes/materials/chemenv.py @@ -50,27 +50,27 @@ def search( """Query for chemical environment data. Arguments: - material_ids (str, List[str]): Search forchemical environment associated with the specified Material IDs. - chemenv_iucr (COORDINATION_GEOMETRIES_IUCR, List[COORDINATION_GEOMETRIES_IUCR]): Unique cationic species in + material_ids (str | list[str]): Search forchemical environment associated with the specified Material IDs. + chemenv_iucr (COORDINATION_GEOMETRIES_IUCR | list[COORDINATION_GEOMETRIES_IUCR]): Unique cationic species in IUCR format, e.g. "[3n]". - chemenv_iupac (COORDINATION_GEOMETRIES_IUPAC, List[COORDINATION_GEOMETRIES_IUPAC]): Unique cationic species + chemenv_iupac (COORDINATION_GEOMETRIES_IUPAC | list[COORDINATION_GEOMETRIES_IUPAC]): Unique cationic species in IUPAC format, e.g., "T-4". - chemenv_name (COORDINATION_GEOMETRIES_NAMES, List[COORDINATION_GEOMETRIES_NAMES]): Coordination environment + chemenv_name (COORDINATION_GEOMETRIES_NAMES | list[COORDINATION_GEOMETRIES_NAMES]): Coordination environment descriptions in text form for unique cationic species, e.g. "Tetrahedron". - chemenv_symbol (COORDINATION_GEOMETRIES, List[COORDINATION_GEOMETRIES]): Coordination environment + chemenv_symbol (COORDINATION_GEOMETRIES | list[COORDINATION_GEOMETRIES]): Coordination environment descriptions as used in ChemEnv package for unique cationic species, e.g. "T:4". - species (str, List[str]): Cationic species in the crystal structure, e.g. "Ti4+". - elements (str, List[str]): Element names in the crystal structure, e.g., "Ti". - exclude_elements (List[str]): A list of elements to exclude. - csm (Tuple[float,float]): Minimum and maximum value of continuous symmetry measure to consider. - density (Tuple[float,float]): Minimum and maximum density to consider. - num_elements (Tuple[int,int]): Minimum and maximum number of elements to consider. - num_sites (Tuple[int,int]): Minimum and maximum number of sites to consider. - volume (Tuple[float,float]): Minimum and maximum volume to consider. + species (str | list[str]): Cationic species in the crystal structure, e.g. "Ti4+". + elements (str | list[str]): Element names in the crystal structure, e.g., "Ti". + exclude_elements (list[str]): A list of elements to exclude. + csm (tuple[float, float]): Minimum and maximum value of continuous symmetry measure to consider. + density (tuple[float, float]): Minimum and maximum density to consider. + num_elements (tuple[int, int]): Minimum and maximum number of elements to consider. + num_sites (tuple[int, int]): Minimum and maximum number of sites to consider. + volume (tuple[float, float]): Minimum and maximum volume to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in ChemEnvDoc to return data for. + fields (list[str]): List of fields in ChemEnvDoc to return data for. Returns: ([ChemEnvDoc], [dict]) List of chemenv documents or dictionaries. diff --git a/mp_api/client/routes/materials/dielectric.py b/mp_api/client/routes/materials/dielectric.py index ccd03bdf..b71d917f 100644 --- a/mp_api/client/routes/materials/dielectric.py +++ b/mp_api/client/routes/materials/dielectric.py @@ -28,16 +28,16 @@ def search( """Query dielectric docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - e_total (Tuple[float,float]): Minimum and maximum total dielectric constant to consider. - e_ionic (Tuple[float,float]): Minimum and maximum ionic dielectric constant to consider. - e_electronic (Tuple[float,float]): Minimum and maximum electronic dielectric constant to consider. - n (Tuple[float,float]): Minimum and maximum refractive index to consider. + e_total (tuple[float, float]): Minimum and maximum total dielectric constant to consider. + e_ionic (tuple[float, float]): Minimum and maximum ionic dielectric constant to consider. + e_electronic (tuple[float, float]): Minimum and maximum electronic dielectric constant to consider. + n (tuple[float, float]): Minimum and maximum refractive index to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in DielectricDoc to return data for. + fields (list[str]): List of fields in DielectricDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/doi.py b/mp_api/client/routes/materials/doi.py index 26b268ca..83606a3b 100644 --- a/mp_api/client/routes/materials/doi.py +++ b/mp_api/client/routes/materials/doi.py @@ -25,11 +25,11 @@ def search( """Query for DOI data. Arguments: - material_ids (str, List[str]): Search for DOI data associated with the specified Material IDs + material_ids (str | list[str]): Search for DOI data associated with the specified Material IDs num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in DOIDoc to return data for. + fields (list[str]): List of fields in DOIDoc to return data for. Returns: ([DOIDoc], [dict]) List of DOIDoc documents or dictionaries. diff --git a/mp_api/client/routes/materials/elasticity.py b/mp_api/client/routes/materials/elasticity.py index 59f622c8..d39f496c 100644 --- a/mp_api/client/routes/materials/elasticity.py +++ b/mp_api/client/routes/materials/elasticity.py @@ -32,28 +32,28 @@ def search( """Query elasticity docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - elastic_anisotropy (Tuple[float,float]): Minimum and maximum value to consider for + elastic_anisotropy (tuple[float, float]): Minimum and maximum value to consider for the elastic anisotropy. - g_voigt (Tuple[float,float]): Minimum and maximum value in GPa to consider for + g_voigt (tuple[float, float]): Minimum and maximum value in GPa to consider for the Voigt average of the shear modulus. - g_reuss (Tuple[float,float]): Minimum and maximum value in GPa to consider for + g_reuss (tuple[float, float]): Minimum and maximum value in GPa to consider for the Reuss average of the shear modulus. - g_vrh (Tuple[float,float]): Minimum and maximum value in GPa to consider for + g_vrh (tuple[float, float]): Minimum and maximum value in GPa to consider for the Voigt-Reuss-Hill average of the shear modulus. - k_voigt (Tuple[float,float]): Minimum and maximum value in GPa to consider for + k_voigt (tuple[float, float]): Minimum and maximum value in GPa to consider for the Voigt average of the bulk modulus. - k_reuss (Tuple[float,float]): Minimum and maximum value in GPa to consider for + k_reuss (tuple[float, float]): Minimum and maximum value in GPa to consider for the Reuss average of the bulk modulus. - k_vrh (Tuple[float,float]): Minimum and maximum value in GPa to consider for + k_vrh (tuple[float, float]): Minimum and maximum value in GPa to consider for the Voigt-Reuss-Hill average of the bulk modulus. - poisson_ratio (Tuple[float,float]): Minimum and maximum value to consider for + poisson_ratio (tuple[float, float]): Minimum and maximum value to consider for Poisson's ratio. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in ElasticityDoc to return data for. + fields (list[str]): List of fields in ElasticityDoc to return data for. Default is material_id and prett-formula if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/electrodes.py b/mp_api/client/routes/materials/electrodes.py index 209f0a4d..f5832923 100644 --- a/mp_api/client/routes/materials/electrodes.py +++ b/mp_api/client/routes/materials/electrodes.py @@ -48,39 +48,39 @@ def search( """Query using a variety of search criteria. Arguments: - battery_ids (str, List[str]): A single battery ID string or list of strings + battery_ids (str | list[str]): A single battery ID string or list of strings (e.g., mp-22526_Li, [mp-22526_Li, mp-22526_Ca]). - average_voltage (Tuple[float,float]): Minimum and maximum value of the average voltage for a particular + average_voltage (tuple[float, float]): Minimum and maximum value of the average voltage for a particular voltage step in V. - capacity_grav (Tuple[float,float]): Minimum and maximum value of the gravimetric capacity in maH/g. - capacity_vol (Tuple[float,float]): Minimum and maximum value of the volumetric capacity in maH/cc. - elements (List[str]): A list of elements for the framework material. - energy_grav (Tuple[float,float]): Minimum and maximum value of the gravimetric energy (specific energy) + capacity_grav (tuple[float, float]): Minimum and maximum value of the gravimetric capacity in maH/g. + capacity_vol (tuple[float, float]): Minimum and maximum value of the volumetric capacity in maH/cc. + elements (list[str]): A list of elements for the framework material. + energy_grav (tuple[float, float]): Minimum and maximum value of the gravimetric energy (specific energy) in Wh/kg. - energy_vol (Tuple[float,float]): Minimum and maximum value of the volumetric energy (energy density) + energy_vol (tuple[float, float]): Minimum and maximum value of the volumetric energy (energy density) in Wh/l. - exclude_elements (List[str]): A list of elements to exclude for the framework material. - formula (str, List[str]): Chemical formula or list of chemical formulas of any of the materials + exclude_elements (list[str]): A list of elements to exclude for the framework material. + formula (str | list[str]): Chemical formula or list of chemical formulas of any of the materials associated with the electrode system. This includes materials partially along the charge-discharge path. - fracA_charge (Tuple[float,float]): Minimum and maximum value of the atomic fraction of the working ion + fracA_charge (tuple[float, float]): Minimum and maximum value of the atomic fraction of the working ion in the charged state. - fracA_discharge (Tuple[float,float]): Minimum and maximum value of the atomic fraction of the working ion + fracA_discharge (tuple[float, float]): Minimum and maximum value of the atomic fraction of the working ion in the discharged state. - max_delta_volume (Tuple[float,float]): Minimum and maximum value of the max volume change in percent for a + max_delta_volume (tuple[float, float]): Minimum and maximum value of the max volume change in percent for a particular voltage step. - max_voltage_step (Tuple[float,float]): Minimum and maximum value of the maximum voltage for a particular + max_voltage_step (tuple[float, float]): Minimum and maximum value of the maximum voltage for a particular voltage step in V. - num_elements (Tuple[int,int]): Minimum and maximum number of elements to consider. + num_elements (tuple[int, int]): Minimum and maximum number of elements to consider. num_steps (int): Number of distinct voltage steps from charged to discharged based on stable intermediates. - stability_charge (Tuple[float,float]): Minimum and maximum value of the energy above hull of the charged + stability_charge (tuple[float, float]): Minimum and maximum value of the energy above hull of the charged material. - stability_discharge (Tuple[float,float]): Minimum and maximum value of the energy above hull of the + stability_discharge (tuple[float, float]): Minimum and maximum value of the energy above hull of the discharged material. - working_ion (Element, List[Element]): Element or list of elements of the working ion. + working_ion (Element | list[Element]): Element or list of elements of the working ion. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in InsertionElectrodeDoc or ConversionElectrodeDoc to return data for. + fields (list[str]): List of fields in InsertionElectrodeDoc or ConversionElectrodeDoc to return data for. Default is battery_id and last_updated if all_fields is False. _page (int or None) : Page of the results to skip to. _sort_fields (str or None) : Field to sort on. Including a leading "-" sign will reverse sort order. diff --git a/mp_api/client/routes/materials/electronic_structure.py b/mp_api/client/routes/materials/electronic_structure.py index 1c009408..c32b98db 100644 --- a/mp_api/client/routes/materials/electronic_structure.py +++ b/mp_api/client/routes/materials/electronic_structure.py @@ -60,25 +60,25 @@ def search( """Query electronic structure docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - band_gap (Tuple[float,float]): Minimum and maximum band gap in eV to consider. - chemsys (str, List[str]): A chemical system or list of chemical systems + band_gap (tuple[float, float]): Minimum and maximum band gap in eV to consider. + chemsys (str | list[str]): A chemical system or list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]). - efermi (Tuple[float,float]): Minimum and maximum fermi energy in eV to consider. - elements (List[str]): A list of elements. - exclude_elements (List[str]): A list of elements to exclude. - formula (str, List[str]): A formula including anonymized formula + efermi (tuple[float, float]): Minimum and maximum fermi energy in eV to consider. + elements (list[str]): A list of elements. + exclude_elements (list[str]): A list of elements to exclude. + formula (str | list[str]): A formula including anonymized formula or wild cards (e.g., Fe2O3, ABO3, Si*). A list of chemical formulas can also be passed (e.g., [Fe2O3, ABO3]). is_gap_direct (bool): Whether the material has a direct band gap. is_metal (bool): Whether the material is considered a metal. magnetic_ordering (Ordering): Magnetic ordering of the material. - num_elements (Tuple[int,int]): Minimum and maximum number of elements to consider. + num_elements (tuple[int, int]): Minimum and maximum number of elements to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in ElectronicStructureDoc to return data for. + fields (list[str]): List of fields in ElectronicStructureDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: @@ -200,8 +200,8 @@ def search( """Query band structure summary data in electronic structure docs using a variety of search criteria. Arguments: - band_gap (Tuple[float,float]): Minimum and maximum band gap in eV to consider. - efermi (Tuple[float,float]): Minimum and maximum fermi energy in eV to consider. + band_gap (tuple[float, float]): Minimum and maximum band gap in eV to consider. + efermi (tuple[float, float]): Minimum and maximum fermi energy in eV to consider. is_gap_direct (bool): Whether the material has a direct band gap. is_metal (bool): Whether the material is considered a metal. magnetic_ordering (Ordering or str): Magnetic ordering of the material. @@ -209,7 +209,7 @@ def search( num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in ElectronicStructureDoc to return data for. + fields (list[str]): List of fields in ElectronicStructureDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: @@ -341,7 +341,7 @@ def get_bandstructure_from_material_id( bandstructure, if available. Returns: - bandstructure (Union[BandStructure, BandStructureSymmLine]): BandStructure or BandStructureSymmLine object + bandstructure (BandStructure | BandStructureSymmLine): BandStructure or BandStructureSymmLine object """ pt: BSPathType = ( BSPathType(path_type) if isinstance(path_type, str) else path_type @@ -436,8 +436,8 @@ def search( """Query density of states summary data in electronic structure docs using a variety of search criteria. Arguments: - band_gap (Tuple[float,float]): Minimum and maximum band gap in eV to consider. - efermi (Tuple[float,float]): Minimum and maximum fermi energy in eV to consider. + band_gap (tuple[float, float]): Minimum and maximum band gap in eV to consider. + efermi (tuple[float, float]): Minimum and maximum fermi energy in eV to consider. element (Element or str): Element for element-projected dos data. magnetic_ordering (Ordering or str): Magnetic ordering of the material. orbital (OrbitalType or str): Orbital for orbital-projected dos data. @@ -446,7 +446,7 @@ def search( num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in ElectronicStructureDoc to return data for. + fields (list[str]): List of fields in ElectronicStructureDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/eos.py b/mp_api/client/routes/materials/eos.py index aad22f5f..032c1ffd 100644 --- a/mp_api/client/routes/materials/eos.py +++ b/mp_api/client/routes/materials/eos.py @@ -28,13 +28,13 @@ def search( """Query equations of state docs using a variety of search criteria. Arguments: - task_ids (str, List[str]): Search for equation of states associated with the specified task IDs - energies (Tuple[float,float]): Minimum and maximum energy in eV/atom to consider for EOS plot range. - volumes (Tuple[float,float]): Minimum and maximum volume in A³/atom to consider for EOS plot range. + task_ids (str | list[str]): Search for equation of states associated with the specified task IDs + energies (tuple[float, float]): Minimum and maximum energy in eV/atom to consider for EOS plot range. + volumes (tuple[float, float]): Minimum and maximum volume in A³/atom to consider for EOS plot range. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in EOSDoc to return data for. + fields (list[str]): List of fields in EOSDoc to return data for. Default is material_id only if all_fields is False. **kwargs : used for handling deprecated kwargs diff --git a/mp_api/client/routes/materials/grain_boundaries.py b/mp_api/client/routes/materials/grain_boundaries.py index d9ac75c3..6390abc6 100644 --- a/mp_api/client/routes/materials/grain_boundaries.py +++ b/mp_api/client/routes/materials/grain_boundaries.py @@ -34,24 +34,24 @@ def search( """Query grain boundary docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): Search for grain boundary data associated with the specified Material IDs + material_ids (str | list[str]): Search for grain boundary data associated with the specified Material IDs chemsys (str): Dash-delimited string of elements in the material. - gb_plane(List[str]): The Miller index of grain boundary plane. e.g., [1, 1, 1] - gb_energy (Tuple[float,float]): Minimum and maximum grain boundary energy in J/m³ to consider. - material_ids (List[str]): List of Materials Project IDs to query with. + gb_plane(list[str]): The Miller index of grain boundary plane. e.g., [1, 1, 1] + gb_energy (tuple[float, float]): Minimum and maximum grain boundary energy in J/m³ to consider. + material_ids (list[str]): List of Materials Project IDs to query with. pretty_formula (str): Formula of the material. - rotation_angle (Tuple[float,float]): Minimum and maximum rotation angle in degrees to consider. + rotation_angle (tuple[float, float]): Minimum and maximum rotation angle in degrees to consider. rotation_axis (tuple of 3 int or of 4 int): The Miller index of rotation axis. A 3- or 4-tuple of int or str: e.g., (0, 0, 0, 1), (1, 0, 0), (1, 1, 0), or (1, 1, 1) sigma (int): Sigma value of grain boundary. - separation_energy (Tuple[float,float]): Minimum and maximum work of separation energy in J/m³ to consider. + separation_energy (tuple[float, float]): Minimum and maximum work of separation energy in J/m³ to consider. sigma (int): Sigma value of the boundary. type (GBTypeEnum): Grain boundary type. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in GrainBoundaryDoc to return data for. + fields (list[str]): List of fields in GrainBoundaryDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/magnetism.py b/mp_api/client/routes/materials/magnetism.py index 8321f1e4..be1f8e5b 100644 --- a/mp_api/client/routes/materials/magnetism.py +++ b/mp_api/client/routes/materials/magnetism.py @@ -31,20 +31,20 @@ def search( """Query magnetism docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - num_magnetic_sites (Tuple[int,int]): Minimum and maximum number of magnetic sites to consider. - num_unique_magnetic_sites (Tuple[int,int]): Minimum and maximum number of unique magnetic sites + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). + num_magnetic_sites (tuple[int, int]): Minimum and maximum number of magnetic sites to consider. + num_unique_magnetic_sites (tuple[int, int]): Minimum and maximum number of unique magnetic sites to consider. ordering (Ordering]): The magnetic ordering of the material. - total_magnetization (Tuple[float,float]): Minimum and maximum total magnetization values to consider. - total_magnetization_normalized_vol (Tuple[float,float]): Minimum and maximum total magnetization values + total_magnetization (tuple[float, float]): Minimum and maximum total magnetization values to consider. + total_magnetization_normalized_vol (tuple[float, float]): Minimum and maximum total magnetization values normalized by volume to consider. - total_magnetization_normalized_formula_units (Tuple[float,float]): Minimum and maximum total magnetization + total_magnetization_normalized_formula_units (tuple[float, float]): Minimum and maximum total magnetization values normalized by formula units to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in MagnetismDoc to return data for. + fields (list[str]): List of fields in MagnetismDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/materials.py b/mp_api/client/routes/materials/materials.py index a95a2863..603c4dc7 100644 --- a/mp_api/client/routes/materials/materials.py +++ b/mp_api/client/routes/materials/materials.py @@ -34,7 +34,7 @@ def get_structure_by_material_id( (pre-relaxation) structures. Defaults to True. Returns: - structure (Union[Structure, List[Structure]]): Pymatgen structure object or list of + structure (Structure | list[Structure]): Pymatgen structure object or list of pymatgen structure objects. """ field = "structure" if final else "initial_structures" @@ -75,28 +75,28 @@ def search( """Query core material docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - chemsys (str, List[str]): A chemical system or list of chemical systems + chemsys (str | list[str]): A chemical system or list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]). crystal_system (CrystalSystem): Crystal system of material. - density (Tuple[float,float]): Minimum and maximum density to consider. + density (tuple[float, float]): Minimum and maximum density to consider. deprecated (bool): Whether the material is tagged as deprecated. - elements (List[str]): A list of elements. - exclude_elements (List[str]): A list of elements to exclude. - formula (str, List[str]): A formula including anonymized formula + elements (list[str]): A list of elements. + exclude_elements (list[str]): A list of elements to exclude. + formula (str | list[str]): A formula including anonymized formula or wild cards (e.g., Fe2O3, ABO3, Si*). A list of chemical formulas can also be passed (e.g., [Fe2O3, ABO3]). - num_elements (Tuple[int,int]): Minimum and maximum number of elements to consider. - num_sites (Tuple[int,int]): Minimum and maximum number of sites to consider. + num_elements (tuple[int, int]): Minimum and maximum number of elements to consider. + num_sites (tuple[int, int]): Minimum and maximum number of sites to consider. spacegroup_number (int): Space group number of material. spacegroup_symbol (str): Space group symbol of the material in international short symbol notation. - task_ids (List[str]): List of Materials Project IDs to return data for. - volume (Tuple[float,float]): Minimum and maximum volume to consider. + task_ids (list[str]): List of Materials Project IDs to return data for. + volume (tuple[float, float]): Minimum and maximum volume to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in MaterialsCoreDoc to return data for. + fields (list[str]): List of fields in MaterialsCoreDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. Returns: @@ -273,7 +273,7 @@ def get_blessed_entries( Args: run_type (str or RunType): Calculation run type (e.g. GGA, GGA+U, r2SCAN, PBESol) material_ids (list[str]): List of material ID values - uncorrected_energy (tuple[Optional[float], Optional[float]] | float): Tuple of minimum and maximum uncorrected DFT energy in eV/atom. + uncorrected_energy (tuple[float | None, float | None] | float): Tuple of minimum and maximum uncorrected DFT energy in eV/atom. Note that if a single value is passed, it will be used as the minimum and maximum. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. diff --git a/mp_api/client/routes/materials/oxidation_states.py b/mp_api/client/routes/materials/oxidation_states.py index 7ee45568..a27c133d 100644 --- a/mp_api/client/routes/materials/oxidation_states.py +++ b/mp_api/client/routes/materials/oxidation_states.py @@ -27,18 +27,18 @@ def search( """Query oxidation state docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - chemsys (str, List[str]): A chemical system or list of chemical systems + chemsys (str | list[str]): A chemical system or list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]). - formula (str, List[str]): A formula including anonymized formula + formula (str | list[str]): A formula including anonymized formula or wild cards (e.g., Fe2O3, ABO3, Si*). A list of chemical formulas can also be passed (e.g., [Fe2O3, ABO3]). - possible_species (List[str]): A list of element symbols appended with oxidation states (e.g. [Cr2+, O2-]]). + possible_species (list[str]): A list of element symbols appended with oxidation states (e.g. [Cr2+, O2-]]). num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in OxidationStateDoc to return data for. + fields (list[str]): List of fields in OxidationStateDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/phonon.py b/mp_api/client/routes/materials/phonon.py index 81f39122..13240c8b 100644 --- a/mp_api/client/routes/materials/phonon.py +++ b/mp_api/client/routes/materials/phonon.py @@ -57,15 +57,15 @@ def search( phonon IDs are then used to query the phonon endpoint. Arguments: - identifiers (str, List[str]): A single Phonon Task ID string or list of strings + identifiers (str | list[str]): A single Phonon Task ID string or list of strings (e.g., aaaaaaft, [aaaaaaft, aaaeguxu]). - material_ids (str, List[str]): A single Materials Project ID or list of IDs + material_ids (str | list[str]): A single Materials Project ID or list of IDs (e.g., mp-149, [mp-149, mp-13]). phonon_method (str): phonon method to search (dfpt, phonopy, pheasy) num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in PhononBSDOSTask to return data for. + fields (list[str]): List of fields in PhononBSDOSTask to return data for. Default is identifier, last_updated, and formula_pretty if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/piezo.py b/mp_api/client/routes/materials/piezo.py index c2f8380a..2e7070c7 100644 --- a/mp_api/client/routes/materials/piezo.py +++ b/mp_api/client/routes/materials/piezo.py @@ -25,14 +25,14 @@ def search( """Query piezoelectric data using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - piezoelectric_modulus (Tuple[float,float]): Minimum and maximum of the + piezoelectric_modulus (tuple[float, float]): Minimum and maximum of the piezoelectric modulus in C/m² to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in PiezoDoc to return data for. + fields (list[str]): List of fields in PiezoDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/provenance.py b/mp_api/client/routes/materials/provenance.py index fe80ea28..bd8652e0 100644 --- a/mp_api/client/routes/materials/provenance.py +++ b/mp_api/client/routes/materials/provenance.py @@ -23,13 +23,13 @@ def search( """Query provenance docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). deprecated (bool): Whether the material is tagged as deprecated. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in ProvenanceDoc to return data for. + fields (list[str]): List of fields in ProvenanceDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/robocrys.py b/mp_api/client/routes/materials/robocrys.py index ece5b665..532429b5 100644 --- a/mp_api/client/routes/materials/robocrys.py +++ b/mp_api/client/routes/materials/robocrys.py @@ -20,12 +20,12 @@ def search( """Search text generated from Robocrystallographer. Arguments: - keywords (List[str]): List of search keywords - num_chunks (Optional[int]): Maximum number of chunks of data to yield. None will yield all possible. - chunk_size (Optional[int]): Number of data entries per chunk. + keywords (list[str]): List of search keywords + num_chunks (int | None): Maximum number of chunks of data to yield. None will yield all possible. + chunk_size (int | None): Number of data entries per chunk. Returns: - robocrys_docs (List[RobocrystallogapherDoc]): List of robocrystallographer documents + robocrys_docs (list[RobocrystallogapherDoc]): List of robocrystallographer documents """ keyword_string = ",".join(keywords) @@ -53,12 +53,12 @@ def search_docs( """Query robocrystallographer docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in RobocrystallogapherDoc to return data for. + fields (list[str]): List of fields in RobocrystallogapherDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/similarity.py b/mp_api/client/routes/materials/similarity.py index 0ba8c5b7..f4d11a34 100644 --- a/mp_api/client/routes/materials/similarity.py +++ b/mp_api/client/routes/materials/similarity.py @@ -47,12 +47,12 @@ def search( """Query similarity docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in SimilarityDoc to return data for. + fields (list[str]): List of fields in SimilarityDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/substrates.py b/mp_api/client/routes/materials/substrates.py index 6f1096b1..c47610b3 100644 --- a/mp_api/client/routes/materials/substrates.py +++ b/mp_api/client/routes/materials/substrates.py @@ -30,18 +30,18 @@ def search( """Query substrate docs using a variety of search criteria. Arguments: - area (Tuple[float,float]): Minimum and maximum volume in Ų to consider for the minimum coincident + area (tuple[float, float]): Minimum and maximum volume in Ų to consider for the minimum coincident interface area range. - energy (Tuple[float,float]): Minimum and maximum energy in meV to consider for the elastic energy range. + energy (tuple[float, float]): Minimum and maximum energy in meV to consider for the elastic energy range. film_id (str): Materials Project ID of the film material. - film_orientation (List[int]): Vector indicating the surface orientation of the film material. + film_orientation (list[int]): Vector indicating the surface orientation of the film material. substrate_id (str): Materials Project ID of the substrate material. substrate_formula (str): Reduced formula of the substrate material. - substrate_orientation (List[int]): Vector indicating the surface orientation of the substrate material. + substrate_orientation (list[int]): Vector indicating the surface orientation of the substrate material. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in SubstratesDoc to return data for. + fields (list[str]): List of fields in SubstratesDoc to return data for. Default is the film_id and substrate_id only if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/summary.py b/mp_api/client/routes/materials/summary.py index 7d6d7205..02774097 100644 --- a/mp_api/client/routes/materials/summary.py +++ b/mp_api/client/routes/materials/summary.py @@ -81,77 +81,77 @@ def search( # noqa: D417 """Query core data using a variety of search criteria. Arguments: - band_gap (Tuple[float,float]): Minimum and maximum band gap in eV to consider. - chemsys (str, List[str]): A chemical system or list of chemical systems + band_gap (tuple[float, float]): Minimum and maximum band gap in eV to consider. + chemsys (str | list[str]): A chemical system or list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]). crystal_system (CrystalSystem or list[CrystalSystem]): Crystal system(s) of the materials. - density (Tuple[float,float]): Minimum and maximum density to consider. + density (tuple[float, float]): Minimum and maximum density to consider. deprecated (bool): Whether the material is tagged as deprecated. - e_electronic (Tuple[float,float]): Minimum and maximum electronic dielectric constant to consider. - e_ionic (Tuple[float,float]): Minimum and maximum ionic dielectric constant to consider. - e_total (Tuple[float,float]): Minimum and maximum total dielectric constant to consider. - efermi (Tuple[float,float]): Minimum and maximum fermi energy in eV to consider. - elastic_anisotropy (Tuple[float,float]): Minimum and maximum value to consider for the elastic anisotropy. - elements (List[str]): A list of elements. - energy_above_hull (Tuple[int,int]): Minimum and maximum energy above the hull in eV/atom to consider. - equilibrium_reaction_energy (Tuple[float,float]): Minimum and maximum equilibrium reaction energy in + e_electronic (tuple[float, float]): Minimum and maximum electronic dielectric constant to consider. + e_ionic (tuple[float, float]): Minimum and maximum ionic dielectric constant to consider. + e_total (tuple[float, float]): Minimum and maximum total dielectric constant to consider. + efermi (tuple[float, float]): Minimum and maximum fermi energy in eV to consider. + elastic_anisotropy (tuple[float, float]): Minimum and maximum value to consider for the elastic anisotropy. + elements (list[str]): A list of elements. + energy_above_hull (tuple[int, int]): Minimum and maximum energy above the hull in eV/atom to consider. + equilibrium_reaction_energy (tuple[float, float]): Minimum and maximum equilibrium reaction energy in eV/atom to consider. exclude_elements (List(str)): List of elements to exclude. - formation_energy (Tuple[int,int]): Minimum and maximum formation energy in eV/atom to consider. - formula (str, List[str]): A formula including anonymized formula + formation_energy (tuple[int, int]): Minimum and maximum formation energy in eV/atom to consider. + formula (str | list[str]): A formula including anonymized formula or wild cards (e.g., Fe2O3, ABO3, Si*). A list of chemical formulas can also be passed (e.g., [Fe2O3, ABO3]). - g_reuss (Tuple[float,float]): Minimum and maximum value in GPa to consider for the Reuss average + g_reuss (tuple[float, float]): Minimum and maximum value in GPa to consider for the Reuss average of the shear modulus. - g_voigt (Tuple[float,float]): Minimum and maximum value in GPa to consider for the Voigt average + g_voigt (tuple[float, float]): Minimum and maximum value in GPa to consider for the Voigt average of the shear modulus. - g_vrh (Tuple[float,float]): Minimum and maximum value in GPa to consider for the Voigt-Reuss-Hill + g_vrh (tuple[float, float]): Minimum and maximum value in GPa to consider for the Voigt-Reuss-Hill average of the shear modulus. - has_props: (List[HasProps], List[str]): The calculated properties available for the material. + has_props (list[HasProps] | list[str]): The calculated properties available for the material. has_reconstructed (bool): Whether the entry has any reconstructed surfaces. is_gap_direct (bool): Whether the material has a direct band gap. is_metal (bool): Whether the material is considered a metal. is_stable (bool): Whether the material lies on the convex energy hull. - k_reuss (Tuple[float,float]): Minimum and maximum value in GPa to consider for the Reuss average + k_reuss (tuple[float, float]): Minimum and maximum value in GPa to consider for the Reuss average of the bulk modulus. - k_voigt (Tuple[float,float]): Minimum and maximum value in GPa to consider for the Voigt average + k_voigt (tuple[float, float]): Minimum and maximum value in GPa to consider for the Voigt average of the bulk modulus. - k_vrh (Tuple[float,float]): Minimum and maximum value in GPa to consider for the Voigt-Reuss-Hill + k_vrh (tuple[float, float]): Minimum and maximum value in GPa to consider for the Voigt-Reuss-Hill average of the bulk modulus. magnetic_ordering (Ordering): Magnetic ordering of the material. - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - n (Tuple[float,float]): Minimum and maximum refractive index to consider. - nelements (Tuple[int,int]): Minimum and maximum number of elements to consider. - num_elements (Tuple[int,int]): Alias for `nelements`, deprecated. Minimum and maximum number of elements to consider. - num_sites (Tuple[int,int]): Minimum and maximum number of sites to consider. - num_magnetic_sites (Tuple[int,int]): Minimum and maximum number of magnetic sites to consider. - num_unique_magnetic_sites (Tuple[int,int]): Minimum and maximum number of unique magnetic sites to consider. - piezoelectric_modulus (Tuple[float,float]): Minimum and maximum piezoelectric modulus to consider. - poisson_ratio (Tuple[float,float]): Minimum and maximum value to consider for Poisson's ratio. + n (tuple[float, float]): Minimum and maximum refractive index to consider. + nelements (tuple[int, int]): Minimum and maximum number of elements to consider. + num_elements (tuple[int, int]): Alias for `nelements`, deprecated. Minimum and maximum number of elements to consider. + num_sites (tuple[int, int]): Minimum and maximum number of sites to consider. + num_magnetic_sites (tuple[int, int]): Minimum and maximum number of magnetic sites to consider. + num_unique_magnetic_sites (tuple[int, int]): Minimum and maximum number of unique magnetic sites to consider. + piezoelectric_modulus (tuple[float, float]): Minimum and maximum piezoelectric modulus to consider. + poisson_ratio (tuple[float, float]): Minimum and maximum value to consider for Poisson's ratio. possible_species (List(str)): List of element symbols appended with oxidation states. (e.g. Cr2+,O2-) - shape_factor (Tuple[float,float]): Minimum and maximum shape factor values to consider. + shape_factor (tuple[float, float]): Minimum and maximum shape factor values to consider. spacegroup_number (int or list[int]): Space group number(s) of materials. spacegroup_symbol (str or list[str]): Space group symbol(s) of the materials in international short symbol notation. - surface_energy_anisotropy (Tuple[float,float]): Minimum and maximum surface energy anisotropy values + surface_energy_anisotropy (tuple[float, float]): Minimum and maximum surface energy anisotropy values to consider. theoretical: (bool): Whether the material is theoretical. - total_energy (Tuple[int,int]): Minimum and maximum corrected total energy in eV/atom to consider. - total_magnetization (Tuple[float,float]): Minimum and maximum total magnetization values to consider. - total_magnetization_normalized_formula_units (Tuple[float,float]): Minimum and maximum total magnetization + total_energy (tuple[int, int]): Minimum and maximum corrected total energy in eV/atom to consider. + total_magnetization (tuple[float, float]): Minimum and maximum total magnetization values to consider. + total_magnetization_normalized_formula_units (tuple[float, float]): Minimum and maximum total magnetization values normalized by formula units to consider. - total_magnetization_normalized_vol (Tuple[float,float]): Minimum and maximum total magnetization values + total_magnetization_normalized_vol (tuple[float, float]): Minimum and maximum total magnetization values normalized by volume to consider. - uncorrected_energy (Tuple[int,int]): Minimum and maximum uncorrected total energy in eV/atom to consider. - volume (Tuple[float,float]): Minimum and maximum volume to consider. - weighted_surface_energy (Tuple[float,float]): Minimum and maximum weighted surface energy + uncorrected_energy (tuple[int, int]): Minimum and maximum uncorrected total energy in eV/atom to consider. + volume (tuple[float, float]): Minimum and maximum volume to consider. + weighted_surface_energy (tuple[float, float]): Minimum and maximum weighted surface energy in J/m² to consider. - weighted_work_function (Tuple[float,float]): Minimum and maximum weighted work function in eV to consider. + weighted_work_function (tuple[float, float]): Minimum and maximum weighted work function in eV to consider. include_gnome (bool): whether to include materials from GNoMe dataset num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in SummaryDoc to return data for. + fields (list[str]): List of fields in SummaryDoc to return data for. Default is material_id if all_fields is False. _page (int or None) : Page of the results to skip to. _sort_fields (str or None) : Field to sort on. Including a leading "-" sign will reverse sort order. diff --git a/mp_api/client/routes/materials/surface_properties.py b/mp_api/client/routes/materials/surface_properties.py index 3a36d5f9..b8742084 100644 --- a/mp_api/client/routes/materials/surface_properties.py +++ b/mp_api/client/routes/materials/surface_properties.py @@ -30,19 +30,19 @@ def search( """Query surface properties docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). has_reconstructed (bool): Whether the entry has any reconstructed surfaces. - shape_factor (Tuple[float,float]): Minimum and maximum shape factor values to consider. - surface_energy_anisotropy (Tuple[float,float]): Minimum and maximum surface energy anisotropy values to + shape_factor (tuple[float, float]): Minimum and maximum shape factor values to consider. + surface_energy_anisotropy (tuple[float, float]): Minimum and maximum surface energy anisotropy values to consider. - weighted_surface_energy (Tuple[float,float]): Minimum and maximum weighted surface energy in J/m² to + weighted_surface_energy (tuple[float, float]): Minimum and maximum weighted surface energy in J/m² to consider. - weighted_work_function (Tuple[float,float]): Minimum and maximum weighted work function in eV to consider. + weighted_work_function (tuple[float, float]): Minimum and maximum weighted work function in eV to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in SurfacePropDoc to return data for. + fields (list[str]): List of fields in SurfacePropDoc to return data for. Default is material_id only if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/synthesis.py b/mp_api/client/routes/materials/synthesis.py index 4567c51f..bb0f5750 100644 --- a/mp_api/client/routes/materials/synthesis.py +++ b/mp_api/client/routes/materials/synthesis.py @@ -34,20 +34,20 @@ def search( """Search synthesis recipe text. Arguments: - keywords (Optional[List[str]]): List of string keywords to search synthesis paragraph text with - synthesis_type (Optional[List[SynthesisTypeEnum]]): Type of synthesis to include - target_formula (Optional[str]): Chemical formula of the target material - precursor_formula (Optional[str]): Chemical formula of the precursor material - operations (Optional[List[OperationTypeEnum]]): List of operations that syntheses must have - condition_heating_temperature_min (Optional[float]): Minimal heating temperature - condition_heating_temperature_max (Optional[float]): Maximal heating temperature - condition_heating_time_min (Optional[float]): Minimal heating time - condition_heating_time_max (Optional[float]): Maximal heating time - condition_heating_atmosphere (Optional[List[str]]): Required heating atmosphere, such as "air", "argon" - condition_mixing_device (Optional[List[str]]): Required mixing device, such as "zirconia", "Al2O3". - condition_mixing_media (Optional[List[str]]): Required mixing media, such as "alcohol", "water" - num_chunks (Optional[int]): Maximum number of chunks of data to yield. None will yield all possible. - chunk_size (Optional[int]): Number of data entries per chunk. + keywords (list[str] | None): List of string keywords to search synthesis paragraph text with + synthesis_type (list[SynthesisTypeEnum] | None): Type of synthesis to include + target_formula (str | None): Chemical formula of the target material + precursor_formula (str | None): Chemical formula of the precursor material + operations (list[OperationTypeEnum] | None): List of operations that syntheses must have + condition_heating_temperature_min (float | None): Minimal heating temperature + condition_heating_temperature_max (float | None): Maximal heating temperature + condition_heating_time_min (float | None): Minimal heating time + condition_heating_time_max (float | None): Maximal heating time + condition_heating_atmosphere (list[str] | None): Required heating atmosphere, such as "air", "argon" + condition_mixing_device (list[str] | None): Required mixing device, such as "zirconia", "Al2O3". + condition_mixing_media (list[str] | None): Required mixing media, such as "alcohol", "water" + num_chunks (int | None): Maximum number of chunks of data to yield. None will yield all possible. + chunk_size (int | None): Number of data entries per chunk. Returns: diff --git a/mp_api/client/routes/materials/tasks.py b/mp_api/client/routes/materials/tasks.py index 8343b767..dab724e4 100644 --- a/mp_api/client/routes/materials/tasks.py +++ b/mp_api/client/routes/materials/tasks.py @@ -78,17 +78,17 @@ def search( """Query core task docs using a variety of search criteria. Arguments: - task_ids (str, List[str]): List of Materials Project IDs to return data for. - elements (List[str]): A list of elements. - exclude_elements (List[str]): A list of elements to exclude. - formula (str, List[str]): A formula including anonymized formula + task_ids (str | list[str]): List of Materials Project IDs to return data for. + elements (list[str]): A list of elements. + exclude_elements (list[str]): A list of elements to exclude. + formula (str | list[str]): A formula including anonymized formula or wild cards (e.g., Fe2O3, ABO3, Si*). A list of chemical formulas can also be passed (e.g., [Fe2O3, ABO3]). last_updated (tuple[datetime, datetime]): A tuple of min and max UTC formatted datetimes. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. Max size is 100. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in CoreTaskDoc to return data for. + fields (list[str]): List of fields in CoreTaskDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/thermo.py b/mp_api/client/routes/materials/thermo.py index ec7c9069..3d4985e5 100644 --- a/mp_api/client/routes/materials/thermo.py +++ b/mp_api/client/routes/materials/thermo.py @@ -77,30 +77,30 @@ def search( """Query core thermo docs using a variety of search criteria. Arguments: - material_ids (str, List[str]): A single Material ID string or list of strings + material_ids (str | list[str]): A single Material ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). - chemsys (str, List[str]): A chemical system or list of chemical systems + chemsys (str | list[str]): A chemical system or list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]). - energy_above_hull (Tuple[float,float]): Minimum and maximum energy above the hull in eV/atom to consider. - equilibrium_reaction_energy (Tuple[float,float]): Minimum and maximum equilibrium reaction energy + energy_above_hull (tuple[float, float]): Minimum and maximum energy above the hull in eV/atom to consider. + equilibrium_reaction_energy (tuple[float, float]): Minimum and maximum equilibrium reaction energy in eV/atom to consider. - formation_energy (Tuple[float,float]): Minimum and maximum formation energy in eV/atom to consider. - formula (str, List[str]): A formula including anonymized formula + formation_energy (tuple[float, float]): Minimum and maximum formation energy in eV/atom to consider. + formula (str | list[str]): A formula including anonymized formula or wild cards (e.g., Fe2O3, ABO3, Si*). A list of chemical formulas can also be passed (e.g., [Fe2O3, ABO3]). is_stable (bool): Whether the material is stable. - material_ids (List[str]): List of Materials Project IDs to return data for. - thermo_ids (List[str]): List of thermo IDs to return data for. This is a combination of the Materials + material_ids (list[str]): List of Materials Project IDs to return data for. + thermo_ids (list[str]): List of thermo IDs to return data for. This is a combination of the Materials Project ID and thermo type (e.g. mp-149_GGA_GGA+U). - thermo_types (List[ThermoType or str]): List of thermo/run types to return data for (e.g. ThermoType.GGA_GGA_U). - num_elements (Tuple[int,int]): Minimum and maximum number of elements in the material to consider. - total_energy (Tuple[float,float]): Minimum and maximum corrected total energy in eV/atom to consider. - uncorrected_energy (Tuple[float,float]): Minimum and maximum uncorrected total + thermo_types (list[ThermoType | str]): List of thermo/run types to return data for (e.g. ThermoType.GGA_GGA_U). + num_elements (tuple[int, int]): Minimum and maximum number of elements in the material to consider. + total_energy (tuple[float, float]): Minimum and maximum corrected total energy in eV/atom to consider. + uncorrected_energy (tuple[float, float]): Minimum and maximum uncorrected total energy in eV/atom to consider. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in ThermoDoc to return data for. + fields (list[str]): List of fields in ThermoDoc to return data for. Default is material_id and last_updated if all_fields is False. Returns: diff --git a/mp_api/client/routes/materials/xas.py b/mp_api/client/routes/materials/xas.py index 01dfed41..646e7c4b 100644 --- a/mp_api/client/routes/materials/xas.py +++ b/mp_api/client/routes/materials/xas.py @@ -46,18 +46,18 @@ def search( absorbing_element (Element): The absorbing element. formula (str): A formula including anonymized formula or wild cards (e.g., Fe2O3, ABO3, Si*). - chemsys (str, List[str]): A chemical system or list of chemical systems + chemsys (str | list[str]): A chemical system or list of chemical systems (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]). - elements (List[str]): A list of elements. - task_ids (str, List[str]): A single Task ID string or list of strings + elements (list[str]): A list of elements. + task_ids (str | list[str]): A single Task ID string or list of strings (e.g., mp-149, [mp-149, mp-13]). spectrum_type (XasType): Spectrum type (e.g. EXAFS, XAFS, or XANES). - spectrum_ids (str, List[str]): A single Spectrum ID string or list of strings + spectrum_ids (str | list[str]): A single Spectrum ID string or list of strings (e.g., mp-149-XANES-Li-K, [mp-149-XANES-Li-K, mp-13-XANES-Li-K]). num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in MaterialsCoreDoc to return data for. + fields (list[str]): List of fields in MaterialsCoreDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. _page (int or None) : Page of the results to skip to. _sort_fields (str or None) : Field to sort on. Including a leading "-" sign will reverse sort order. diff --git a/mp_api/client/routes/molecules/jcesr.py b/mp_api/client/routes/molecules/jcesr.py index 24d3f5e6..fb963e32 100644 --- a/mp_api/client/routes/molecules/jcesr.py +++ b/mp_api/client/routes/molecules/jcesr.py @@ -49,20 +49,19 @@ def search( JCESR = Joint Center for Energy Storage Research Arguments: - task_ids (str, List[str]): A single molecule task ID string or list of strings. + task_ids (str | list[str]): A single molecule task ID string or list of strings. (e.g., mol-45004, [mol-45004, mol-45228]). - charge (Tuple[float,float]): Minimum and maximum value of the charge in +e to consider. - elements (List[Element]): A list of elements. - film_orientation (List[Elements]): List of elements that are in the molecule. - EA (Tuple[float,float]): Minimum and maximum value of the electron affinity in eV to consider. - IE (Tuple[float,float]): Minimum and maximum value of the ionization energy in eV to consider. - nelements (Tuple[float,float]): Minimum and maximum number of elements in the molecule to consider. + charge (tuple[float, float]): Minimum and maximum value of the charge in +e to consider. + elements (list[Element]): A list of elements. + EA (tuple[float, float]): Minimum and maximum value of the electron affinity in eV to consider. + IE (tuple[float, float]): Minimum and maximum value of the ionization energy in eV to consider. + nelements (tuple[float, float]): Minimum and maximum number of elements in the molecule to consider. pointgroup (str): Point group of the molecule in Schoenflies notation. smiles (str): The simplified molecular input line-entry system (SMILES) representation of the molecule. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in MoleculesDoc to return data for. + fields (list[str]): List of fields in MoleculesDoc to return data for. Default is the material_id only if all_fields is False. _page (int or None) : Page of the results to skip to. _sort_fields (str or None) : Field to sort on. Including a leading "-" sign will reverse sort order. diff --git a/mp_api/client/routes/molecules/summary.py b/mp_api/client/routes/molecules/summary.py index 2f91677e..58f36933 100644 --- a/mp_api/client/routes/molecules/summary.py +++ b/mp_api/client/routes/molecules/summary.py @@ -37,21 +37,21 @@ def search( Arguments: charge (int): Minimum and maximum charge for the molecule. spin_multiplicity (int): Minimum and maximum spin for the molecule. - nelements (Tuple[int, int]): Minimum and maximum number of elements - chemsys (str, List[str]): A chemical system, list of chemical systems + nelements (tuple[int, int]): Minimum and maximum number of elements + chemsys (str | list[str]): A chemical system, list of chemical systems (e.g., Li-C-O, [C-O-H-N, Li-N]). #deprecated (bool): Whether the material is tagged as deprecated. - elements (List[str]): A list of elements. + elements (list[str]): A list of elements. exclude_elements (List(str)): List of elements to exclude. - formula (str, List[str]): An alphabetical formula or list of formulas + formula (str | list[str]): An alphabetical formula or list of formulas (e.g. "C2 Li2 O4", ["C2 H4", "C2 H6"]). - has_props: (List[HasProps]): The calculated properties available for the material. + has_props (list[HasProps]): The calculated properties available for the material. molecule_ids (str or MPculeID, or list[str | MPculeID]): (List of) Materials Project Molecule IDs (MPculeIDs) to return data for. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. - fields (List[str]): List of fields in SearchDoc to return data for. + fields (list[str]): List of fields in SearchDoc to return data for. Default is material_id if all_fields is False. _page (int or None) : Page of the results to skip to. _sort_fields (str or None) : Field to sort on. Including a leading "-" sign will reverse sort order. diff --git a/pyproject.toml b/pyproject.toml index 5bbf0739..f6188bd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,10 @@ authors = [ classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", "Intended Audience :: Information Technology", @@ -37,7 +41,6 @@ classifiers = [ dependencies = [ "pymatgen>2024.2.20", - "typing-extensions>=3.7.4.1", "requests>=2.23.0", "monty>=2024.12.10", "emmet-core>=0.87.3rc2,<0.87.4", @@ -76,11 +79,7 @@ test = [ "pytest-cov", "pytest-mock", "pytest-xdist", - "flake8", - "pycodestyle", "mypy", - "mypy-extensions", - "typing-extensions", "types-setuptools", "types-requests", "types-tqdm" From 5d252ef3bf3612667a92c52c37683338997ab54e Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:17:33 -0700 Subject: [PATCH 10/30] tqdm -> rich --- mp_api/client/__init__.py | 11 +- mp_api/client/contribs/_logger.py | 79 +--- mp_api/client/contribs/_types.py | 5 +- mp_api/client/contribs/client.py | 308 ++++++++------ mp_api/client/contribs/utils.py | 28 +- mp_api/client/core/_display.py | 464 +++++++++++++++++++++ mp_api/client/core/client.py | 332 ++++++++------- mp_api/client/core/delta.py | 13 +- mp_api/client/core/exceptions.py | 8 + mp_api/client/core/schemas.py | 45 +- mp_api/client/core/settings.py | 10 + mp_api/client/mprester.py | 22 +- mp_api/client/routes/materials/summary.py | 37 +- mp_api/mcp/utils.py | 2 + pyproject.toml | 4 +- tests/client/contribs/test_side_effects.py | 124 ++++++ tests/client/core/test_client_progress.py | 179 ++++++++ tests/client/core/test_delta.py | 10 + tests/client/core/test_display.py | 351 ++++++++++++++++ tests/client/core/test_schemas.py | 30 ++ tests/client/materials/test_summary.py | 26 ++ 21 files changed, 1651 insertions(+), 437 deletions(-) create mode 100644 mp_api/client/core/_display.py create mode 100644 tests/client/contribs/test_side_effects.py create mode 100644 tests/client/core/test_client_progress.py create mode 100644 tests/client/core/test_display.py diff --git a/mp_api/client/__init__.py b/mp_api/client/__init__.py index 75307bf6..6fde0e19 100644 --- a/mp_api/client/__init__.py +++ b/mp_api/client/__init__.py @@ -2,19 +2,22 @@ from __future__ import annotations -import logging import os from importlib.metadata import PackageNotFoundError, version +from mp_api.client.core._display import ( + _install_default_logging, + disable_logging, + enable_logging, +) from mp_api.client.core.exceptions import MPRestError from mp_api.client.mprester import MPRester -__all__ = ["MPRestError", "MPRester"] +__all__ = ["MPRestError", "MPRester", "disable_logging", "enable_logging"] try: __version__ = version("mp_api") except PackageNotFoundError: # pragma: no cover __version__ = os.getenv("SETUPTOOLS_SCM_PRETEND_VERSION", "") -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) +_install_default_logging() diff --git a/mp_api/client/contribs/_logger.py b/mp_api/client/contribs/_logger.py index 6cf15884..f69796d9 100644 --- a/mp_api/client/contribs/_logger.py +++ b/mp_api/client/contribs/_logger.py @@ -1,57 +1,31 @@ +"""Logger for the MPContribs client. + +Records go through the `mp_api.client` logger's default handler (stderr), +see `mp_api.client.core._display`. No handlers are attached here. +""" + from __future__ import annotations import logging import os -import sys -from io import StringIO from mp_api.client.contribs.settings import MPCC_SETTINGS -class LogFilter(logging.Filter): - def __init__(self, level: int, *args, **kwargs) -> None: - """Start an instance of logging.Filter. - - Args: - level (int) : logging level - *args : args to pass to logging.Filter - **kwargs : kwargs to pass to logging.Filter - """ - self.level = level - super().__init__(*args, **kwargs) - - def filter(self, record: logging.LogRecord) -> bool: - """Filter whether a message is logged. - - Args: - record (logging.LogRecord) : message to test if it - meets threshold for logging. - - Returns: - bool : True if message should be suppressed. - """ - return record.levelno < self.level - - class CustomLoggerAdapter(logging.LoggerAdapter): - """Adapter for logger to enable prefixing.""" + """Prefix messages with the supervisor group/process, if running under supervisor.""" def process(self, msg, kwargs): - """Prefix logging message. - - Args: - msg (str) : logging message - kwargs : args to pass to logging.LoggerAdapter - """ - prefix = self.extra.get("prefix") + prefix = self.extra.get("prefix") # type: ignore[union-attr] return f"[{prefix}] {msg}" if prefix else msg, kwargs def get_logger(name: str = "mp_api.client.contribs") -> CustomLoggerAdapter: - """Get the default MPContribs logger. + """Get the contribs logger. Args: - name (str) : name of the logger + name (str) : logger name, a child of `mp_api.client` + Returns: CustomLoggerAdapter """ @@ -59,39 +33,8 @@ def get_logger(name: str = "mp_api.client.contribs") -> CustomLoggerAdapter: process = os.environ.get("SUPERVISOR_PROCESS_NAME") group = os.environ.get("SUPERVISOR_GROUP_NAME") cfg = {"prefix": f"{group}/{process}"} if process and group else {} - info_handler = logging.StreamHandler(sys.stdout) - error_handler = logging.StreamHandler(sys.stderr) - info_handler.addFilter(LogFilter(logging.WARNING)) - error_handler.setLevel(max(logging.DEBUG, logging.WARNING)) - logger.handlers = [info_handler, error_handler] logger.setLevel(MPCC_SETTINGS.CLIENT_LOG_LEVEL) return CustomLoggerAdapter(logger, cfg) MPCC_LOGGER = get_logger() - - -class TqdmToLogger(StringIO): - logger: logging.LoggerAdapter = MPCC_LOGGER - level: int | str = MPCC_SETTINGS.CLIENT_LOG_LEVEL - buf: str = "" - - def __init__( - self, logger: logging.LoggerAdapter = MPCC_LOGGER, level: int | None = None - ) -> None: - """Start an instance of a TQDM logger. - - Args: - logger (logging.Logger) : Logger to pass through - level (int) : logging level - """ - super().__init__() - self.logger = logger - self.level = level or logging.INFO - - def write(self, buf: str) -> int: - self.buf = buf.strip("\r\n\t ") - return 1 - - def flush(self) -> None: - self.logger.log(self.level, self.buf) # type: ignore[arg-type] diff --git a/mp_api/client/contribs/_types.py b/mp_api/client/contribs/_types.py index aa81a91d..458ca689 100644 --- a/mp_api/client/contribs/_types.py +++ b/mp_api/client/contribs/_types.py @@ -69,7 +69,10 @@ def display(self): try: allowed_kwargs = getfullargspec(line_chart).args attrs = {k: v for k, v in self.attrs.items() if k in allowed_kwargs} - return self.plot(**attrs) + # Plot with plotly directly, rather than changing the + # process-wide pandas plotting backend / plotly template. + attrs.setdefault("template", "simple_white") + return line_chart(self, **attrs) except Exception as e: MPCC_LOGGER.error(f"Can't display table: {e}") diff --git a/mp_api/client/contribs/client.py b/mp_api/client/contribs/client.py index 438184bc..88284f8f 100644 --- a/mp_api/client/contribs/client.py +++ b/mp_api/client/contribs/client.py @@ -21,7 +21,6 @@ import orjson import pandas as pd -import plotly.io as pio import requests from bravado.client import SwaggerClient from bravado.config import bravado_config_from_config_dict @@ -43,11 +42,9 @@ from pymatgen.core import Structure as PmgStructure from requests.exceptions import RequestException from requests_futures.sessions import FuturesSession -from swagger_spec_validator.common import SwaggerValidationError -from tqdm.auto import tqdm from urllib3.util.retry import Retry -from mp_api.client.contribs._logger import MPCC_LOGGER, TqdmToLogger +from mp_api.client.contribs._logger import MPCC_LOGGER from mp_api.client.contribs._types import ( Attachment, ComponentIdSets, @@ -64,9 +61,16 @@ QueryResult, ) from mp_api.client.contribs.settings import MPCC_SETTINGS -from mp_api.client.contribs.utils import flatten_dict, get_md5, unflatten_dict +from mp_api.client.contribs.utils import ( + MPContribsValidationError, + flatten_dict, + get_md5, + unflatten_dict, +) +from mp_api.client.core._display import progress_bar from mp_api.client.core.exceptions import MPContribsClientError from mp_api.client.core.schemas import _convert_to_model +from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS if TYPE_CHECKING: from collections.abc import Iterable, Sequence @@ -82,9 +86,6 @@ VALID_OPS = {"query", "create", "update", "delete", "download"} VALID_OPS_T = Literal[*VALID_OPS] # type: ignore[valid-type] -pd.options.plotting.backend = "plotly" -pio.templates.default = "simple_white" -warnings.formatwarning = lambda msg, *args, **kwargs: f"{msg}\n" warnings.filterwarnings("default", category=DeprecationWarning, module=__name__) @@ -96,20 +97,20 @@ def validate_email(email_string: str) -> None: Returns: None Raises: - SwaggerValidationError on malformed email address. + MPContribsValidationError (a SwaggerValidationError) on malformed email address. """ if email_string.count(":") != 1: - raise SwaggerValidationError( + raise MPContribsValidationError( f"{email_string} not of format :." ) provider, email = email_string.split(":", 1) if provider not in MPCC_SETTINGS.PROVIDERS: - raise SwaggerValidationError(f"{provider} is not a valid provider.") + raise MPContribsValidationError(f"{provider} is not a valid provider.") d = is_email(email, diagnose=True) if d > BaseDiagnosis.CATEGORIES["VALID"]: - raise SwaggerValidationError(f"{email} {d.message}") + raise MPContribsValidationError(f"{email} {d.message}") return None @@ -138,12 +139,12 @@ def validate_url( None Raises: - SwaggerValidationError if any `qualifying` fields are missing + MPContribsValidationError (a SwaggerValidationError) if any `qualifying` fields are missing """ tokens = urlsplit(url_string) if not all(getattr(tokens, qual_attr) for qual_attr in qualifying): - raise SwaggerValidationError(f"{url_string} invalid") + raise MPContribsValidationError(f"{url_string} invalid") url_format = SwaggerFormat( @@ -229,13 +230,12 @@ def _run_futures( total = total if total_set else len(futures) responses: dict[str, dict[str, Any]] = {} - with tqdm( # type: ignore[call-arg,attr-defined] + with progress_bar( + desc or "Requests", total=total, - desc=desc, - file=TqdmToLogger(), - miniters=1, + enabled=not disable, delay=5, - disable=disable, + unit="it", ) as pbar: for future in as_completed(futures): if not future.cancelled(): @@ -454,6 +454,7 @@ def __init__( project: str | None = None, session: requests.Session | None = None, use_document_model: bool = False, + mute_progress_bars: bool = MAPI_CLIENT_SETTINGS.MUTE_PROGRESS_BARS, **kwargs, ) -> None: """Initialize the client - only reloads API spec from server as needed. @@ -465,6 +466,7 @@ def __init__( project (str): use this project for all operations (query, update, create, delete) session (requests.Session): override session for client to use use_document_model (bool) : whether to use pydantic document models by default to validate data + mute_progress_bars (bool) : whether to hide progress bars kwargs : To handle deprecated class attributes """ # NOTE bravado future doesn't work with concurrent.futures @@ -519,6 +521,7 @@ def __init__( self.session = get_session(session=session) self.use_document_model = use_document_model + self.mute_progress_bars = mute_progress_bars super().__init__(self.cached_swagger_spec) @@ -807,7 +810,12 @@ def search_future(search_term): futures = [ self._get_future(i, q, rel_url="projects") for i, q in enumerate(queries) ] - responses = _run_futures(futures, total=total_count, timeout=timeout) + responses = _run_futures( + futures, + total=total_count, + timeout=timeout, + disable=self.mute_progress_bars, + ) # NOTE: resp["result"]["data"] is a dict where each key is an int, # and each value is a **list** of projects as dict @@ -1273,7 +1281,9 @@ def delete_contributions( _, total_pages = self.get_totals(query=query) queries = self._split_query(query, op="delete", pages=total_pages) futures = [self._get_future(i, q, op="delete") for i, q in enumerate(queries)] - _run_futures(futures, total=total, timeout=timeout) + _run_futures( + futures, total=total, timeout=timeout, disable=self.mute_progress_bars + ) left, _ = self.get_totals(query=query) deleted = total - left self.init_columns(name=name) @@ -1320,7 +1330,9 @@ def get_totals( futures = [ self._get_future(i, q, rel_url=resource) for i, q in enumerate(queries) ] - responses = _run_futures(futures, timeout=timeout, desc="Totals") + responses = _run_futures( + futures, timeout=timeout, desc="Totals", disable=self.mute_progress_bars + ) result = { k: sum(resp.get("result", {}).get(k, 0) for resp in responses.values()) @@ -1394,7 +1406,12 @@ def _get_contrib_identifier_payloads( _, total_pages = self.get_totals(query=query, timeout=timeout) queries = self._split_query(query, op=op, pages=total_pages) futures = [self._get_future(i, q) for i, q in enumerate(queries)] - responses = _run_futures(futures, timeout=timeout, desc="Identifiers") + responses = _run_futures( + futures, + timeout=timeout, + desc="Identifiers", + disable=self.mute_progress_bars, + ) contributions: list[dict[str, Any]] = [] for resp in responses.values(): @@ -1689,7 +1706,12 @@ def query_contributions( _, total_pages = self.get_totals(query=cids_query) queries = self._split_query(cids_query, pages=total_pages) futures = [self._get_future(i, q) for i, q in enumerate(queries)] - responses = _run_futures(futures, total=total, timeout=timeout) + responses = _run_futures( + futures, + total=total, + timeout=timeout, + disable=self.mute_progress_bars, + ) ret: dict[str, int | list[str]] = {"total_count": 0, "data": []} for resp in responses.values(): @@ -1768,7 +1790,9 @@ def update_contributions( self._get_future(i, q, op="update", data=data) for i, q in enumerate(queries) ] - responses = _run_futures(futures, total=total, timeout=timeout) + responses = _run_futures( + futures, total=total, timeout=timeout, disable=self.mute_progress_bars + ) updated = sum(resp["count"] for _, resp in responses.items()) if updated: @@ -1990,130 +2014,139 @@ def submit_contributions( ] fields.remove("needs_build") # internal field - for contrib in tqdm(contributions, desc="Prepare"): # type: ignore[call-arg,attr-defined] - if "data" in contrib: - contrib["data"] = unflatten_dict(contrib["data"]) - self._is_serializable_dict(contrib["data"]) - - update = "id" in contrib - project_name = id2project[contrib["id"]] if update else contrib["project"] - if ( - not update - and unique_identifiers.get(project_name) - and contrib["identifier"] - in existing.get(project_name, {}).get("identifiers", {}) - ): - continue + with progress_bar( + "Prepare", + total=len(contributions), + enabled=not self.mute_progress_bars, + unit="contribs", + ) as prepare_bar: + for contrib in contributions: + prepare_bar.update(1) + if "data" in contrib: + contrib["data"] = unflatten_dict(contrib["data"]) + self._is_serializable_dict(contrib["data"]) + + update = "id" in contrib + project_name = ( + id2project[contrib["id"]] if update else contrib["project"] + ) + if ( + not update + and unique_identifiers.get(project_name) + and contrib["identifier"] + in existing.get(project_name, {}).get("identifiers", {}) + ): + continue - contrib_copy: dict[str, Any] = {} - for k in fields: - if k in contrib: - if isinstance(contrib[k], dict): - flat: dict[str, str | int | float] = {} - for kk, vv in flatten_dict(contrib[k]).items(): - if isinstance(vv, bool): - flat[kk] = "Yes" if vv else "No" - elif (isinstance(vv, str) and vv) or isinstance( - vv, (float, int) - ): - flat[kk] = vv - contrib_copy[k] = deepcopy(unflatten_dict(flat)) - else: - contrib_copy[k] = deepcopy(contrib[k]) + contrib_copy: dict[str, Any] = {} + for k in fields: + if k in contrib: + if isinstance(contrib[k], dict): + flat: dict[str, str | int | float] = {} + for kk, vv in flatten_dict(contrib[k]).items(): + if isinstance(vv, bool): + flat[kk] = "Yes" if vv else "No" + elif (isinstance(vv, str) and vv) or isinstance( + vv, (float, int) + ): + flat[kk] = vv + contrib_copy[k] = deepcopy(unflatten_dict(flat)) + else: + contrib_copy[k] = deepcopy(contrib[k]) - contribs[project_name].append(contrib_copy) + contribs[project_name].append(contrib_copy) - for component in MPCC_SETTINGS.COMPONENTS: - elements = contrib.get(component, []) - nelems = len(elements) + for component in MPCC_SETTINGS.COMPONENTS: + elements = contrib.get(component, []) + nelems = len(elements) - if nelems > MPCC_SETTINGS.MAX_ELEMS: - raise MPContribsClientError( - f"Too many {component} ({nelems} > {MPCC_SETTINGS.MAX_ELEMS})!" - ) + if nelems > MPCC_SETTINGS.MAX_ELEMS: + raise MPContribsClientError( + f"Too many {component} ({nelems} > {MPCC_SETTINGS.MAX_ELEMS})!" + ) - if update and not nelems: - continue # nothing to update for this component + if update and not nelems: + continue # nothing to update for this component - contribs[project_name][-1][component] = [] + contribs[project_name][-1][component] = [] - for element in elements: - if update and element is None: - contribs[project_name][-1][component].append(None) - continue + for element in elements: + if update and element is None: + contribs[project_name][-1][component].append(None) + continue - is_structure = isinstance(element, PmgStructure) - is_table = isinstance(element, (pd.DataFrame, Table)) - is_attachment = isinstance(element, (str, Path, Attachment)) - if component == "structures" and not is_structure: - raise MPContribsClientError( - f"Use pymatgen Structure for {component}!" - ) - elif component == "tables" and not is_table: - raise MPContribsClientError( - f"Use pandas DataFrame or mp_api.client.contribs.Table for {component}!" - ) - elif component == "attachments" and not is_attachment: - raise MPContribsClientError( - f"Use str, pathlib.Path or mp_api.client.contribs.Attachment for {component}" - ) + is_structure = isinstance(element, PmgStructure) + is_table = isinstance(element, (pd.DataFrame, Table)) + is_attachment = isinstance(element, (str, Path, Attachment)) + if component == "structures" and not is_structure: + raise MPContribsClientError( + f"Use pymatgen Structure for {component}!" + ) + elif component == "tables" and not is_table: + raise MPContribsClientError( + f"Use pandas DataFrame or mp_api.client.contribs.Table for {component}!" + ) + elif component == "attachments" and not is_attachment: + raise MPContribsClientError( + f"Use str, pathlib.Path or mp_api.client.contribs.Attachment for {component}" + ) - if is_structure: - dct = element.as_dict() - del dct["@module"] - del dct["@class"] + if is_structure: + dct = element.as_dict() + del dct["@module"] + del dct["@class"] - if not dct.get("charge"): - del dct["charge"] + if not dct.get("charge"): + del dct["charge"] - if "properties" in dct: - if dct["properties"]: - MPCC_LOGGER.warning( - "storing structure properties not supported, yet!" - ) - del dct["properties"] - elif is_table: - table = element - if not isinstance(table, Table): - table = Table(element) - table.attrs = element.attrs - - table._clean() - dct = table.to_dict(orient="split") - elif is_attachment: - if isinstance(element, (str, Path)): - element = Attachment.from_file(element) - - dct = {k: element[k] for k in ["mime", "content"]} - else: - raise MPContribsClientError("This should never happen") - - digest = get_md5(dct) - - if is_structure: - dct["name"] = getattr(element, "name", "structure") - elif is_table: - dct["name"], dct["attrs"] = table._attrs_as_dict() - elif is_attachment: - dct["name"] = element.name - - dupe = bool( - digest in digests[project_name][component] - or digest - in existing.get(project_name, {}) - .get(component, {}) # type: ignore[union-attr] - .get("md5s", []) - ) + if "properties" in dct: + if dct["properties"]: + MPCC_LOGGER.warning( + "storing structure properties not supported, yet!" + ) + del dct["properties"] + elif is_table: + table = element + if not isinstance(table, Table): + table = Table(element) + table.attrs = element.attrs + + table._clean() + dct = table.to_dict(orient="split") + elif is_attachment: + if isinstance(element, (str, Path)): + element = Attachment.from_file(element) + + dct = {k: element[k] for k in ["mime", "content"]} + else: + raise MPContribsClientError("This should never happen") + + digest = get_md5(dct) + + if is_structure: + dct["name"] = getattr(element, "name", "structure") + elif is_table: + dct["name"], dct["attrs"] = table._attrs_as_dict() + elif is_attachment: + dct["name"] = element.name + + dupe = bool( + digest in digests[project_name][component] + or digest + in existing.get(project_name, {}) + .get(component, {}) # type: ignore[union-attr] + .get("md5s", []) + ) - if not ignore_dupes and dupe: - # TODO add matching duplicate info to msg - msg = f"Duplicate in {project_name}: {contrib['identifier']} {dct['name']}" - raise MPContribsClientError(msg) + if not ignore_dupes and dupe: + # TODO add matching duplicate info to msg + msg = f"Duplicate in {project_name}: {contrib['identifier']} {dct['name']}" + raise MPContribsClientError(msg) - digests[project_name][component].add(digest) - contribs[project_name][-1][component].append(dct) + digests[project_name][component].add(digest) + contribs[project_name][-1][component].append(dct) - self._is_valid_payload("Contribution", contribs[project_name][-1]) + self._is_valid_payload("Contribution", contribs[project_name][-1]) # submit contributions if contribs: @@ -2197,6 +2230,7 @@ def put_future(pk, payload): total=ncontribs - total_processed, timeout=timeout, desc="Submit", + disable=self.mute_progress_bars, ) processed = sum(r.get("count", 0) for r in responses.values()) total_processed += processed @@ -2514,7 +2548,9 @@ def _download_resource( ) if futures: - responses = _run_futures(futures, timeout=timeout) + responses = _run_futures( + futures, timeout=timeout, disable=self.mute_progress_bars + ) for p, resp in responses.items(): Path(p).write_bytes(resp["result"]) diff --git a/mp_api/client/contribs/utils.py b/mp_api/client/contribs/utils.py index 87e32a36..652a29ac 100644 --- a/mp_api/client/contribs/utils.py +++ b/mp_api/client/contribs/utils.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING import orjson -from jsonschema.exceptions import ValidationError from swagger_spec_validator.common import SwaggerValidationError from mp_api.client.contribs.settings import MPCC_SETTINGS @@ -26,29 +25,12 @@ def _in_ipython() -> bool: return _ipython is not None and "IPKernelApp" in getattr(_ipython, "config", {}) -if _in_ipython(): +class MPContribsValidationError(MPContribsClientError, SwaggerValidationError): + """Raised when client-side validation of a value (e.g. email, URL) fails. - def _hide_traceback( - exc_tuple=None, - filename=None, - tb_offset=None, - exception_only=False, - running_compiled_code=False, - ): - etype, value, tb = sys.exc_info() - - if issubclass( - etype, (MPContribsClientError, SwaggerValidationError, ValidationError) - ): - return _ipython._showtraceback( - etype, value, _ipython.InteractiveTB.get_exception_only(etype, value) - ) - - return _ipython._showtraceback( - etype, value, _ipython.InteractiveTB(etype, value, tb) - ) - - _ipython.showtraceback = _hide_traceback # type: ignore[union-attr] + Subclasses SwaggerValidationError, which these validators raised previously, + so existing `except SwaggerValidationError` handlers keep working. + """ def _compress(data: Any) -> tuple[int, bytes]: diff --git a/mp_api/client/core/_display.py b/mp_api/client/core/_display.py new file mode 100644 index 00000000..2f14d928 --- /dev/null +++ b/mp_api/client/core/_display.py @@ -0,0 +1,464 @@ +"""Terminal / notebook output for the client: theme, progress bars and logging. + +All output goes to a single `rich.console.Console` writing to **stderr**, +so stdout stays clean (required by e.g. the MCP server's stdio transport). + +Progress bars +------------- +`progress_bar(...)` is a context manager. Every bar in the process is a task +on one shared `rich.progress.Progress`, because rich allows only one live +display at a time. Nested bars (e.g. batched queries that paginate) and bars +opened from several threads are supported. A bar is always removed when its +`with` block exits, including on exceptions. + +Bars are only drawn in an interactive terminal or Jupyter (with ipywidgets +installed). Elsewhere (web servers, CI, pipes) nothing is drawn or printed. + +Logging +------- +A default handler is attached to the `mp_api.client` logger on import, so +users see the client's messages without configuring logging. It writes +themed output in a terminal / notebook and plain one-line records otherwise. + +If the application has configured logging (handlers on the root logger, or on +any ancestor of `mp_api.client`), records are passed to those handlers +instead, honouring the application's levels, so nothing is printed twice. + +Opt out with `MPRESTER_LOGGING=false` or `mp_api.client.disable_logging()`. +""" + +from __future__ import annotations + +import logging +import threading +import time +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from rich.console import Console +from rich.logging import RichHandler +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + ProgressColumn, + SpinnerColumn, + TaskID, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) +from rich.table import Column +from rich.text import Text +from rich.theme import Theme + +if TYPE_CHECKING: + from collections.abc import Iterator + + from rich.progress import Task + +__all__ = [ + "MP_THEME", + "disable_logging", + "enable_logging", + "get_console", + "print_notice", + "progress_bar", + "set_console", +] + +LOGGER_NAME = "mp_api.client" + +MP_THEME = Theme( + { + "mp.brand": "#00BDAD", # $primary (MP teal) + "mp.brand.dark": "#008F83", # $primary-dark + "mp.accent": "#3960E3", # $tertiary (links) + "mp.secondary": "#939FC8", # $secondary-light (readable on dark bg) + "mp.success": "#48C78E", # Bulma success + "mp.warning": "#D69E00", # amber, readable on light + dark bg + "mp.error": "#F14668", # Bulma danger + "mp.muted": "#7A7A7A", # Bulma .has-text-grey + "mp.title": "bold underline", + "mp.field": "bold", + # rich built-ins used by progress bars / logging + "progress.description": "default", + "progress.download": "#939FC8", + "progress.elapsed": "#7A7A7A", + "progress.remaining": "#7A7A7A", + "progress.spinner": "#00BDAD", + "bar.complete": "#00BDAD", + "bar.finished": "#008F83", + "bar.pulse": "#00BDAD", + "logging.level.info": "#00BDAD", + "logging.level.warning": "#D69E00", + "logging.level.error": "#F14668", + "logging.level.critical": "bold reverse #F14668", + "logging.level.debug": "#7A7A7A", + } +) + +# -------------------------------------------------------------------------- +# Console +# -------------------------------------------------------------------------- + + +class _ConsoleHolder: + console: Console | None = None + lock = threading.Lock() + + +def get_console() -> Console: + """Get the shared console (stderr, MP theme), creating it on first use.""" + if _ConsoleHolder.console is None: + with _ConsoleHolder.lock: + if _ConsoleHolder.console is None: + _ConsoleHolder.console = Console(stderr=True, theme=MP_THEME) + return _ConsoleHolder.console + + +def set_console(console: Console | None) -> None: + """Replace the shared console, e.g. in tests. `None` resets to the default.""" + with _ConsoleHolder.lock: + _ConsoleHolder.console = console + + +def _is_interactive(console: Console) -> bool: + return console.is_jupyter or (console.is_terminal and not console.is_dumb_terminal) + + +def _can_draw_live(console: Console) -> bool: + """Whether live progress can be drawn (needs ipywidgets under Jupyter).""" + if console.is_jupyter: + try: + import ipywidgets # noqa: F401 + except ImportError: + return False + return True + return console.is_terminal and not console.is_dumb_terminal + + +def print_notice(message: str, style: str | None = None) -> None: + """Print a one-line notice to the console (stderr). + + Plain text: the message is not parsed for rich markup. + + Args: + message (str) : text to print + style (str or None) : theme style, e.g. "mp.warning" + """ + get_console().print(Text(message, style=style or "")) + + +# -------------------------------------------------------------------------- +# Progress bars +# -------------------------------------------------------------------------- + + +class _RateColumn(ProgressColumn): + """Items per second, using the task's `unit` field. Hidden on narrow terminals.""" + + min_console_width = 100 + + def render(self, task: Task) -> Text: + console = get_console() + if console.width < self.min_console_width: + return Text("") + speed = task.finished_speed or task.speed + if speed is None: + return Text("") + unit = task.fields.get("unit", "it") + if speed >= 1000: + return Text(f"{speed / 1000:.1f}k {unit}/s", style="mp.secondary") + return Text(f"{speed:.0f} {unit}/s", style="mp.secondary") + + +class _ProgressState: + """The process-wide Progress and its reference count. Guarded by `lock`.""" + + def __init__(self) -> None: + self.lock = threading.RLock() + self.progress: Progress | None = None + self.active = 0 + self.local = threading.local() # per-thread nesting depth + + def depth(self) -> int: + return getattr(self.local, "depth", 0) + + def acquire(self, console: Console) -> Progress: + with self.lock: + if self.progress is None: + self.progress = Progress( + SpinnerColumn(), + # The description is truncated (with an ellipsis) to leave + # room for the numbers on narrow terminals. + TextColumn( + "{task.description}", + table_column=Column( + no_wrap=True, + overflow="ellipsis", + max_width=max(20, console.width * 2 // 5), + ), + ), + BarColumn(bar_width=20), + MofNCompleteColumn(table_column=Column(no_wrap=True)), + _RateColumn(table_column=Column(no_wrap=True)), + TimeElapsedColumn(table_column=Column(no_wrap=True)), + TimeRemainingColumn(table_column=Column(no_wrap=True)), + console=console, + transient=True, + ) + self.progress.start() + self.active += 1 + return self.progress + + def release(self, task_id: TaskID) -> None: + with self.lock: + if self.progress is None: # pragma: no cover - defensive + return + self.progress.remove_task(task_id) + self.active -= 1 + if self.active == 0: + self.progress.stop() + self.progress = None + + +_STATE = _ProgressState() + + +class ProgressHandle: + """Handle yielded by `progress_bar`; update it as work completes.""" + + def __init__( + self, + total: float | None, + progress: Progress | None = None, + task_id: TaskID | None = None, + delay: float = 0.0, + ) -> None: + self.completed: float = 0 + self._total = total + self._progress = progress + self._task_id = task_id + self._delay = delay + self._start = time.monotonic() + self.shown = progress is not None and delay <= 0 + + @property + def total(self) -> float | None: + return self._total + + @total.setter + def total(self, value: float | None) -> None: + self._total = value + if self._progress is not None and self._task_id is not None: + self._progress.update(self._task_id, total=value) + + @property + def elapsed(self) -> float: + return time.monotonic() - self._start + + def update(self, advance: float = 1) -> None: + """Advance the bar by `advance` units.""" + self.completed += advance + if self._progress is None or self._task_id is None: + return + if not self.shown and self.elapsed >= self._delay: + self.shown = True + self._progress.update(self._task_id, visible=True) + self._progress.advance(self._task_id, advance) + + +def _as_int(value: float | None) -> float | None: + """Show (near) whole-number counts as ints, without a trailing `.0`.""" + if isinstance(value, float) and abs(value - round(value)) < 1e-6: + return int(round(value)) + return value + + +@contextmanager +def progress_bar( + description: str, + total: float | None = None, + *, + enabled: bool = True, + delay: float = 0.0, + unit: str = "docs", + summary: str | None = None, +) -> Iterator[ProgressHandle]: + """Show a progress bar for the duration of a `with` block. + + Args: + description (str) : label shown next to the bar + total (float or None) : expected number of units, None if unknown + enabled (bool) : if False, nothing is shown (a no-op handle is yielded) + delay (float) : seconds before the bar is shown, so quick + operations stay silent. Checked on each update. + unit (str) : unit name for the rate column + summary (str or None) : if given, printed as a one-line summary when + the outermost bar on this thread finishes without error, was + shown, and completed at least one unit. Formatted with `completed` and `total`, and followed by + the elapsed time, e.g. "Retrieved {completed:,} documents". + + Yields: + ProgressHandle + """ + console = get_console() + if not enabled or not _is_interactive(console): + yield ProgressHandle(total) + return + + outermost = _STATE.depth() == 0 + _STATE.local.depth = _STATE.depth() + 1 + try: + if not _can_draw_live(console): + # e.g. Jupyter without ipywidgets: no live bar, still summarize + handle = ProgressHandle(total) + handle.shown = delay <= 0 + yield handle + else: + progress = _STATE.acquire(console) + task_id = progress.add_task( + description, total=total, visible=delay <= 0, unit=unit + ) + handle = ProgressHandle(total, progress, task_id, delay) + try: + yield handle + finally: + _STATE.release(task_id) + finally: + _STATE.local.depth -= 1 + + if summary and outermost and handle.shown and handle.completed: + message = summary.format( + completed=_as_int(handle.completed), total=_as_int(handle.total) + ) + console.print( + Text.assemble( + ("✓ ", "mp.brand"), + message, + (f" in {handle.elapsed:.1f}s", "mp.muted"), + ) + ) + + +# -------------------------------------------------------------------------- +# Logging +# -------------------------------------------------------------------------- + +_PLAIN_FORMAT = "%(name)s - %(levelname)s - %(message)s" + + +def _app_handlers(logger: logging.Logger) -> list[logging.Handler]: + """Handlers the application attached to ancestors of `logger`.""" + handlers: list[logging.Handler] = [] + current = logger.parent + while current is not None: + handlers.extend(current.handlers) + if not current.propagate: + break + current = current.parent + return handlers + + +def _app_level(logger: logging.Logger) -> int: + """Effective level the application set above `logger`.""" + current = logger.parent + while current is not None: + if current.level: + return current.level + current = current.parent + return logging.WARNING # pragma: no cover - root always has a level + + +class _MPDefaultHandler(logging.Handler): + """Default handler for `mp_api.client`, see module docstring.""" + + def __init__(self, logger: logging.Logger) -> None: + super().__init__() + self._logger = logger + self._rich: RichHandler | None = None + self._plain = logging.Formatter(_PLAIN_FORMAT) + + def _rich_handler(self, console: Console) -> RichHandler: + if self._rich is None or self._rich.console is not console: + self._rich = RichHandler( + console=console, + show_time=False, + show_path=False, + markup=False, + rich_tracebacks=False, + ) + return self._rich + + def emit(self, record: logging.LogRecord) -> None: + # Defer to logging the application configured, with its levels. + # (`mp_api.client` doesn't propagate while this handler is installed.) + if handlers := _app_handlers(self._logger): + if record.levelno >= _app_level(self._logger): + for handler in handlers: + if record.levelno >= handler.level: + handler.handle(record) + return + + try: + console = get_console() + if _is_interactive(console): + self._rich_handler(console).emit(record) + else: + console.file.write(self._plain.format(record) + "\n") + console.file.flush() + except Exception: # pragma: no cover - logging must not raise + self.handleError(record) + + +_handler_lock = threading.Lock() + + +def _installed_handler(logger: logging.Logger) -> _MPDefaultHandler | None: + return next((h for h in logger.handlers if isinstance(h, _MPDefaultHandler)), None) + + +def enable_logging(level: int | str | None = None) -> None: + """Show messages from the client on stderr (the default). + + Safe to call repeatedly; only one handler is ever installed. + + Args: + level (int, str or None) : minimum level to show, e.g. "WARNING". + Defaults to the `MPRESTER_LOG_LEVEL` setting (INFO). + """ + from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS + + logger = logging.getLogger(LOGGER_NAME) + with _handler_lock: + if _installed_handler(logger) is None: + # Remove the placeholder NullHandler, if any + for h in [h for h in logger.handlers if type(h) is logging.NullHandler]: + logger.removeHandler(h) + logger.addHandler(_MPDefaultHandler(logger)) + # The handler forwards to the application's handlers itself + logger.propagate = False + logger.setLevel(level or MAPI_CLIENT_SETTINGS.LOG_LEVEL) + + +def disable_logging() -> None: + """Stop the client printing messages; standard logging propagation resumes.""" + logger = logging.getLogger(LOGGER_NAME) + with _handler_lock: + if (handler := _installed_handler(logger)) is not None: + logger.removeHandler(handler) + if not logger.handlers: + logger.addHandler(logging.NullHandler()) + logger.propagate = True + logger.setLevel(logging.NOTSET) + + +def _install_default_logging() -> None: + """Install the default handler on import, unless disabled by settings.""" + from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS + + if MAPI_CLIENT_SETTINGS.LOGGING: + enable_logging() + else: + logging.getLogger(LOGGER_NAME).addHandler(logging.NullHandler()) diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index be5968d4..8362ad6c 100644 --- a/mp_api/client/core/client.py +++ b/mp_api/client/core/client.py @@ -15,6 +15,7 @@ import sys import warnings from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from contextlib import ExitStack from copy import copy from functools import cache from importlib.metadata import PackageNotFoundError, version @@ -39,10 +40,10 @@ from pydantic import BaseModel from requests.adapters import HTTPAdapter from requests.exceptions import RequestException -from tqdm.auto import tqdm from urllib3.util.retry import Retry from mp_api.client._server_utils import get_consumer, get_user_api_key, is_dev_env +from mp_api.client.core._display import ProgressHandle, progress_bar from mp_api.client.core.delta import DeltaCatalog from mp_api.client.core.exceptions import ( MPRestError, @@ -130,7 +131,7 @@ def register(self, table_name: str, delta_table: DeltaTable) -> QueryBuilder: def execute(self, sql: str) -> RecordBatchReader: """Execute SQL against the tables in the underlying catalog.""" - return self.catalog.execute_stream(sql) + return self.catalog._execute_raw(sql) class _Rester: @@ -240,6 +241,12 @@ def session(self) -> requests.Session: ) return self._session + @property + def _docs_description(self) -> str: + """E.g. "SummaryDoc documents", for progress bars and messages.""" + name = getattr(getattr(self, "document_model", None), "__name__", "") + return f"{name} documents" if name and not name.startswith("_") else "documents" + @property def delta_catalog(self) -> DeltaCatalog: """The DeltaCatalog used for delta-backed queries, created on first use.""" @@ -748,9 +755,6 @@ def _query_delta_backed( prefix = prefix.rstrip("/") # Check if user has access to GNoMe - # temp suppress tqdm - re_enable = not self.mute_progress_bars - self.mute_progress_bars = True has_gnome_access = bool( self._submit_requests( url=urljoin(self.base_endpoint, "materials/summary/"), @@ -762,11 +766,11 @@ def _query_delta_backed( num_chunks=1, chunk_size=1, timeout=timeout if timeout is not None else self.timeout, + show_progress=False, ) .get("meta", {}) .get("total_doc", 0) ) - self.mute_progress_bars = not re_enable suffix = prefix.rsplit("/")[1] @@ -833,19 +837,6 @@ def _query_delta_backed( # batch_id isn't a valid field num_docs_needed = self.count() - pbar = ( - tqdm( - desc=( - f"Retrieving DeltaTable-backed {self.document_model.__name__} documents" - if self.document_model is not None - else "Retrieving documents" - ), - total=num_docs_needed, - ) - if not self.mute_progress_bars - else None - ) - iterator = self.delta_catalog.execute_stream( f"SELECT * FROM {tbl_lbl} {predicate}" ) @@ -878,42 +869,43 @@ def _flush( file_options=file_options, ) - group = 1 - size = 0 - accumulator = [] - _schema = pa.schema(arrowize(self.document_model)) - schema = ( - _schema.insert(0, pa.field("version", pa.string())) - if versioned - else _schema - ) - partitioning_schema = pa.schema([pa.field("version", pa.string())]) - partitioning = ( - pa.dataset.partitioning(partitioning_schema, flavor="hive") - if versioned - else None - ) - for page in iterator: - # arro3 rb to pyarrow rb for compat w/ pyarrow ds writer - rg = pa.record_batch(page) - accumulator.append(rg) - page_size = page.num_rows - size += rg.get_total_buffer_size() - - if pbar is not None: - pbar.update(page_size) + docs = self._docs_description + with progress_bar( + f"Retrieving DeltaTable-backed {docs}", + total=num_docs_needed, + enabled=not self.mute_progress_bars, + summary=f"Downloaded {{completed:,}} {docs} to {target_path}", + ) as pbar: + group = 1 + size = 0 + accumulator = [] + _schema = pa.schema(arrowize(self.document_model)) + schema = ( + _schema.insert(0, pa.field("version", pa.string())) + if versioned + else _schema + ) + partitioning_schema = pa.schema([pa.field("version", pa.string())]) + partitioning = ( + pa.dataset.partitioning(partitioning_schema, flavor="hive") + if versioned + else None + ) + for rg in iterator: + accumulator.append(rg) + page_size = rg.num_rows + size += rg.get_total_buffer_size() - if size >= MAPI_CLIENT_SETTINGS.DATASET_FLUSH_THRESHOLD: - _flush(accumulator, group, schema, partitioning) - group += 1 - size = 0 - accumulator.clear() + pbar.update(page_size) - if accumulator: - _flush(accumulator, group + 1, schema, partitioning) + if size >= MAPI_CLIENT_SETTINGS.DATASET_FLUSH_THRESHOLD: + _flush(accumulator, group, schema, partitioning) + group += 1 + size = 0 + accumulator.clear() - if pbar is not None: - pbar.close() + if accumulator: + _flush(accumulator, group + 1, schema, partitioning) logger.info(f"Dataset for {suffix} written to {target_path}") logger.info("Converting to DeltaTable...") @@ -948,6 +940,7 @@ def _query_resource( num_chunks: int | None = None, chunk_size: int | None = None, timeout: int | None = None, + show_progress: bool | None = None, ) -> dict[str, Any]: """Query the endpoint for a Resource containing a list of documents and meta information about pagination and total document count. @@ -963,6 +956,8 @@ def _query_resource( num_chunks: Maximum number of chunks of data to yield. None will yield all possible. chunk_size: Number of data entries per chunk. timeout (float or None): Time in seconds to wait until a request timeout error is thrown + show_progress (bool or None): Whether to show progress bars for this call. + If None, defers to `not self.mute_progress_bars`. Returns: A Resource, a dict with two keys, "data" containing a list of documents, and @@ -971,6 +966,8 @@ def _query_resource( """ if use_document_model is None: use_document_model = self.use_document_model + if show_progress is None: + show_progress = not self.mute_progress_bars timeout = self.timeout if timeout is None else timeout @@ -1000,11 +997,9 @@ def _query_resource( url = validate_endpoint(self.endpoint, suffix=suburl) if query_s3: - pbar_message = ( # type: ignore - f"Retrieving {self.document_model.__name__} documents" # type: ignore - if self.document_model is not None - else "Retrieving documents" - ) + docs_name = self._docs_description + pbar_message = f"Retrieving {docs_name}" + pbar_summary = f"Retrieved {{completed:,}} {docs_name}" if "/" not in self.suffix: suffix = self.suffix @@ -1060,6 +1055,7 @@ def _query_resource( num_chunks=num_chunks, chunk_size=chunk_size, timeout=timeout, + show_progress=show_progress, ) if fields: @@ -1078,25 +1074,21 @@ def _query_resource( for key in keys } - # Setup progress bar num_docs_needed = int(self.count()) - pbar = ( - tqdm( - desc=pbar_message, - total=num_docs_needed, - ) - if not self.mute_progress_bars - else None - ) - - unzipped_chunks = [ - docs - for docs, _, _ in self._multi_thread( - self._query_open_data, - list(s3_params_list.values()), - pbar, # type: ignore - ) - ] + with progress_bar( + pbar_message, + total=num_docs_needed, + enabled=show_progress, + summary=pbar_summary, + ) as pbar: + unzipped_chunks = [ + docs + for docs, _, _ in self._multi_thread( + self._query_open_data, + list(s3_params_list.values()), + pbar, + ) + ] _chunks = chain.from_iterable(unzipped_chunks) data: dict[str, Any] = { @@ -1116,6 +1108,7 @@ def _query_resource( num_chunks=num_chunks, chunk_size=chunk_size, timeout=timeout, + show_progress=show_progress, ) return data @@ -1132,6 +1125,7 @@ def _submit_requests( timeout: int | None = None, max_batch_size: int = 100, norecur: bool = False, + show_progress: bool | None = None, ) -> dict: """Handle submitting requests sequentially with pagination. @@ -1148,10 +1142,15 @@ def _submit_requests( max_batch_size (int) : Maximum size of a batch when retrieving batches in parallel norecur (bool) : Whether to forbid recursive splitting of a query field when a direct query fails + show_progress (bool or None): Whether to show progress bars for this call. + If None, defers to `not self.mute_progress_bars`. Returns: Dictionary containing data and metadata """ + if show_progress is None: + show_progress = not self.mute_progress_bars + # Parameters that naturally support comma-separated values and should NOT be split no_split_params = { "elements", @@ -1175,6 +1174,51 @@ def _submit_requests( "chemsys", } + with ExitStack() as stack: + return self._submit_requests_inner( + stack, + url=url, + criteria=criteria, + use_document_model=use_document_model, + chunk_size=chunk_size, + num_chunks=num_chunks, + timeout=timeout, + max_batch_size=max_batch_size, + norecur=norecur, + show_progress=show_progress, + no_split_params=no_split_params, + ) + + def _submit_requests_inner( + self, + stack: ExitStack, + url: str, + criteria: dict[str, Any], + use_document_model: bool, + chunk_size: int | None, + num_chunks: int | None, + timeout: int | None, + max_batch_size: int, + norecur: bool, + show_progress: bool, + no_split_params: set[str], + ) -> dict: + """Body of `_submit_requests`; `stack` owns the progress bar's lifetime.""" + docs_name = self._docs_description + pbar: ProgressHandle | None = None + + def open_bar() -> ProgressHandle: + # Started before the first request (spinner until the total is + # known), closed when `stack` exits, including on errors. + return stack.enter_context( + progress_bar( + f"Retrieving {docs_name}", + total=None, + enabled=show_progress, + summary=f"Retrieved {{completed:,}} {docs_name}", + ) + ) + # Check if we need to split any comma-separated parameters split_param = None split_values = [] @@ -1231,49 +1275,42 @@ def _submit_requests( # Batch the split values to reduce number of requests # Use batches of up to 100 values to balance URL length and request count - num_batches = min( - max_batch_size, max(1, len(split_values) // max_batch_size) - ) batch_size = min(len(split_values), max_batch_size) + num_batches = ceil(len(split_values) / batch_size) + + with progress_bar( + f"Retrieving {len(split_values)} {split_param} values " + f"in {num_batches} batches", + total=num_batches, + enabled=show_progress, + unit="batches", + summary=f"Retrieved {len(split_values)} {split_param} values " + "in {completed} batches", + ) as pbar: + for batch in batched(split_values, batch_size): + split_criteria = copy(criteria) + split_criteria[split_param] = ",".join(batch) + + # Recursively call _submit_requests with the batch + # This will trigger another split if the batch is still too large + result = self._submit_requests( + url=url, + criteria=split_criteria, + use_document_model=use_document_model, + chunk_size=chunk_size, + num_chunks=num_chunks, + timeout=timeout, + norecur=len(batch) <= max_batch_size, + show_progress=show_progress, + ) + + data_chunks.append(result["data"]) + if "meta" in result: + total_data["meta"] = result["meta"] + total_num_docs += result["meta"].get("total_doc", 0) - # Setup progress bar for split parameter requests - pbar_message = f"Retrieving {len(split_values)} {split_param} values in {num_batches} batches" - pbar = ( - tqdm( - desc=pbar_message, - total=num_batches, - ) - if not self.mute_progress_bars - else None - ) - - for batch in batched(split_values, batch_size): - split_criteria = copy(criteria) - split_criteria[split_param] = ",".join(batch) - - # Recursively call _submit_requests with the batch - # This will trigger another split if the batch is still too large - result = self._submit_requests( - url=url, - criteria=split_criteria, - use_document_model=use_document_model, - chunk_size=chunk_size, - num_chunks=num_chunks, - timeout=timeout, - norecur=len(batch) <= max_batch_size, - ) - - data_chunks.append(result["data"]) - if "meta" in result: - total_data["meta"] = result["meta"] - total_num_docs += result["meta"].get("total_doc", 0) - - if pbar is not None: pbar.update(1) - if pbar is not None: - pbar.close() - total_data["data"] = list(chain.from_iterable(data_chunks)) # Update total_doc if we have meta @@ -1286,6 +1323,7 @@ def _submit_requests( raise else: # No splitting needed - get first page + pbar = open_bar() total_data = {"data": []} initial_criteria = copy(criteria) if isinstance( @@ -1320,25 +1358,12 @@ def _submit_requests( # Get total number of docs needed num_docs_needed = min((max_pages * chunk_size), total_num_docs) - # Setup progress bar - pbar_message = ( # type: ignore - f"Retrieving {self.document_model.__name__} documents" # type: ignore - if self.document_model is not None - else "Retrieving documents" - ) - pbar = ( - tqdm( - desc=pbar_message, - total=num_docs_needed, - ) - if not self.mute_progress_bars and total_num_docs > 0 - else None - ) - + if pbar is None: + # first request succeeded on the split path + pbar = open_bar() initial_data_length = total_data_len - - if pbar is not None: - pbar.update(initial_data_length) + pbar.total = num_docs_needed + pbar.update(min(initial_data_length, num_docs_needed)) # If we have all the results in a single page, return directly if initial_data_length >= num_docs_needed or num_chunks == 1: @@ -1346,9 +1371,6 @@ def _submit_requests( new_total_data["data"] = list(chain.from_iterable(data_chunks))[ :num_docs_needed ] - - if pbar is not None: - pbar.close() return new_total_data # Warning to select specific fields only for many results @@ -1384,9 +1406,7 @@ def _submit_requests( data_chunks.append(data["data"]) chunk_len = len(data["data"]) total_data_len += chunk_len - - if pbar is not None: - pbar.update(chunk_len) + pbar.update(chunk_len) skip += page_criteria["_limit"] remaining_docs -= chunk_len @@ -1395,9 +1415,6 @@ def _submit_requests( if chunk_len == 0: break - if pbar is not None: - pbar.close() - total_data["data"] = list(chain.from_iterable(data_chunks)) return total_data @@ -1408,14 +1425,14 @@ def _multi_thread( self, func: Callable, params_list: list[dict], - progress_bar: tqdm | None = None, + progress_bar: ProgressHandle | None = None, ) -> list[tuple[Any, int, int]]: """Handles setting up a threadpool and sending parallel requests. Arguments: func (Callable): Callable function to multi params_list (list): list of dictionaries containing url and params for each request - progress_bar (tqdm): progress bar to update with progress + progress_bar (ProgressHandle): progress bar to update with progress Returns: Tuples with data, total number of docs in matching the query in the database, @@ -1598,7 +1615,6 @@ def _search( """A generic search method to retrieve documents matching specific parameters. Arguments: - mute (bool): Whether to mute progress bars. num_chunks (int): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int): Number of data entries per chunk. all_fields (bool): Set to False to only return specific fields of interest. This will @@ -1669,7 +1685,7 @@ def _get_all_documents( num_chunks=None, ) -> list[BaseModel] | list[dict]: """Iterates over pages until all documents are retrieved. Displays - progress using tqdm. This method is designed to give a common + progress bars. This method is designed to give a common implementation for the search_* methods on various endpoints. See materials endpoint for an example of this in use. """ @@ -1703,15 +1719,14 @@ def count(self, criteria: dict | None = None) -> int: int : Count of total results """ criteria = criteria or {} - user_preferences = ( - self.use_document_model, - self.mute_progress_bars, - ) - self.use_document_model, self.mute_progress_bars = ( - False, - True, - ) # do not waste cycles decoding - results = self._query_resource(criteria=criteria, num_chunks=1, chunk_size=1) + # do not waste cycles decoding, and don't show progress + query_kwargs: dict[str, Any] = { + "num_chunks": 1, + "chunk_size": 1, + "use_document_model": False, + "show_progress": False, + } + results = self._query_resource(criteria=criteria, **query_kwargs) cnt = results["meta"]["total_doc"] no_query = not {field for field in criteria if field[0] != "_"} @@ -1719,9 +1734,7 @@ def count(self, criteria: dict | None = None) -> int: allowed_params = inspect.getfullargspec(self.search).args if "deprecated" in allowed_params: criteria["deprecated"] = True - results = self._query_resource( - criteria=criteria, num_chunks=1, chunk_size=1 - ) + results = self._query_resource(criteria=criteria, **query_kwargs) cnt += results["meta"]["total_doc"] warnings.warn( "Omitting a query also includes deprecated documents in the results. " @@ -1730,11 +1743,6 @@ def count(self, criteria: dict | None = None) -> int: stacklevel=2, ) - ( - self.use_document_model, - self.mute_progress_bars, - ) = user_preferences - if isinstance(cnt, str): raise MPRestError(f"Error counting documents: {cnt}") return cnt diff --git a/mp_api/client/core/delta.py b/mp_api/client/core/delta.py index 69428590..dde22d26 100644 --- a/mp_api/client/core/delta.py +++ b/mp_api/client/core/delta.py @@ -304,7 +304,7 @@ def execute(self, sql: str, label: str | None = None) -> pa.Table: ) raise - def execute_stream(self, sql: str) -> RecordBatchReader: + def execute_stream(self, sql: str) -> pa.RecordBatchReader: """Run a SQL query and stream the results, without retrying. Used for full-dataset downloads, where batches may already have been @@ -314,12 +314,21 @@ def execute_stream(self, sql: str) -> RecordBatchReader: Does not use the runner: batches are fetched lazily as the returned reader is consumed, in the consuming thread. + The arro3 reader from deltalake is wrapped in a pyarrow reader (via the + Arrow C stream interface, no copy). Iterating the arro3 reader holds the + GIL while each batch is fetched, which freezes other threads, e.g. the + progress bar's refresh thread; pyarrow releases it. + Args: sql (str) : SQL query Returns: - arro3 RecordBatchReader + pyarrow.RecordBatchReader """ + return pa.RecordBatchReader.from_stream(self._execute_raw(sql)) + + def _execute_raw(self, sql: str) -> RecordBatchReader: + """Run a SQL query and return deltalake's (arro3) reader.""" with self._lock: qb = self._qb return qb.execute(sql) diff --git a/mp_api/client/core/exceptions.py b/mp_api/client/core/exceptions.py index 12faf21c..043a0ccf 100644 --- a/mp_api/client/core/exceptions.py +++ b/mp_api/client/core/exceptions.py @@ -16,6 +16,14 @@ class MPRestWarning(Warning): class MPContribsClientError(ValueError): """Raised when the MPContribs client has problems.""" + def _render_traceback_(self) -> list[str]: + """Show only the error message (no stack) in IPython / Jupyter. + + These errors describe invalid user input, so the stack isn't useful. + IPython calls this hook for this exception type only. + """ + return [f"{type(self).__name__}: {self}"] + def _emit_status_warning() -> None: """Emit a warning if client can't hear a heartbeat.""" diff --git a/mp_api/client/core/schemas.py b/mp_api/client/core/schemas.py index 9a886b28..dbc35604 100644 --- a/mp_api/client/core/schemas.py +++ b/mp_api/client/core/schemas.py @@ -16,6 +16,8 @@ create_model, field_validator, ) +from rich.highlighter import ReprHighlighter +from rich.text import Text if TYPE_CHECKING: from collections.abc import Iterator @@ -153,29 +155,45 @@ def _generate_returned_model( orig_rester_name = document_model.__name__ + def _shown_fields(self, include_not_requested: bool) -> list[str]: + return [ + n + for n in data_model.model_fields + if n in set_fields + or (include_not_requested and n == "fields_not_requested") + ] + def new_repr(self) -> str: extra = ",\n".join( - f"\033[1m{n}\033[0;0m={getattr(self, n)!r}" - for n in data_model.model_fields - if n == "fields_not_requested" or n in set_fields + f"{n}={getattr(self, n)!r}" for n in _shown_fields(self, True) ) - - s = f"\033[4m\033[1m{self.__class__.__name__}<{orig_rester_name}>\033[0;0m\033[0;0m(\n{extra}\n)" # noqa: E501 - return s + return f"{self.__class__.__name__}<{orig_rester_name}>(\n{extra}\n)" def new_str(self) -> str: extra = ",\n".join( - f"\033[1m{n}\033[0;0m={getattr(self, n)!r}" - for n in data_model.model_fields - if n in set_fields + f"{n}={getattr(self, n)!r}" for n in _shown_fields(self, False) ) - return ( - f"\033[4m\033[1m{self.__class__.__name__}" - f"<{orig_rester_name}>\033[0;0m\033[0;0m" + f"{self.__class__.__name__}<{orig_rester_name}>" f"\n{extra}\n\n" - f"\033[1mFields not requested:\033[0;0m\n{fields_not_requested}" + f"Fields not requested:\n{fields_not_requested}" + ) + + def new_rich(self) -> Text: + """Styled rendering for rich (console.print, rich.pretty in IPython).""" + highlight = ReprHighlighter() + text = Text() + text.append( + f"{self.__class__.__name__}<{orig_rester_name}>", style="bold underline" ) + text.append("(\n") + for n in _shown_fields(self, True): + text.append(f" {n}", style="bold") + text.append("=") + text.append_text(highlight(repr(getattr(self, n)))) + text.append(",\n") + text.append(")") + return text def new_getattr(self, attr) -> str: if attr in self.unavailable_fields: @@ -196,6 +214,7 @@ def new_dict(self, *args, **kwargs): data_model.__repr__ = new_repr data_model.__str__ = new_str + data_model.__rich__ = new_rich data_model.__getattr__ = new_getattr data_model.dict = new_dict diff --git a/mp_api/client/core/settings.py b/mp_api/client/core/settings.py index 4e030e72..b22dacf1 100644 --- a/mp_api/client/core/settings.py +++ b/mp_api/client/core/settings.py @@ -53,6 +53,16 @@ class MAPIClientSettings(BaseSettings): description="Whether to mute progress bars when data is retrieved.", ) + LOGGING: bool = Field( + True, + description="Whether the client shows its log messages on stderr by default. " + "If the application configures logging, messages go to its handlers instead.", + ) + + LOG_LEVEL: str = Field( + "INFO", description="Minimum level of client log messages to show." + ) + MIN_EMMET_VERSION: str = Field( "0.86.3rc0", description="Minimum compatible version of emmet-core for the client.", diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index 985238ea..b0a0e353 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -29,6 +29,7 @@ from pymatgen.symmetry.analyzer import SpacegroupAnalyzer from requests import Session, get +from mp_api.client.core._display import print_notice from mp_api.client.core._oxygen_evolution import OxygenEvolution from mp_api.client.core.client import _Rester from mp_api.client.core.exceptions import ( @@ -163,10 +164,10 @@ def __init__( Share one instance across MPRester instances (e.g. one per web-server worker) to reuse loaded table snapshots. If None, one is created and shared by all resters under this MPRester. - notify_db_version (bool): If True, the current MP database version will - be retrieved and logged locally in the ~/.mprester.log.yaml. If the database - version changes, you will be notified. The current database version is - also printed on instantiation. These local logs are not sent to + notify_db_version (bool): If True, the current MP database version is + printed (to stderr) on instantiation and recorded locally in + ~/.mprester.log.yaml. If it changed since the last recorded version, this + is highlighted and an MPRestWarning is also emitted. These local logs are not sent to materialsproject.org and are not associated with your API key, so be aware that a notification may not be presented if you run MPRester from multiple computing environments. @@ -290,6 +291,7 @@ def contribs(self): - headers - session - use_document_model + - mute_progress_bars """ if self._contribs is None: @@ -301,6 +303,7 @@ def contribs(self): headers=self.headers, session=self.session, use_document_model=self.use_document_model, + mute_progress_bars=self.mute_progress_bars, **self._contribs_kwargs, ) @@ -1921,7 +1924,7 @@ def get_oxygen_evolution( ) def _db_version_check(self) -> None: - """Check if the database version has drifted.""" + """Print the database version and note if it has changed since last use.""" import yaml # type: ignore[import-untyped] old_db_version = None @@ -1934,6 +1937,15 @@ def _db_version_check(self) -> None: if not isinstance(old_db_version, str): old_db_version = None + if old_db_version and old_db_version != self.db_version: + print_notice( + "Materials Project database version changed: " + f"v{old_db_version} → v{self.db_version}", + style="mp.warning", + ) + else: + print_notice(f"Materials Project database version: v{self.db_version}") + if old_db_version != self.db_version: MAPI_CLIENT_SETTINGS.LOG_FILE.write_text( yaml.safe_dump({"MAPI_DB_VERSION": self.db_version}) diff --git a/mp_api/client/routes/materials/summary.py b/mp_api/client/routes/materials/summary.py index 02774097..fad14575 100644 --- a/mp_api/client/routes/materials/summary.py +++ b/mp_api/client/routes/materials/summary.py @@ -234,52 +234,45 @@ def search( # noqa: D417 warning_strs: list[str] = [] exc_strs: list[str] = [] - def csrc(x): - return f"\x1b[34m{x}\x1b[39m" - - def _csrc(x): - return f"\x1b[31m{x}\x1b[39m" - # Warn the user if they input any fields from _search without setting equivalent kwargs in search if db_keys["warn"]: warning_strs.extend( [ - f"You have specified fields used by {_csrc('`_search`')} that can be understood by {csrc('`search`')}", - f" {', '.join([_csrc(min_max_name_dict[k]) for k in db_keys['warn']])}", - f"To ensure long term support, please use their {csrc('`search`')} equivalents:", - f" {', '.join([csrc(k) for k in db_keys['warn']])}", + "You have specified fields used by `_search` that can be understood by `search`:", + f" {', '.join(min_max_name_dict[k] for k in db_keys['warn'])}", + "To ensure long term support, please use their `search` equivalents:", + f" {', '.join(db_keys['warn'])}", ] ) # Throw an exception if the user input a field from _search and its equivalent search kwarg if db_keys["duplicate"]: - dupe_pairs = "\n".join( - f"{csrc(k)} and {_csrc(min_max_name_dict[k])}" - for k in db_keys["duplicate"] + dupe_pairs = "\n ".join( + f"{k} and {min_max_name_dict[k]}" for k in db_keys["duplicate"] ) exc_strs.extend( [ - f"You have specified fields known to both {csrc('`search`')} and {_csrc('`_search`')}", - f" {dupe_pairs}", - f"To avoid query ambiguity, please check your {csrc('`search`')} query and only specify", - f" {', '.join([csrc(k) for k in db_keys['duplicate']])}", + "You have specified fields known to both `search` and `_search`:", + f" {dupe_pairs}", + "To avoid query ambiguity, please check your `search` query and only specify:", + f" {', '.join(db_keys['duplicate'])}", ] ) # Throw an exception if any unknown kwargs were input if db_keys["unknown"]: exc_strs.extend( [ - f"You have specified the following kwargs which are unknown to {csrc('`search`')}, " - f"but may be known to {_csrc('`_search`')}", - f" \x1b[36m{', '.join(db_keys['unknown'])}\x1b[39m", + "You have specified the following kwargs which are unknown to `search`, " + "but may be known to `_search`:", + f" {', '.join(db_keys['unknown'])}", ] ) # Always print links to documentation on warning / exception warn_ref_strs = [ "Please see the documentation:", - f" {csrc('`search`: https://materialsproject.github.io/api/_autosummary/mp_api.client.routes.materials.summary.SummaryRester.html#mp_api.client.routes.materials.summary.SummaryRester.search')}", - f" {_csrc('`_search`: https://api.materialsproject.org/redoc#tag/Materials-Summary/operation/search_materials_summary__get')}", + " `search`: https://materialsproject.github.io/api/_autosummary/mp_api.client.routes.materials.summary.SummaryRester.html#mp_api.client.routes.materials.summary.SummaryRester.search", + " `_search`: https://api.materialsproject.org/redoc#tag/Materials-Summary/operation/search_materials_summary__get", ] if exc_strs: diff --git a/mp_api/mcp/utils.py b/mp_api/mcp/utils.py index f3601e58..158d6bfe 100644 --- a/mp_api/mcp/utils.py +++ b/mp_api/mcp/utils.py @@ -12,6 +12,8 @@ _REQUIRED_CLIENT_KWARGS = { "use_document_model": False, "include_user_agent": True, + # stdio transport: never draw progress bars (they go to stderr regardless) + "mute_progress_bars": True, } diff --git a/pyproject.toml b/pyproject.toml index f6188bd1..a663eabc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,10 +48,12 @@ dependencies = [ "orjson >= 3.10,<4", "pyarrow >= 20.0.0", "deltalake>=1.4.0,<1.6.0", + "rich>=13.0", ] [project.optional-dependencies] mcp = ["fastmcp"] +notebook = ["ipywidgets"] server = ["flask"] contribs = [ "boltons", @@ -71,6 +73,7 @@ all = [ "emmet-core[all]>=0.87.3rc2,<0.87.4", "fastmcp", "flask", + "ipywidgets", ] test = [ "pre-commit", @@ -82,7 +85,6 @@ test = [ "mypy", "types-setuptools", "types-requests", - "types-tqdm" ] docs = ["sphinx"] diff --git a/tests/client/contribs/test_side_effects.py b/tests/client/contribs/test_side_effects.py new file mode 100644 index 00000000..209e1222 --- /dev/null +++ b/tests/client/contribs/test_side_effects.py @@ -0,0 +1,124 @@ +"""Importing / using contribs must not change process-wide state.""" + +import importlib +import logging +import subprocess +import sys +import warnings + +import pytest +from swagger_spec_validator.common import SwaggerValidationError + +from mp_api.client.core.exceptions import MPContribsClientError + + +def test_import_has_no_global_side_effects(): + """Checked in a fresh interpreter, so earlier imports can't mask anything.""" + code = """ +import logging, warnings +import pandas as pd +import plotly.io as pio + +fmt = warnings.formatwarning +backend = pd.options.plotting.backend +template = pio.templates.default + +import mp_api.client.contribs.client # noqa + +assert warnings.formatwarning is fmt, "warnings.formatwarning changed" +assert pd.options.plotting.backend == backend, "pandas plotting backend changed" +assert pio.templates.default == template, "plotly template changed" +assert logging.getLogger("mp_api.client.contribs").handlers == [], "handlers added" +print("ok") +""" + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, timeout=120 + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "ok" + + +@pytest.mark.parametrize( + "func, arg", + [ + ("validate_email", "not-an-email"), + ("validate_email", "nope:someone@example.com"), + ("validate_url", "example"), + ], +) +def test_validation_errors_are_backwards_compatible(func, arg): + client = importlib.import_module("mp_api.client.contribs.client") + from mp_api.client.contribs.utils import MPContribsValidationError + + with pytest.raises(MPContribsValidationError) as excinfo: + getattr(client, func)(arg) + assert isinstance(excinfo.value, SwaggerValidationError) + assert isinstance(excinfo.value, MPContribsClientError) + + +def test_short_traceback_hook(): + err = MPContribsClientError("Invalid API key: abc") + assert err._render_traceback_() == ["MPContribsClientError: Invalid API key: abc"] + + +def test_ipython_showtraceback_not_patched(): + pytest.importorskip("IPython") + from IPython.core.interactiveshell import InteractiveShell + + original = InteractiveShell.showtraceback + importlib.reload(importlib.import_module("mp_api.client.contribs.utils")) + assert InteractiveShell.showtraceback is original + + +def test_contribs_logger_has_no_handlers_and_propagates(): + from mp_api.client.contribs._logger import MPCC_LOGGER + + assert MPCC_LOGGER.logger.handlers == [] + assert MPCC_LOGGER.logger.propagate + + +def test_mute_progress_bars_forwarded(monkeypatch): + from mp_api.client import MPRester + from mp_api.client.contribs import client as contribs_client + + captured = {} + + class FakeContribs: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(contribs_client, "ContribsClient", FakeContribs) + monkeypatch.setattr( + MPRester, "_get_heartbeat_info", staticmethod(lambda endpoint: ("v", [])) + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + mpr = MPRester(api_key="a" * 32, mute_progress_bars=True) + assert isinstance(mpr.contribs, FakeContribs) + assert captured["mute_progress_bars"] is True + + +def test_run_futures_progress_respects_disable(monkeypatch): + from mp_api.client.contribs import client as contribs_client + + seen = {} + real = contribs_client.progress_bar + + def spy(*args, **kwargs): + seen.update(kwargs) + return real(*args, **kwargs) + + monkeypatch.setattr(contribs_client, "progress_bar", spy) + contribs_client._run_futures([], disable=True) + assert seen["enabled"] is False and seen["delay"] == 5 + contribs_client._run_futures([], disable=False, desc="Submit") + assert seen["enabled"] is True + + +def test_logging_does_not_go_to_stdout(capsys): + from mp_api.client.contribs._logger import MPCC_LOGGER + + logging.getLogger().handlers # pytest attaches capture handlers to root + MPCC_LOGGER.info("Nothing to submit.") + out, _ = capsys.readouterr() + assert out == "" diff --git a/tests/client/core/test_client_progress.py b/tests/client/core/test_client_progress.py new file mode 100644 index 00000000..a0150aaf --- /dev/null +++ b/tests/client/core/test_client_progress.py @@ -0,0 +1,179 @@ +"""Progress bar behaviour of the core client, without network access.""" + +import io +import re + +import pytest +from rich.console import Console + +import mp_api.client.core._display as display +from mp_api.client.core.client import BaseRester +from mp_api.client.core.exceptions import MPRestError + + +def plain(text: str) -> str: + return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text) + + +@pytest.fixture +def term(): + buf = io.StringIO() + display.set_console( + Console(file=buf, force_terminal=True, force_jupyter=False, width=120) + ) + yield buf + display.set_console(None) + + +@pytest.fixture +def rester(monkeypatch): + monkeypatch.setattr( + BaseRester, "_get_heartbeat_info", staticmethod(lambda endpoint: ("v", [])) + ) + return BaseRester(api_key="a" * 32) + + +def fake_pages(total: int, fail_on_call: int | None = None): + """Stand-in for _submit_request_and_process serving `total` docs.""" + calls = {"n": 0} + + def submit(url, verify, params, use_document_model, timeout=None): + calls["n"] += 1 + if fail_on_call is not None and calls["n"] == fail_on_call: + raise MPRestError("REST query timed out") + limit = params.get("_limit", 10) + skip = params.get("_skip", 0) + n = max(0, min(limit, total - skip)) + return {"data": [{"i": skip + j} for j in range(n)], "meta": {}}, total + + return submit, calls + + +def test_pagination_shows_bar_and_summary(rester, term, monkeypatch): + submit, calls = fake_pages(25) + monkeypatch.setattr(rester, "_submit_request_and_process", submit) + + out = rester._submit_requests( + url="u", criteria={"_limit": 10}, use_document_model=False, chunk_size=10 + ) + + assert len(out["data"]) == 25 and calls["n"] == 3 + assert "✓ Retrieved 25 documents in" in plain(term.getvalue()) + assert display._STATE.progress is None + + +def test_bar_starts_before_first_request(rester, term, monkeypatch): + """The spinner is visible while the first (often slowest) request runs.""" + submit, _ = fake_pages(5) + + def spy(*args, **kwargs): + assert display._STATE.active == 1 + assert display._STATE.progress.tasks[0].total is None # not known yet + return submit(*args, **kwargs) + + monkeypatch.setattr(rester, "_submit_request_and_process", spy) + rester._submit_requests( + url="u", criteria={"_limit": 10}, use_document_model=False, chunk_size=10 + ) + assert "✓ Retrieved 5 documents in" in plain(term.getvalue()) + + +def test_empty_result_has_no_summary(rester, term, monkeypatch): + submit, _ = fake_pages(0) + monkeypatch.setattr(rester, "_submit_request_and_process", submit) + rester._submit_requests( + url="u", criteria={"_limit": 10}, use_document_model=False, chunk_size=10 + ) + assert "Retrieved" not in plain(term.getvalue()) + assert display._STATE.progress is None + + +def test_bar_closed_when_first_request_fails(rester, term, monkeypatch): + submit, _ = fake_pages(25, fail_on_call=1) + monkeypatch.setattr(rester, "_submit_request_and_process", submit) + with pytest.raises(MPRestError): + rester._submit_requests( + url="u", criteria={"_limit": 10}, use_document_model=False, chunk_size=10 + ) + assert display._STATE.progress is None and display._STATE.active == 0 + + +def test_show_progress_false_and_mute_are_silent(rester, term, monkeypatch): + submit, _ = fake_pages(25) + monkeypatch.setattr(rester, "_submit_request_and_process", submit) + kwargs = dict( + url="u", criteria={"_limit": 10}, use_document_model=False, chunk_size=10 + ) + + rester._submit_requests(**kwargs, show_progress=False) + assert term.getvalue() == "" + + rester.mute_progress_bars = True + rester._submit_requests(**kwargs) + assert term.getvalue() == "" + + # explicit True overrides the instance setting + rester._submit_requests(**kwargs, show_progress=True) + assert "Retrieved 25" in plain(term.getvalue()) + + +def test_bar_closed_when_page_request_fails(rester, term, monkeypatch): + submit, _ = fake_pages(25, fail_on_call=2) + monkeypatch.setattr(rester, "_submit_request_and_process", submit) + + with pytest.raises(MPRestError, match="timed out"): + rester._submit_requests( + url="u", criteria={"_limit": 10}, use_document_model=False, chunk_size=10 + ) + + assert display._STATE.progress is None + assert display._STATE.active == 0 + assert "Retrieved" not in term.getvalue() + + +def test_split_batches_nest_without_error(rester, term, monkeypatch): + """A 414 splits the query into batches whose pagination bars nest under it.""" + values = [f"mp-{i}" for i in range(250)] + + def submit(url, verify, params, use_document_model, timeout=None): + ids = params["material_ids"].split(",") + if len(ids) > 100: + raise MPRestError("REST query returned with error status code 414") + skip, limit = params.get("_skip", 0), params.get("_limit", 50) + page = ids[skip : skip + limit] + return { + "data": [{"id": i} for i in page], + "meta": {"total_doc": len(ids)}, + }, len(ids) + + monkeypatch.setattr(rester, "_submit_request_and_process", submit) + out = rester._submit_requests( + url="u", + criteria={"material_ids": ",".join(values), "_limit": 50}, + use_document_model=False, + chunk_size=50, + ) + + assert len(out["data"]) == 250 + text = plain(term.getvalue()) + # one summary, for the outer batching bar; total is ceil(250/100) = 3 + assert "✓ Retrieved 250 material_ids values in 3 batches" in text + assert "Retrieved 100 documents" not in text + assert display._STATE.progress is None + + +def test_count_failure_does_not_leave_rester_muted(rester, monkeypatch): + def boom(*args, **kwargs): + assert kwargs["show_progress"] is False + assert kwargs["use_document_model"] is False + raise MPRestError("network down") + + monkeypatch.setattr(rester, "_query_resource", boom) + rester.mute_progress_bars = False + rester.use_document_model = True + + with pytest.raises(MPRestError): + rester.count() + + assert rester.mute_progress_bars is False + assert rester.use_document_model is True diff --git a/tests/client/core/test_delta.py b/tests/client/core/test_delta.py index a6f201d8..3677cdc4 100644 --- a/tests/client/core/test_delta.py +++ b/tests/client/core/test_delta.py @@ -503,3 +503,13 @@ def test_query_delta_single_retries_and_wraps_errors(no_heartbeat): FakeQueryBuilder.failures = [DeltaError("request timed out")] with pytest.raises(MPRestError, match="increasing the 'timeout'"): rester._query_delta_single("SELECT 1", label=lbl) + + +def test_execute_stream_returns_pyarrow_reader(catalog): + """Streaming must go through pyarrow, which releases the GIL between batches + (arro3 iteration holds it, freezing e.g. the progress bar refresh thread).""" + reader = catalog.execute_stream("SELECT * FROM a") + assert isinstance(reader, pa.RecordBatchReader) + batches = list(reader) + assert sum(b.num_rows for b in batches) == 1 + assert all(isinstance(b, pa.RecordBatch) for b in batches) diff --git a/tests/client/core/test_display.py b/tests/client/core/test_display.py new file mode 100644 index 00000000..69ae2894 --- /dev/null +++ b/tests/client/core/test_display.py @@ -0,0 +1,351 @@ +"""Tests for progress bars, the shared console, and default logging. + +Uses consoles writing to StringIO; no network access or API key needed. +""" + +import io +import re +import logging +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest +from rich.console import Console + +import mp_api.client.core._display as display +from mp_api.client.core._display import ( + MP_THEME, + disable_logging, + enable_logging, + get_console, + print_notice, + progress_bar, + set_console, +) + + +def _console(terminal: bool = True) -> tuple[Console, io.StringIO]: + buf = io.StringIO() + console = Console( + file=buf, + force_terminal=terminal, + force_jupyter=False, + theme=MP_THEME, + width=120, + color_system="truecolor" if terminal else None, + ) + return console, buf + + +@pytest.fixture +def term(): + console, buf = _console(terminal=True) + set_console(console) + yield buf + set_console(None) + + +@pytest.fixture +def pipe(): + console, buf = _console(terminal=False) + set_console(console) + yield buf + set_console(None) + + +@pytest.fixture +def no_root_handlers(monkeypatch): + """Simulate an application that hasn't configured logging. + + (pytest's logging plugin attaches handlers to the root logger during each + test, which the default handler would correctly defer to.) + """ + monkeypatch.setattr(display, "_app_handlers", lambda logger: []) + enable_logging() + yield + enable_logging() + + +def plain(text: str) -> str: + return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text) + + +# --- console ------------------------------------------------------------------- + + +def test_default_console_writes_to_stderr(capsys): + set_console(None) + try: + console = get_console() + assert console.stderr + print_notice("hello stderr") + out, err = capsys.readouterr() + assert out == "" + assert "hello stderr" in err + finally: + set_console(None) + + +def test_print_notice_is_not_markup(term): + print_notice("[bold]literal[/bold]") + assert "[bold]literal[/bold]" in term.getvalue() + + +def test_theme_colours(): + styles = MP_THEME.styles + assert str(styles["mp.brand"].color.triplet.hex) == "#00bdad" + assert str(styles["mp.warning"].color.triplet.hex) == "#d69e00" + + +# --- progress bars ------------------------------------------------------------- + + +def test_disabled_bar_is_silent(term): + with progress_bar("x", total=3, enabled=False, summary="Done {completed}") as bar: + bar.update(3) + assert term.getvalue() == "" + assert bar.completed == 3 + + +def test_non_interactive_console_draws_nothing(pipe): + with progress_bar("x", total=3, summary="Done {completed}") as bar: + bar.update(3) + assert pipe.getvalue() == "" + assert display._STATE.progress is None + + +def test_summary_printed_for_completed_bar(term): + with progress_bar( + "Fetching", total=5, summary="Retrieved {completed:,} docs" + ) as bar: + bar.update(5) + out = plain(term.getvalue()) + assert "✓ Retrieved 5 docs in" in out + assert display._STATE.progress is None + assert display._STATE.active == 0 + + +def test_nested_bars_share_display_and_only_outer_summarizes(term): + with progress_bar("outer", total=2, summary="outer done") as outer: + assert display._STATE.active == 1 + progress = display._STATE.progress + for _ in range(2): + with progress_bar("inner", total=1, summary="inner done") as inner: + # no LiveError: same Progress, extra task + assert display._STATE.progress is progress + assert display._STATE.active == 2 + inner.update(1) + outer.update(1) + out = term.getvalue() + assert "outer done" in out + assert "inner done" not in out + assert display._STATE.progress is None + + +def test_bar_cleaned_up_on_exception(term): + with pytest.raises(RuntimeError), progress_bar("x", total=2, summary="nope") as bar: + bar.update(1) + raise RuntimeError("boom") + assert display._STATE.progress is None + assert display._STATE.active == 0 + assert "nope" not in term.getvalue() + assert display._STATE.depth() == 0 + + +def test_delay_hides_quick_operations(term): + with progress_bar("x", total=1, delay=60, summary="hidden") as bar: + bar.update(1) + assert not bar.shown + task = display._STATE.progress.tasks[0] + assert not task.visible + assert "hidden" not in term.getvalue() + + +def test_delay_shows_after_elapsed(term, monkeypatch): + with progress_bar("x", total=2, delay=5, summary="shown") as bar: + monkeypatch.setattr(type(bar), "elapsed", property(lambda self: 10.0)) + bar.update(1) + assert bar.shown + assert display._STATE.progress.tasks[0].visible + assert "shown" in term.getvalue() + + +def test_total_can_be_changed(term): + with progress_bar("x", total=None) as bar: + bar.total = 10 + assert display._STATE.progress.tasks[0].total == 10 + + +def test_concurrent_bars_from_threads(term): + def work(i): + with progress_bar(f"t{i}", total=10, summary=f"thread {i} done") as bar: + for _ in range(10): + bar.update(1) + return i + + with ThreadPoolExecutor(4) as pool: + assert sorted(pool.map(work, range(8))) == list(range(8)) + + out = term.getvalue() + # each thread's bar is outermost on its own thread + assert all(f"thread {i} done" in out for i in range(8)) + assert display._STATE.progress is None + assert display._STATE.active == 0 + + +def test_jupyter_without_ipywidgets_still_summarizes(monkeypatch, term): + monkeypatch.setattr(display, "_can_draw_live", lambda console: False) + with progress_bar("x", total=1, summary="nb done") as bar: + bar.update(1) + assert display._STATE.progress is None + assert "nb done" in term.getvalue() + + +# --- logging ------------------------------------------------------------------- + + +def _client_logger(): + return logging.getLogger("mp_api.client") + + +def _installed(): + return [ + h for h in _client_logger().handlers if isinstance(h, display._MPDefaultHandler) + ] + + +def test_default_handler_installed_on_import(): + import mp_api.client # noqa: F401 + + assert len(_installed()) == 1 + assert not _client_logger().propagate + + +def test_enable_logging_is_idempotent(): + enable_logging() + enable_logging("WARNING") + try: + assert len(_installed()) == 1 + assert _client_logger().level == logging.WARNING + finally: + enable_logging() + + +def test_disable_and_reenable_logging(): + disable_logging() + try: + assert _installed() == [] + assert _client_logger().propagate + assert any( + isinstance(h, logging.NullHandler) for h in _client_logger().handlers + ) + finally: + enable_logging() + assert len(_installed()) == 1 + + +def test_logging_plain_when_not_a_terminal(pipe, no_root_handlers): + logging.getLogger("mp_api.client.core.client").warning("dataset written") + out = pipe.getvalue() + assert out == "mp_api.client.core.client - WARNING - dataset written\n" + + +def test_logging_rich_in_terminal(term, no_root_handlers): + logging.getLogger("mp_api.client.core.client").info("dataset written") + out = term.getvalue() + assert "dataset written" in out + assert "INFO" in out + assert "\x1b[" in out # styled + + +def test_logging_level_respected(pipe, no_root_handlers): + enable_logging("WARNING") + logging.getLogger("mp_api.client.core.client").info("too quiet") + assert pipe.getvalue() == "" + + +def test_defers_to_application_logging(pipe, monkeypatch): + """With root handlers configured, records go there once, at the app's level.""" + root = logging.getLogger() + app_buf = io.StringIO() + app_handler = logging.StreamHandler(app_buf) + monkeypatch.setattr(root, "handlers", [app_handler]) + monkeypatch.setattr(root, "level", logging.ERROR) + enable_logging() + + log = logging.getLogger("mp_api.client.core.client") + log.warning("below app level") + log.error("app sees this") + + assert pipe.getvalue() == "" # our console printed nothing + assert app_buf.getvalue() == "app sees this\n" # once, not duplicated + + +def test_contribs_logger_uses_shared_handler(pipe, no_root_handlers, capsys): + pytest.importorskip("boltons") + from mp_api.client.contribs._logger import MPCC_LOGGER + + assert MPCC_LOGGER.logger.handlers == [] + MPCC_LOGGER.info("project created") + out, _ = capsys.readouterr() + assert out == "" # never stdout + assert "mp_api.client.contribs - INFO - project created" in pipe.getvalue() + + +def test_logging_setting_off(monkeypatch): + from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS + + disable_logging() + monkeypatch.setattr(MAPI_CLIENT_SETTINGS, "LOGGING", False) + try: + display._install_default_logging() + assert _installed() == [] + finally: + enable_logging() + + +def test_logging_from_threads_during_progress(term, no_root_handlers): + log = logging.getLogger("mp_api.client.core.delta") + with progress_bar("x", total=4, summary="done") as bar: + threads = [ + threading.Thread(target=lambda: (log.warning("refreshing"), bar.update(1))) + for _ in range(4) + ] + for t in threads: + t.start() + for t in threads: + t.join() + out = term.getvalue() + assert out.count("refreshing") == 4 + assert "done" in out + + +# --- notify_db_version --------------------------------------------------------- + + +def test_db_version_check_prints(term, monkeypatch, tmp_path): + import warnings + + from mp_api.client.core.exceptions import MPRestWarning + from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS + from mp_api.client.mprester import MPRester + + log_file = tmp_path / "mprester.log.yaml" + monkeypatch.setattr(MAPI_CLIENT_SETTINGS, "LOG_FILE", log_file) + + class Fake: + db_version = "2026.01.01" + + MPRester._db_version_check(Fake()) # first run: just the version + assert "Materials Project database version: v2026.01.01" in plain(term.getvalue()) + + Fake.db_version = "2026.02.01" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + MPRester._db_version_check(Fake()) + assert ( + "Materials Project database version changed: v2026.01.01 → v2026.02.01" + in plain(term.getvalue()) + ) + assert any(issubclass(w.category, MPRestWarning) for w in caught) + assert "2026.02.01" in log_file.read_text() diff --git a/tests/client/core/test_schemas.py b/tests/client/core/test_schemas.py index 55018a71..04468cea 100644 --- a/tests/client/core/test_schemas.py +++ b/tests/client/core/test_schemas.py @@ -108,3 +108,33 @@ def test_model_generation(): # Ensure graceful handling of empty iterator input (no docs returned) assert _convert_to_model(iter([]), DummyClass) == [] + + +def test_returned_model_repr_is_plain_and_rich_is_styled(): + import io + + from rich.console import Console + + from mp_api.client.core.schemas import _generate_returned_model + + class Doc(BaseModel): + material_id: str | None = None + band_gap: float | None = None + formula: str | None = None + + model, _, _ = _generate_returned_model( + {"material_id": "mp-149", "band_gap": 1.1}, Doc + ) + doc = model(material_id="mp-149", band_gap=1.1) + + for text in (repr(doc), str(doc)): + assert "\x1b[" not in text + assert "MPDataDoc" in text and "band_gap=1.1" in text + assert "fields_not_requested=['formula']" in repr(doc) + assert "Fields not requested:" in str(doc) + + buf = io.StringIO() + Console(file=buf, force_terminal=True, width=100).print(doc) + out = buf.getvalue() + assert "\x1b[1;4mMPDataDoc" in out # bold underline title + assert "material_id" in out and "'mp-149'" in out diff --git a/tests/client/materials/test_summary.py b/tests/client/materials/test_summary.py index 7869cb2b..cb286472 100644 --- a/tests/client/materials/test_summary.py +++ b/tests/client/materials/test_summary.py @@ -162,3 +162,29 @@ def test_pagination(): def test_sort(sort_field: str): with SummaryRester() as rester: client_sort(rester.search, sort_field, aux_query={sort_field: (0, 10)}) + + +def test_search_field_messages_have_no_ansi_codes(monkeypatch): + """Warning / error text must be plain: it ends up in logs and servers.""" + import warnings + + from mp_api.client.routes.materials.summary import SummaryRester + + monkeypatch.setattr( + SummaryRester, "_get_heartbeat_info", staticmethod(lambda endpoint: ("v", [])) + ) + from mp_api.client.core.client import BaseRester + + monkeypatch.setattr(BaseRester, "_search", lambda self, **kwargs: []) + rester = SummaryRester(api_key="a" * 32) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + rester.search(nelements=10) + messages = [str(w.message) for w in caught if "_search" in str(w.message)] + assert messages and all("\x1b" not in m for m in messages) + + for kwargs in ({"nelements": 10, "num_elements": 11}, {"apples": "oranges"}): + with pytest.raises(MPRestError) as excinfo: + rester.search(**kwargs) + assert "\x1b" not in str(excinfo.value) From 931d214f58eacb7cedaab2ed2eeeac63746117b6 Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:51:06 -0700 Subject: [PATCH 11/30] enable retrieving versioned datasets + add missing static collections that are now delta-backed --- mp_api/client/core/_display.py | 78 +- mp_api/client/core/client.py | 697 ++++++++++---- mp_api/client/core/delta.py | 36 +- mp_api/client/core/utils.py | 90 +- mp_api/client/mprester.py | 129 ++- mp_api/client/routes/materials/absorption.py | 5 + mp_api/client/routes/materials/alloys.py | 5 + mp_api/client/routes/materials/bonds.py | 5 + mp_api/client/routes/materials/chemenv.py | 5 + mp_api/client/routes/materials/dielectric.py | 5 + mp_api/client/routes/materials/elasticity.py | 5 + mp_api/client/routes/materials/electrodes.py | 6 +- .../routes/materials/electronic_structure.py | 5 + .../routes/materials/grain_boundaries.py | 1 - mp_api/client/routes/materials/magnetism.py | 5 + mp_api/client/routes/materials/materials.py | 5 + .../routes/materials/oxidation_states.py | 5 + mp_api/client/routes/materials/piezo.py | 5 + mp_api/client/routes/materials/provenance.py | 5 + mp_api/client/routes/materials/robocrys.py | 5 + mp_api/client/routes/materials/summary.py | 6 + .../routes/materials/surface_properties.py | 1 - mp_api/client/routes/materials/synthesis.py | 12 +- mp_api/client/routes/materials/thermo.py | 22 +- mp_api/client/routes/materials/xas.py | 1 - tests/client/core/test_delta_versions.py | 902 ++++++++++++++++++ 26 files changed, 1856 insertions(+), 190 deletions(-) create mode 100644 tests/client/core/test_delta_versions.py diff --git a/mp_api/client/core/_display.py b/mp_api/client/core/_display.py index 2f14d928..575911f1 100644 --- a/mp_api/client/core/_display.py +++ b/mp_api/client/core/_display.py @@ -48,13 +48,15 @@ TimeElapsedColumn, TimeRemainingColumn, ) +from rich.spinner import Spinner from rich.table import Column from rich.text import Text from rich.theme import Theme if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator + from rich.console import RenderableType from rich.progress import Task __all__ = [ @@ -65,6 +67,7 @@ "print_notice", "progress_bar", "set_console", + "status", ] LOGGER_NAME = "mp_api.client" @@ -173,6 +176,32 @@ def render(self, task: Task) -> Text: return Text(f"{speed:.0f} {unit}/s", style="mp.secondary") +class _MPProgress(Progress): + """Progress that draws status tasks (see `status`) as full-width lines. + + Status lines are shown above the bars, so long messages aren't cut to + the width of the bar table's description column. + """ + + _spinner = Spinner("dots", style="progress.spinner") + + def get_renderables(self) -> Iterable[RenderableType]: + statuses = [t for t in self.tasks if t.fields.get("status") and t.visible] + bars = [t for t in self.tasks if not t.fields.get("status")] + for task in statuses: + spinner = self._spinner.render(self.get_time()) + yield Text.assemble( + spinner if isinstance(spinner, Text) else Text(str(spinner)), + " ", + Text(task.description, style="mp.warning"), + " ", + Text(f"({int(task.elapsed or 0)}s)", style="progress.elapsed"), + overflow="fold", + ) + if bars: + yield self.make_tasks_table(bars) + + class _ProgressState: """The process-wide Progress and its reference count. Guarded by `lock`.""" @@ -188,7 +217,7 @@ def depth(self) -> int: def acquire(self, console: Console) -> Progress: with self.lock: if self.progress is None: - self.progress = Progress( + self.progress = _MPProgress( SpinnerColumn(), # The description is truncated (with an ellipsis) to leave # room for the numbers on narrow terminals. @@ -258,6 +287,16 @@ def total(self, value: float | None) -> None: def elapsed(self) -> float: return time.monotonic() - self._start + def hide(self) -> None: + """Remove the bar from the display, e.g. before a final slow step. + + The summary (if any) is still printed when the `progress_bar` + block exits, and later updates are counted but not drawn. + """ + if self._progress is not None and self._task_id is not None: + self._progress.update(self._task_id, visible=False) + self._delay = float("inf") # don't show it again on update + def update(self, advance: float = 1) -> None: """Advance the bar by `advance` units.""" self.completed += advance @@ -349,6 +388,41 @@ def progress_bar( _PLAIN_FORMAT = "%(name)s - %(levelname)s - %(message)s" +@contextmanager +def status( + message: str, + *, + enabled: bool = True, + logger: logging.Logger | None = None, +) -> Iterator[None]: + """Show a transient status line while a slow step runs. + + In a terminal or notebook with a live display, an amber spinner line is + shown and removed when the block exits. Otherwise (logs, CI, or + `enabled=False`), `message` is logged once as a warning via `logger`, + since a log line can't be taken back. + + Args: + message (str) : what is happening, e.g. "Counting documents..." + enabled (bool) : if False, skip the spinner and only log + logger (logging.Logger or None) : logger for the fallback warning. + Nothing is logged if None. + """ + console = get_console() + if not (enabled and _is_interactive(console) and _can_draw_live(console)): + if logger is not None: + logger.warning(message) + yield + return + + progress = _STATE.acquire(console) + task_id = progress.add_task(message, total=None, status=True) + try: + yield + finally: + _STATE.release(task_id) + + def _app_handlers(logger: logging.Logger) -> list[logging.Handler]: """Handlers the application attached to ancestors of `logger`.""" handlers: list[logging.Handler] = [] diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index 8362ad6c..1da5b289 100644 --- a/mp_api/client/core/client.py +++ b/mp_api/client/core/client.py @@ -8,11 +8,14 @@ import gzip import inspect import itertools +import json import logging import os import platform import shutil import sys +import time +import uuid import warnings from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from contextlib import ExitStack @@ -25,7 +28,7 @@ from math import ceil from pathlib import Path from typing import TYPE_CHECKING -from urllib.parse import urljoin +from urllib.parse import quote, unquote, urljoin import boto3 import pyarrow as pa @@ -34,7 +37,8 @@ from botocore import UNSIGNED from botocore.config import Config from botocore.exceptions import ClientError -from deltalake import DeltaTable, QueryBuilder, Schema, convert_to_deltalake +from deltalake import DeltaTable, QueryBuilder, Schema +from deltalake.transaction import AddAction, create_table_with_add_actions from emmet.core.arrow import arrowize from emmet.core.utils import jsanitize from pydantic import BaseModel @@ -43,7 +47,7 @@ from urllib3.util.retry import Retry from mp_api.client._server_utils import get_consumer, get_user_api_key, is_dev_env -from mp_api.client.core._display import ProgressHandle, progress_bar +from mp_api.client.core._display import ProgressHandle, progress_bar, status from mp_api.client.core.delta import DeltaCatalog from mp_api.client.core.exceptions import ( MPRestError, @@ -55,12 +59,14 @@ from mp_api.client.core.utils import ( MPDataset, load_json, + to_db_version, + to_partition_version, validate_endpoint, validate_ids, ) if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Iterable from typing import Any from arro3.core import RecordBatchReader @@ -74,7 +80,7 @@ STATIC_COLLECTIONS = [ "eos", - "grain_boundaries", + "grain-boundaries", "jcesr", "molecules", "phonon", @@ -83,6 +89,12 @@ "synth-descriptions", "xas", ] +# Routes whose S3 dataset name differs from the route name +S3_COLLECTION_NAMES = { + "molecules/summary": "molecules", + "molecules/jcesr": "jcesr", + "materials/synthesis": "synth-descriptions", +} CONTROLLED_COLLECTIONS = [ "chemenv", "materials", @@ -94,6 +106,20 @@ logger = logging.getLogger(__name__) +LATEST_DB_VERSION = "latest" + + +def _normalize_db_version(db_version: str | None) -> str: + """Normalize a user-given database version, e.g. "2026-04-13" -> "2026.04.13". + + "latest" (any case) is kept as "latest", and None becomes "". + """ + if not db_version: + return "" + if db_version.strip().lower() == LATEST_DB_VERSION: + return LATEST_DB_VERSION + return to_db_version(db_version) + class QueryBuilderWithCache(QueryBuilder): def __init__(self, catalog: DeltaCatalog | None = None, _warn: bool = True) -> None: @@ -181,9 +207,10 @@ def __init__( advanced usage only. headers: Custom headers for localhost connections. mute_progress_bars: Whether to disable progress bars. - db_version (str) : EXPERIMENTAL, allows for accessing a different version of the database - than what is currently deployed. The Materials Project cannot guarantee that all - features will still work. + db_version (str) : Database version to use for data read from S3 (full dataset + downloads, phase diagrams), e.g. "2026.04.13". Defaults to the version + currently served by the API. REST queries always use the current database. + See `available_db_versions()` on a rester for the versions available. local_dataset_cache: Target directory for downloading full datasets. Defaults to 'mp_datasets' in the user's home directory force_renew: Option to overwrite existing local dataset @@ -213,7 +240,7 @@ def __init__( self.use_document_model = use_document_model self.mute_progress_bars = mute_progress_bars - self.db_version: str = db_version or "" + self.db_version: str = _normalize_db_version(db_version) self.local_dataset_cache = Path(local_dataset_cache) self.force_renew = force_renew self._query_builder = ( @@ -403,9 +430,10 @@ def __init__( and will not give auto-complete for available fields. headers: Custom headers for localhost connections. mute_progress_bars: Whether to disable progress bars. - db_version (str) : EXPERIMENTAL, allows for accessing a different version of the database - than what is currently deployed. The Materials Project cannot guarantee that all - features will still work. + db_version (str) : Database version to use for data read from S3 (full dataset + downloads, phase diagrams), e.g. "2026.04.13". Defaults to the version + currently served by the API. REST queries always use the current database. + See `available_db_versions()` on a rester for the versions available. local_dataset_cache: Target directory for downloading full datasets. Defaults to 'mp_datasets' in the user's home directory force_renew: Option to overwrite existing local dataset @@ -440,6 +468,8 @@ def __init__( hb_db_version, self.access_controlled_batch_ids, ) = self._get_heartbeat_info(self.base_endpoint) + # The version the REST API serves, may differ from self.db_version + self.current_db_version: str = hb_db_version if not self.db_version: self.db_version = hb_db_version @@ -729,30 +759,230 @@ def _query_delta_single(self, query: str, label: str | None = None) -> pa.Table: ) raise MPRestError(f"Failed to retrieve object due to: {e}. {hint}") from e + def _s3_location(self) -> tuple[str, str, str]: + """Where this rester's full dataset lives on S3. + + Returns: + tuple of the collection name (e.g. "chemenv"), the bucket + (e.g. "materialsproject-build") and the prefix + (e.g. "collections/chemenv") + """ + if self.suffix in S3_COLLECTION_NAMES: + suffix = S3_COLLECTION_NAMES[self.suffix] + elif "/" not in self.suffix: + suffix = self.suffix + else: + infix, suffix = self.suffix.split("/", 1) + suffix = infix if suffix == "core" else suffix + suffix = suffix.replace("_", "-") + + if "tasks" in suffix: + bucket_suffix, prefix = ("parsed", "core/tasks") + elif suffix in STATIC_COLLECTIONS: + bucket_suffix = "build" + prefix = f"static-collections/{suffix}" + else: + # TODO: remove once all collections are migrated to delta-backed format + bucket_suffix = "build" + prefix = f"collections/{suffix}" + + return suffix, f"materialsproject-{bucket_suffix}", prefix + + def available_db_versions(self) -> list[str]: + """Database versions available for this collection's full dataset on S3. + + Read from the DeltaTable's log, no data is downloaded. Any of these + can be pinned with `MPRester(db_version=...)` to download that + version of the dataset. + + Returns: + list of str : sorted database versions, e.g. ["2026.04.13", "2026.09.28"] + + Raises: + MPRestError: if this collection isn't delta-backed, or its + dataset isn't partitioned by database version. + """ + if not self.delta_backed: + raise MPRestError( + f"{self.suffix} is not backed by a DeltaTable on S3, " + "so it has no database versions to list." + ) + _, bucket, prefix = self._s3_location() + label, _ = self._get_delta_table(bucket, prefix, refresh=True) + counts = self.delta_catalog.partition_row_counts(label) + if counts is None: + raise MPRestError( + f"The {self.suffix} dataset is not partitioned by database version, " + "it always contains the latest data." + ) + return sorted(to_db_version(v) for v in counts) + + @staticmethod + def _resolve_db_version( + requested: str, available: dict[str, int] | Iterable[str], collection: str + ) -> str: + """Turn a requested database version into a partition value of a table. + + Args: + requested (str) : normalized database version, or "latest" + available (dict or iterable of str) : partition values in the table + collection (str) : collection name, for messages + + Returns: + str : partition value, e.g. "2026-04-13" + + Raises: + MPRestError: if no version is requested, the table has no versions, + or the requested version isn't in the table. + """ + versions = sorted(available) + if not requested: + raise MPRestError( + f"The {collection} dataset is partitioned by database version, " + "but no database version is set. Pass `db_version` to MPRester." + ) + if not versions: + raise MPRestError(f"The {collection} dataset has no published versions.") + if requested == LATEST_DB_VERSION: + # Versions are dates (with optional -postN suffixes), so they sort as text + return versions[-1] + version = to_partition_version(requested) + if version not in versions: + raise MPRestError( + f"Database version {to_db_version(version)} is not available " + f"for the {collection} dataset. Available versions: " + f"{', '.join(to_db_version(v) for v in versions)}." + ) + return version + + def _check_rest_db_version(self, db_version: str) -> None: + """Raise if a per-call db_version can't be served by the REST API. + + Filtered queries go to the REST API, which only serves the current + database version. + + Args: + db_version (str) : database version requested for this call + + Raises: + MPRestError: if `db_version` isn't the version the API serves + """ + requested = _normalize_db_version(db_version) + current = self.current_db_version + if requested == LATEST_DB_VERSION: + # Compare with the newest version on S3 + try: + requested = self.available_db_versions()[-1] + except Exception: + return # single-version dataset: "latest" is the current data + if current and requested != current: + raise MPRestError( + f"db_version={db_version!r} only applies to full dataset downloads " + "(a search with no filters). Filtered queries are answered by the " + f"API, which serves database version {current}. Remove the filters " + "to download that version, then filter it locally." + ) + def _query_delta_backed( self, bucket: str, prefix: str, access_controlled: bool = True, - versioned: bool = False, timeout: int | None = None, label: str | None = None, + db_version: str | None = None, ) -> dict[str, Any]: - """Retrieve data from S3 backed by a DeltaTable. + """Download a full dataset from a DeltaTable on S3 into the local cache. + + Tables partitioned by `version` hold one partition per database + build. Only the partition for `db_version` (default `self.db_version`) + is downloaded, and it + is added to a local DeltaTable with the same partitioning, so several + versions can be kept side by side. If the local table already has + that version, it is returned without any network access, unless + `self.force_renew` is set, in which case only that version is + downloaded again and replaced. + + Data is written in chunks (see `DATASET_FLUSH_THRESHOLD`) to bound + memory, and the chunks are committed to the local table in one + transaction at the end, so an interrupted download never leaves a + partial version visible. Args: bucket (str) : S3 OpenData bucket prefix (str) : S3 object prefix access_controlled (bool): whether or not table has access controlled data - versioned (bool): whether or not table is partitioned on db version timeout (int or None) : timeout on getting access-controlled groups label (str or None) : label of the table in QueryBuilder + db_version (str or None) : database version to download for this + call, e.g. "2026.04.13" or "latest". Defaults to `self.db_version`. Returns: dict of str to Any """ + override = _normalize_db_version(db_version) + requested = override or self.db_version # just in case prefix = prefix.rstrip("/") + collection = prefix.split("/")[-1] + + target_path = str( + self.local_dataset_cache.joinpath( + f"{bucket.split('materialsproject-')[1]}/{prefix}" + ) + ) + + # Load the remote table to learn its partitioning and versions. + # Full downloads are one-off, always start from the latest snapshot. + tbl_lbl, tbl = self._get_delta_table(bucket, prefix, label=label, refresh=True) + version_counts = self.delta_catalog.partition_row_counts(tbl_lbl) + versioned = version_counts is not None + + version: str | None = None + if versioned: + version = self._resolve_db_version( + requested, version_counts, collection # type: ignore[arg-type] + ) + elif override: + logger.warning( + f"The {collection} dataset has a single version, ignoring " + f"db_version={db_version!r}." + ) + + def _dataset() -> dict[str, Any]: + return { + "data": MPDataset( + path=target_path, + document_model=self.document_model, + use_document_model=self.use_document_model, + version=version, + ) + } + + version_note = f" (v{to_db_version(version)})" if version else "" + local = self._local_delta_table(target_path, versioned, collection) + if local is not None and not self.force_renew: + local_versions = { + p.get("version") for p in local.partitions() if p.get("version") + } + if not versioned or version in local_versions: + logger.warning( + f"Dataset for {collection}{version_note} already exists at " + f"{target_path}, returning existing dataset." + ) + logger.info( + "Delete or move existing dataset or re-run search query with " + "MPRester(force_renew=True) to refresh local dataset.", + ) + return _dataset() + logger.info( + f"Adding {collection}{version_note} to the existing local dataset at " + f"{target_path}." + ) + elif local is not None: + logger.warning( + f"Regenerating {collection}{version_note} at {target_path}..." + ) # Check if user has access to GNoMe has_gnome_access = bool( @@ -772,164 +1002,308 @@ def _query_delta_backed( .get("total_doc", 0) ) - suffix = prefix.rsplit("/")[1] - - target_path = str( - self.local_dataset_cache.joinpath( - f"{bucket.split('materialsproject-')[1]}/{prefix}" - ) - ) - os.makedirs(target_path, exist_ok=True) - - if DeltaTable.is_deltatable(target_path): - if self.force_renew: - shutil.rmtree(target_path) - logger.warning(f"Regenerating {suffix} dataset at {target_path}...") - os.makedirs(target_path, exist_ok=True) - else: - logger.warning( - f"Dataset for {suffix} already exists at {target_path}, returning existing dataset." - ) - logger.info( - "Delete or move existing dataset or re-run search query with MPRester(force_renew=True) " - "to refresh local dataset.", + conditions = [] + if versioned: + conditions.append(f"version = '{version}'") + filter_access = access_controlled and not has_gnome_access + if filter_access: + if collection == "tasks": + controlled_batch_str = ",".join( + [f"'{tag}'" for tag in self.access_controlled_batch_ids] ) - - return { - "data": MPDataset( - path=target_path, - document_model=self.document_model, - use_document_model=self.use_document_model, - ) - } - - # Full downloads are one-off, always start from the latest snapshot - tbl_lbl, tbl = self._get_delta_table(bucket, prefix, label=label, refresh=True) - - controlled_batch_str = ",".join( - [f"'{tag}'" for tag in self.access_controlled_batch_ids] - ) - _coll = prefix.split("/")[-1] - - if _coll == "tasks": - condition = f"batch_id NOT IN ({controlled_batch_str})" - else: - condition = "builder_meta.license != 'BY-NC'" - - predicate = ( - f"WHERE {condition}" if not has_gnome_access and access_controlled else "" + conditions.append(f"batch_id NOT IN ({controlled_batch_str})") + else: + conditions.append("builder_meta.license != 'BY-NC'") + predicate = f"WHERE {' AND '.join(conditions)}" if conditions else "" + + num_docs_needed = self._count_delta_docs( + tbl_lbl, + tbl, + predicate, + version_counts[version] if versioned else None, # type: ignore[index] + filter_access, + f"{collection}{version_note}", ) - # TODO: do we need something like this? - # predicate += f"{' AND ' if predicate else 'WHERE '}version='{self.db_version}'" - - # Setup progress bar - num_docs_needed: int = tbl.count() - - if not has_gnome_access: - mongo_predicate = ( - {"batch_id_neq_any": self.access_controlled_batch_ids} - if _coll == "tasks" - else {"license": "BY-NC"} - ) - try: - num_docs_needed = self.count(mongo_predicate) - except MPRestError: - # batch_id isn't a valid field - num_docs_needed = self.count() iterator = self.delta_catalog.execute_stream( f"SELECT * FROM {tbl_lbl} {predicate}" ) + # Every column except the partition column, in the document model's + # order. The partition value goes in the directory name. + schema = self._download_schema() + if "version" in schema.names: + schema = schema.remove(schema.get_field_index("version")) file_options = ds.ParquetFileFormat().make_write_options(compression="zstd") + data_dir = ( + os.path.join(target_path, f"version={version}") + if versioned + else target_path + ) + os.makedirs(data_dir, exist_ok=True) + # Unique per download, so a retry never overwrites files the table uses + run_tag = uuid.uuid4().hex[:8] + added: list[AddAction] = [] + partition_values = {"version": version} if versioned else {} + + def _record(written_file: Any) -> None: + added.append( + AddAction( + path=quote( + os.path.relpath(written_file.path, target_path), safe="/=" + ), + size=os.path.getsize(written_file.path), + partition_values=partition_values, + modification_time=int(time.time() * 1000), + data_change=True, + stats=json.dumps({"numRecords": written_file.metadata.num_rows}), + ) + ) - def _flush( - accumulator: list[pa.RecordBatch], - group: int, - schema: pa.Schema, - partitioning: pa.dataset.Partitioning, - ): + def _flush(accumulator: list[pa.RecordBatch], group: int) -> None: # somewhere post datafusion 51.0.0 and arrow-rs 57.0.0 # casts to *View types began, need to cast back to base schema # -> pyarrow is behind on implementation support for *View types - tbl = ( + chunk = ( pa.Table.from_batches(accumulator) .select(schema.names) .cast(target_schema=schema) ) - ds.write_dataset( - tbl, - base_dir=target_path, + chunk, + base_dir=data_dir, format="parquet", - partitioning=partitioning, - basename_template=f"group-{group}-" + "part-{i}.zstd.parquet", + basename_template=f"group-{group}-{run_tag}-" + "part-{i}.zstd.parquet", existing_data_behavior="overwrite_or_ignore", max_rows_per_group=1024, file_options=file_options, + file_visitor=_record, ) docs = self._docs_description - with progress_bar( - f"Retrieving DeltaTable-backed {docs}", - total=num_docs_needed, - enabled=not self.mute_progress_bars, - summary=f"Downloaded {{completed:,}} {docs} to {target_path}", - ) as pbar: - group = 1 - size = 0 - accumulator = [] - _schema = pa.schema(arrowize(self.document_model)) - schema = ( - _schema.insert(0, pa.field("version", pa.string())) - if versioned - else _schema + try: + with progress_bar( + f"Retrieving DeltaTable-backed {docs}{version_note}", + total=num_docs_needed, + enabled=not self.mute_progress_bars, + summary=f"Downloaded {{completed:,}} {docs}{version_note} to {target_path}", + ) as pbar: + group = 1 + size = 0 + accumulator: list[pa.RecordBatch] = [] + for rb in iterator: + accumulator.append(rb) + size += rb.get_total_buffer_size() + pbar.update(rb.num_rows) + + if size >= MAPI_CLIENT_SETTINGS.DATASET_FLUSH_THRESHOLD: + with status( + f"Writing {docs} to disk...", + enabled=not self.mute_progress_bars, + ): + _flush(accumulator, group) + group += 1 + size = 0 + accumulator.clear() + + # Writing the last chunk and committing can take a while, + # replace the (full) bar with a spinner meanwhile. + pbar.hide() + with status( + f"Writing {docs}{version_note} to the local DeltaTable...", + enabled=not self.mute_progress_bars, + ): + if accumulator: + _flush(accumulator, group) + self._commit_delta_download( + target_path, local, schema, added, version, versioned + ) + except BaseException: + # The files were never committed, so the table doesn't reference them + for action in added: + try: + os.remove(os.path.join(target_path, unquote(action.path))) + except OSError: + logger.warning( + f"Could not remove uncommitted file {action.path} in {target_path}." + ) + raise + + if not (pbar.shown and pbar.completed): + # The progress summary (which includes the path) wasn't printed, + # e.g. muted progress bars or non-interactive output + logger.info( + f"Dataset for {collection}{version_note} written to {target_path}" ) - partitioning_schema = pa.schema([pa.field("version", pa.string())]) - partitioning = ( - pa.dataset.partitioning(partitioning_schema, flavor="hive") + logger.debug( + "Consult the delta-rs and pyarrow documentation for advanced usage: " + "delta-io.github.io/delta-rs, arrow.apache.org/docs/python" + ) + return _dataset() + + def _download_schema(self) -> pa.Schema: + """Arrow schema of the data written by a full download. + + Defaults to the document model's. Override when the model has fields + that aren't stored in the S3 table (e.g. fields added by a search). + """ + return pa.schema(arrowize(self.document_model)) + + def _local_delta_table( + self, target_path: str, versioned: bool, collection: str + ) -> DeltaTable | None: + """Open the local copy of a dataset, if there is one. + + If the local copy was made by an older client and doesn't match the + remote layout (not a DeltaTable, or not partitioned by version when + the remote is), it can't be added to. With `force_renew` it is + deleted so the download starts fresh; otherwise an error is raised. + + Args: + target_path (str) : local dataset directory + versioned (bool) : whether the remote table is partitioned by version + collection (str) : collection name, for messages + + Returns: + the local DeltaTable, or None if there is none (or it was removed) + """ + if not os.path.isdir(target_path) or not os.listdir(target_path): + return None + + reason = None + if not DeltaTable.is_deltatable(target_path): + reason = "is not a DeltaTable" + else: + local = DeltaTable(target_path) + local_versioned = "version" in local.metadata().partition_columns + if local_versioned == versioned: + return local + reason = ( + "is not partitioned by database version" if versioned - else None + else "is partitioned by database version, but the remote dataset isn't" ) - for rg in iterator: - accumulator.append(rg) - page_size = rg.num_rows - size += rg.get_total_buffer_size() - pbar.update(page_size) + if not self.force_renew: + raise MPRestError( + f"The local {collection} dataset at {target_path} {reason}, likely " + "because it was downloaded by an older version of mp-api. Delete or " + "move it, or re-run with MPRester(force_renew=True) to replace it." + ) + logger.warning( + f"Removing the local {collection} dataset at {target_path}: it {reason}." + ) + shutil.rmtree(target_path) + return None - if size >= MAPI_CLIENT_SETTINGS.DATASET_FLUSH_THRESHOLD: - _flush(accumulator, group, schema, partitioning) - group += 1 - size = 0 - accumulator.clear() + def _count_delta_docs( + self, + tbl_lbl: str, + tbl: DeltaTable, + predicate: str, + version_rows: int | None, + filter_access: bool, + description: str, + ) -> int | None: + """Number of documents a full download will fetch, for the progress bar. + + Without an access filter this is read from the Delta log (instant). + With one, the rows have to be counted on S3, which can be slow for + large tables; a status line is shown meanwhile. - if accumulator: - _flush(accumulator, group + 1, schema, partitioning) + Args: + tbl_lbl (str) : label of the remote table in the catalog + tbl (DeltaTable) : the remote table + predicate (str) : WHERE clause of the download query, or "" + version_rows (int or None) : rows in the requested version, from + the log, or None if the table isn't versioned + filter_access (bool) : whether access-controlled rows are excluded + description (str) : what's being counted, for the status line - logger.info(f"Dataset for {suffix} written to {target_path}") - logger.info("Converting to DeltaTable...") + Returns: + int, or None if counting failed (the bar then has no total) + """ + if not filter_access: + return version_rows if version_rows is not None else tbl.count() - delta_paritioning = Schema.from_arrow(partitioning_schema) + try: + with status( + f"Counting {description} documents (excluding access-controlled " + "entries), this can take a while...", + enabled=not self.mute_progress_bars, + logger=logger, + ): + result = self.delta_catalog.execute( + f"SELECT COUNT(*) AS n FROM {tbl_lbl} {predicate}", label=tbl_lbl + ) + return int(result.column("n")[0].as_py()) + except Exception as exc: + logger.warning( + f"Could not count {description} documents, downloading without a total: {exc}" + ) + return None - convert_to_deltalake( - target_path, - partition_by=delta_paritioning if versioned else None, - partition_strategy="hive" if versioned else None, - ) + @staticmethod + def _commit_delta_download( + target_path: str, + local: DeltaTable | None, + schema: pa.Schema, + added: list[AddAction], + version: str | None, + versioned: bool, + ) -> None: + """Commit downloaded files to the local DeltaTable in one transaction. - logger.info( - "Consult the delta-rs and pyarrow documentation for advanced usage: " - "delta-io.github.io/delta-rs, arrow.apache.org/docs/python" - ) + Creates the table if there is none. Otherwise appends a new version + partition, or, if the version (or, for unversioned tables, the + table) is already there, replaces it. - return { - "data": MPDataset( - path=target_path, - document_model=self.document_model, - use_document_model=self.use_document_model, + Args: + target_path (str) : local dataset directory + local (DeltaTable or None) : the existing local table, if any + schema (pa.Schema) : schema of the data files (no partition column) + added (list of AddAction) : the files written by this download + version (str or None) : partition value of the downloaded version + versioned (bool) : whether the table is partitioned by version + """ + partition_by = ["version"] if versioned else None + table_schema = Schema.from_arrow( + schema.append(pa.field("version", pa.string())) if versioned else schema + ) + if local is None: + create_table_with_add_actions( + target_path, + table_schema, + added, + mode="error", + partition_by=partition_by, ) - } + return + + if not versioned: + local.create_write_transaction(added, mode="overwrite", schema=table_schema) + elif version in {p.get("version") for p in local.partitions()}: + local.create_write_transaction( + added, + mode="overwrite", + schema=table_schema, + partition_by=partition_by, + partition_filters=[("version", "=", version)], # type: ignore[list-item] + ) + else: + local.create_write_transaction( + added, mode="append", schema=table_schema, partition_by=partition_by + ) + return + + # A replaced version's old files are no longer in the table, delete + # them now rather than keeping two copies on disk. + local.update_incremental() + removed = local.vacuum( + retention_hours=0, dry_run=False, enforce_retention_duration=False + ) + if removed: + logger.debug(f"Removed {len(removed)} replaced files from {target_path}.") def _query_resource( self, @@ -941,6 +1315,7 @@ def _query_resource( chunk_size: int | None = None, timeout: int | None = None, show_progress: bool | None = None, + db_version: str | None = None, ) -> dict[str, Any]: """Query the endpoint for a Resource containing a list of documents and meta information about pagination and total document count. @@ -958,6 +1333,9 @@ def _query_resource( timeout (float or None): Time in seconds to wait until a request timeout error is thrown show_progress (bool or None): Whether to show progress bars for this call. If None, defers to `not self.mute_progress_bars`. + db_version (str or None): Database version for this call, e.g. "2026.04.13" + or "latest". Only full downloads (no filters) can use a version other + than the one the API serves. Defaults to `self.db_version`. Returns: A Resource, a dict with two keys, "data" containing a list of documents, and @@ -978,6 +1356,9 @@ def _query_resource( no_query = not {field for field in criteria if field[0] != "_"} query_s3 = no_query and num_chunks is None + if db_version and not query_s3: + self._check_rest_db_version(db_version) + if fields: if isinstance(fields, str): fields = [fields] @@ -1001,38 +1382,21 @@ def _query_resource( pbar_message = f"Retrieving {docs_name}" pbar_summary = f"Retrieved {{completed:,}} {docs_name}" - if "/" not in self.suffix: - suffix = self.suffix - elif self.suffix == "molecules/summary": - suffix = "molecules" - elif self.suffix == "molecules/jcesr": - suffix = "jcesr" - else: - infix, suffix = self.suffix.split("/", 1) - suffix = infix if suffix == "core" else suffix - suffix = suffix.replace("_", "-") - - if "tasks" in suffix: - bucket_suffix, prefix = ("parsed", "core/tasks") - elif suffix in STATIC_COLLECTIONS: - bucket_suffix = "build" - prefix = f"static-collections/{suffix}" - else: - # TODO: remove once all collections are migrated to delta-backed format - bucket_suffix = "build" - prefix = f"collections/{suffix}" - - bucket = f"materialsproject-{bucket_suffix}" + suffix, bucket, prefix = self._s3_location() if self.delta_backed: - access_controlled = suffix in CONTROLLED_COLLECTIONS - versioned = suffix != "tasks" and suffix not in STATIC_COLLECTIONS return self._query_delta_backed( bucket=bucket, prefix=prefix, - access_controlled=access_controlled, - versioned=versioned, + access_controlled=suffix in CONTROLLED_COLLECTIONS, timeout=timeout, + db_version=db_version, + ) + + if db_version: + logger.warning( + f"The {suffix} dataset has a single version, ignoring " + f"db_version={db_version!r}." ) # Paginate over all entries in the bucket. @@ -1610,6 +1974,7 @@ def _search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, **kwargs, ) -> list[BaseModel] | list[dict]: """A generic search method to retrieve documents matching specific parameters. @@ -1624,6 +1989,9 @@ def _search( fields (list[str]): List of fields to project. When searching, it is better to only ask for the specific fields of interest to reduce the time taken to retrieve the documents. See the available_fields property to see a list of fields to choose from. + db_version (str): Database version to download when no filters are given (full + dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. kwargs: Supported search terms, e.g. nelements_max=3 for the "materials" search API. Consult the specific API route for valid search terms. @@ -1642,6 +2010,7 @@ def _search( fields=fields, chunk_size=chunk_size, num_chunks=num_chunks, + db_version=db_version, ) def get_data_by_id( @@ -1683,6 +2052,7 @@ def _get_all_documents( fields=None, chunk_size=1000, num_chunks=None, + db_version: str | None = None, ) -> list[BaseModel] | list[dict]: """Iterates over pages until all documents are retrieved. Displays progress bars. This method is designed to give a common @@ -1705,6 +2075,7 @@ def _get_all_documents( fields=fields, chunk_size=chunk_size, num_chunks=num_chunks, + db_version=db_version, ) return results["data"] diff --git a/mp_api/client/core/delta.py b/mp_api/client/core/delta.py index dde22d26..a26afe62 100644 --- a/mp_api/client/core/delta.py +++ b/mp_api/client/core/delta.py @@ -262,6 +262,40 @@ def get_table( self._reload(entry) return entry.label, entry.table + def partition_row_counts( + self, label: str, column: str = "version" + ) -> dict[str, int] | None: + """Number of rows per value of a partition column, read from the Delta log. + + No data files are read. Uses the cached snapshot; call + `get_table(..., refresh=True)` first for the latest one. + + Args: + label (str) : label of a registered table + column (str) : partition column + + Returns: + dict of partition value to row count, or None if the table isn't + partitioned on `column`. Values whose files have no row count in + the log are counted as 0. + + Raises: + KeyError: if the table hasn't been loaded. + """ + with self._lock: + table = self._entries[self._labels[label]].table + if column not in table.metadata().partition_columns: + return None + actions = pa.table(table.get_add_actions(flatten=True)) + counts: dict[str, int] = {} + for value, rows in zip( + actions[f"partition.{column}"].to_pylist(), + actions["num_records"].to_pylist(), + strict=True, + ): + counts[value] = counts.get(value, 0) + (rows or 0) + return counts + def execute(self, sql: str, label: str | None = None) -> pa.Table: """Run a SQL query and return the full result. @@ -393,7 +427,7 @@ def _reload(self, entry: _Entry) -> None: entry.generation += 1 self._qb = qb - logger.info( + logger.debug( f"Refreshed DeltaTable '{entry.label}' ({entry.uri}) " f"to version {table.version()}." ) diff --git a/mp_api/client/core/utils.py b/mp_api/client/core/utils.py index 3e305378..2aad1026 100644 --- a/mp_api/client/core/utils.py +++ b/mp_api/client/core/utils.py @@ -7,9 +7,11 @@ from itertools import chain from pathlib import Path from typing import TYPE_CHECKING -from urllib.parse import urljoin +from urllib.parse import unquote, urljoin import orjson +import pyarrow as pa +import pyarrow.compute as pc import pyarrow.dataset as ds from deltalake import DeltaTable from emmet.core import __version__ as _EMMET_CORE_VER @@ -59,6 +61,36 @@ def _compare_emmet_ver( )(parse_version(ref_version)) +def to_partition_version(db_version: str) -> str: + """Convert a database version to the form used for DeltaTable partitions. + + Partition values use dashes where the database version uses dots, + e.g. "2026.04.13" -> "2026-04-13" and "2026.04.13.post1" -> "2026-04-13-post1". + Dashes and underscores are accepted in the input too. + + Args: + db_version (str) : database version, e.g. "2026.04.13" + + Returns: + str : partition value, e.g. "2026-04-13" + """ + return db_version.strip().lstrip("v").replace(".", "-").replace("_", "-") + + +def to_db_version(partition_version: str) -> str: + """Convert a DeltaTable partition value to the database version form. + + The inverse of `to_partition_version`, e.g. "2026-04-13" -> "2026.04.13". + + Args: + partition_version (str) : partition value, e.g. "2026-04-13" + + Returns: + str : database version, e.g. "2026.04.13" + """ + return to_partition_version(partition_version).replace("-", ".") + + def load_json( json_like: str | bytes, deser: bool = False, encoding: str = "utf-8" ) -> Any: @@ -259,6 +291,7 @@ def __init__( path: str | Path, document_model: ModelMetaclass, use_document_model: bool, + version: str | None = None, ): """Initialize a MPDataset. @@ -270,11 +303,15 @@ def __init__( Pydantic document model for use during de-serialization of arrow data use_document_model: bool Use 'document_model' during de-serialization of arrow data. + version: str | None + Database version to read, for datasets partitioned by version, + e.g. "2026.04.13". If None, all data in the dataset is read. """ self._start: int = 0 self._path = Path(path) self._document_model: ModelMetaclass = document_model - self._dataset = ds.dataset(path) + self._version = to_db_version(version) if version else None + self._dataset = self._open_dataset() self._row_groups: list[Any] = list( chain.from_iterable( [ @@ -285,6 +322,55 @@ def __init__( ) self._use_document_model = use_document_model + def _open_dataset(self) -> ds.Dataset: + """Open the data files that the DeltaTable's current version uses. + + The file list comes from the Delta log, so files left behind by an + interrupted download, or removed from the table but not yet vacuumed, + are not read. Without a Delta log, every file in the directory is read. + + The files are opened directly with pyarrow rather than through + `DeltaTable.to_pyarrow_dataset()`: reading through deltalake's + filesystem can hang the interpreter at exit (delta-rs 1.5.1). + """ + if not DeltaTable.is_deltatable(str(self._path)): + return ds.dataset(self._path) + + table = DeltaTable(self._path) + actions = pa.table(table.get_add_actions(flatten=True)) + if self._version and "version" in table.metadata().partition_columns: + actions = actions.filter( + pc.equal( # type: ignore[attr-defined] + actions["partition.version"], + to_partition_version(self._version), + ) + ) + # Paths in the log are relative to the table root and URL-encoded + files = [ + str(self._path / unquote(rel)) for rel in actions["path"].to_pylist() + ] + return ds.dataset( + files, schema=self._file_schema(table, files), format="parquet" + ) + + @staticmethod + def _file_schema(table: DeltaTable, files: list[str]) -> pa.Schema | None: + """Schema of the data files, used when there are none to infer it from. + + Partition columns (e.g. version) are stored in the directory names, + not the files, so they are left out. + """ + if files: + return None + partition_columns = set(table.metadata().partition_columns) + schema = pa.schema(table.schema().to_arrow()) # type: ignore[arg-type] + return pa.schema([f for f in schema if f.name not in partition_columns]) + + @property + def version(self) -> str | None: + """The database version this dataset reads, e.g. "2026.04.13".""" + return self._version + @property def pyarrow_dataset(self) -> ds.Dataset: return self._dataset diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index b0a0e353..dd8430e4 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -2,10 +2,12 @@ import itertools import json +import logging import os import re import warnings from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from functools import cache, lru_cache from typing import TYPE_CHECKING @@ -29,9 +31,15 @@ from pymatgen.symmetry.analyzer import SpacegroupAnalyzer from requests import Session, get -from mp_api.client.core._display import print_notice +from mp_api.client.core._display import print_notice, status from mp_api.client.core._oxygen_evolution import OxygenEvolution -from mp_api.client.core.client import _Rester +from mp_api.client.core.client import ( + LATEST_DB_VERSION, + S3_COLLECTION_NAMES, + STATIC_COLLECTIONS, + BaseRester, + _Rester, +) from mp_api.client.core.exceptions import ( MPRestError, MPRestWarning, @@ -47,6 +55,8 @@ from mp_api.client.routes.materials import MATERIALS_RESTERS from mp_api.client.routes.molecules import MOLECULES_RESTERS +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from collections.abc import Iterable, Sequence from typing import Any, Literal @@ -84,6 +94,10 @@ **GENERIC_RESTERS, } +# Routes whose S3 data isn't partitioned by database version, or that have +# no S3 dataset at all (besides STATIC_COLLECTIONS) +_UNVERSIONED_ROUTES = {"tasks", "similarity", "doi"} + TOP_LEVEL_RESTERS = [ "molecules/core", "materials/core", @@ -151,9 +165,10 @@ def __init__( session: Session object to use. By default (None), the client will create one. headers: Custom headers for localhost connections. mute_progress_bars: Whether to mute progress bars. - db_version (str) : EXPERIMENTAL, allows for accessing a different version of the database - than what is currently deployed. The Materials Project cannot guarantee that all - features will still work. + db_version (str) : Database version to use for data read from S3 (full dataset + downloads, phase diagrams), e.g. "2026.04.13". Defaults to the version + currently served by the API. REST queries always use the current database. + See `available_db_versions()` on a rester for the versions available. local_dataset_cache: Target directory for downloading full datasets. Defaults to "mp_datasets" in the user's home directory force_renew: Option to overwrite existing local dataset @@ -241,16 +256,26 @@ def __init__( stacklevel=2, ) - if self.db_version: - warnings.warn( - "Specifying an explicit database version is an experimental " - "feature. The Materials Project cannot guarantee " - "functionality at this time, use at your own risk!", - stacklevel=2, - category=MPRestWarning, + current_db_version = self._get_heartbeat_info(self.endpoint)[0] + # The version the REST API serves, may differ from self.db_version + self.current_db_version: str = current_db_version + rest_note = "REST queries use the " + ( + f"current version, {current_db_version}." + if current_db_version + else "current version." + ) + if not self.db_version: + self.db_version = current_db_version + elif self.db_version == LATEST_DB_VERSION: + logger.warning( + "Using the newest database version on S3 for each dataset (full " + f"dataset downloads, phase diagrams), which may be ahead of the API. {rest_note}" + ) + elif self.db_version != current_db_version: + logger.warning( + f"Using database version {self.db_version} for data read from S3 " + f"(full dataset downloads, phase diagrams). {rest_note}" ) - else: - self.db_version = self._get_heartbeat_info(self.endpoint)[0] if notify_db_version: self._db_version_check() @@ -345,8 +370,84 @@ def __dir__(self): ) def __repr__(self) -> str: + if self.db_version == LATEST_DB_VERSION: + return "MPRester(latest)" return f"MPRester({'v' + self.db_version if self.db_version else 'unknown version'})" + def _versioned_resters(self) -> dict[str, BaseRester]: + """Delta-backed resters, keyed by route (e.g. "summary"), not yet checked for versions.""" + resters: dict[str, BaseRester] = {} + for parent in ("materials", "molecules"): + core = getattr(self, parent) + for route, lazy in core.sub_resters.items(): + # Only collections/... datasets are partitioned by version. Tasks + # and static collections are skipped without loading their (large) + # logs, and checked by name first so their modules aren't + # imported (some are slow to import). + name = S3_COLLECTION_NAMES.get( + f"{parent}/{route}", route.replace("_", "-") + ) + if ( + route in resters + or route in _UNVERSIONED_ROUTES + or name in STATIC_COLLECTIONS + or not getattr(lazy, "delta_backed", False) + ): + continue + rester = getattr(core, route)._obj + if rester._s3_location()[2].startswith("collections/"): + resters[route] = rester + return resters + + def available_db_versions( + self, collection: str | None = None + ) -> dict[str, list[str]] | list[str]: + """Database versions available for full dataset downloads on S3. + + Read from the DeltaTable logs, no data is downloaded. Pass any of these + as `db_version` to a rester's `search()` (with no filters) or to + `MPRester(db_version=...)`. + + Args: + collection (str or None) : a single collection, e.g. "summary" or + "materials/summary". If None, every collection that is + partitioned by database version is listed; tables are loaded in + parallel, with a status line while that runs. + + Returns: + list of str for one collection, e.g. ["2026.04.13", "2026.09.28"], + otherwise dict of collection to list of str. Collections that + couldn't be loaded, or have a single version, are left out. + + Raises: + MPRestError: if `collection` is unknown or has a single version. + """ + resters = self._versioned_resters() + if collection is not None: + route = collection.split("/", 1)[-1].replace("-", "_") + if route not in resters: + raise MPRestError( + f"Unknown collection {collection!r}, expected one of: " + f"{', '.join(sorted(resters))}." + ) + return resters[route].available_db_versions() + + def _list(route: str) -> tuple[str, list[str] | None]: + try: + return route, resters[route].available_db_versions() + except Exception as exc: + logger.debug(f"No database versions for {route}: {exc}") + return route, None + + with status( + f"Fetching available database versions for {len(resters)} datasets...", + enabled=not self.mute_progress_bars, + logger=logger, + ): + with ThreadPoolExecutor(max_workers=min(8, len(resters) or 1)) as pool: + found = list(pool.map(_list, sorted(resters))) + return {route: versions for route, versions in found if versions} + def get_task_ids_associated_with_material_id( self, material_id: str, calc_types: list[CalcType] | None = None ) -> list[str]: diff --git a/mp_api/client/routes/materials/absorption.py b/mp_api/client/routes/materials/absorption.py index 6fd86ce7..0e1fd9b9 100644 --- a/mp_api/client/routes/materials/absorption.py +++ b/mp_api/client/routes/materials/absorption.py @@ -25,6 +25,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[AbsorptionDoc] | list[dict]: """Query for optical absorption spectra data. @@ -53,6 +54,9 @@ def search( chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in AbsorptionDoc to return data for. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([AbsorptionDoc], [dict]) List of optical absorption documents or dictionaries. @@ -99,4 +103,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/alloys.py b/mp_api/client/routes/materials/alloys.py index 95cae74f..a67d2bc0 100644 --- a/mp_api/client/routes/materials/alloys.py +++ b/mp_api/client/routes/materials/alloys.py @@ -21,6 +21,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[AlloyPairDoc] | list[dict]: """Query for hypothetical alloys formed between two commensurate crystal structures, following the methodology in @@ -36,6 +37,9 @@ def search( chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in AlloyPairDoc to return data for. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([AlloyPairDoc], [dict]) List of alloy pair documents or dictionaries. @@ -61,4 +65,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/bonds.py b/mp_api/client/routes/materials/bonds.py index 17516e7e..94f3d351 100644 --- a/mp_api/client/routes/materials/bonds.py +++ b/mp_api/client/routes/materials/bonds.py @@ -25,6 +25,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[BondingDoc] | list[dict]: """Query bonding docs using a variety of search criteria. @@ -44,6 +45,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in DielectricDoc to return data for. Default is material_id and last_updated if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([BondingDoc], [dict]) List of bonding documents or dictionaries. @@ -100,4 +104,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/chemenv.py b/mp_api/client/routes/materials/chemenv.py index 4edc64d3..4a2942a8 100644 --- a/mp_api/client/routes/materials/chemenv.py +++ b/mp_api/client/routes/materials/chemenv.py @@ -46,6 +46,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[ChemEnvDoc] | list[dict]: """Query for chemical environment data. @@ -71,6 +72,9 @@ def search( chunk_size (int): Number of data entries per chunk. all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in ChemEnvDoc to return data for. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([ChemEnvDoc], [dict]) List of chemenv documents or dictionaries. @@ -146,4 +150,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/dielectric.py b/mp_api/client/routes/materials/dielectric.py index b71d917f..65f8323e 100644 --- a/mp_api/client/routes/materials/dielectric.py +++ b/mp_api/client/routes/materials/dielectric.py @@ -24,6 +24,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[DielectricDoc] | list[dict]: """Query dielectric docs using a variety of search criteria. @@ -39,6 +40,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in DielectricDoc to return data for. Default is material_id and last_updated if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([DielectricDoc], [dict]) List of dielectric documents or dictionaries. @@ -80,4 +84,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/elasticity.py b/mp_api/client/routes/materials/elasticity.py index d39f496c..fec6b1d5 100644 --- a/mp_api/client/routes/materials/elasticity.py +++ b/mp_api/client/routes/materials/elasticity.py @@ -28,6 +28,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[ElasticityDoc] | list[dict]: """Query elasticity docs using a variety of search criteria. @@ -55,6 +56,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in ElasticityDoc to return data for. Default is material_id and prett-formula if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([ElasticityDoc], [dict]) List of elasticity documents or dictionaries. @@ -110,4 +114,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/electrodes.py b/mp_api/client/routes/materials/electrodes.py index f5832923..8527a5e0 100644 --- a/mp_api/client/routes/materials/electrodes.py +++ b/mp_api/client/routes/materials/electrodes.py @@ -42,6 +42,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, _page: int | None = None, _sort_fields: str | None = None, ) -> list[InsertionElectrodeDoc | ConversionElectrodeDoc] | list[dict]: @@ -84,6 +85,9 @@ def search( Default is battery_id and last_updated if all_fields is False. _page (int or None) : Page of the results to skip to. _sort_fields (str or None) : Field to sort on. Including a leading "-" sign will reverse sort order. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([InsertionElectrodeDoc or ConversionElectrodeDoc], [dict]) List of insertion/conversion electrode documents or dictionaries. @@ -153,7 +157,7 @@ def search( if query_params[entry] is not None } - return super()._search(**query_params) # type: ignore[return-value] + return super()._search(**query_params, db_version=db_version) # type: ignore[return-value] class ElectrodeRester(BaseElectrodeRester): diff --git a/mp_api/client/routes/materials/electronic_structure.py b/mp_api/client/routes/materials/electronic_structure.py index c32b98db..c67e5960 100644 --- a/mp_api/client/routes/materials/electronic_structure.py +++ b/mp_api/client/routes/materials/electronic_structure.py @@ -56,6 +56,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ): """Query electronic structure docs using a variety of search criteria. @@ -80,6 +81,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in ElectronicStructureDoc to return data for. Default is material_id and last_updated if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([ElectronicStructureDoc]) List of electronic structure documents @@ -146,6 +150,7 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/grain_boundaries.py b/mp_api/client/routes/materials/grain_boundaries.py index 6390abc6..8cf80a34 100644 --- a/mp_api/client/routes/materials/grain_boundaries.py +++ b/mp_api/client/routes/materials/grain_boundaries.py @@ -12,7 +12,6 @@ class GrainBoundaryRester(BaseRester): suffix = "materials/grain_boundaries" document_model = GrainBoundaryDoc # type: ignore primary_key = "material_id" - delta_backed = False def search( self, diff --git a/mp_api/client/routes/materials/magnetism.py b/mp_api/client/routes/materials/magnetism.py index be1f8e5b..c4c716b8 100644 --- a/mp_api/client/routes/materials/magnetism.py +++ b/mp_api/client/routes/materials/magnetism.py @@ -27,6 +27,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[MagnetismDoc] | list[dict]: """Query magnetism docs using a variety of search criteria. @@ -46,6 +47,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in MagnetismDoc to return data for. Default is material_id and last_updated if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([MagnetismDoc], [dict]) List of magnetism documents or dictionaries. @@ -121,4 +125,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/materials.py b/mp_api/client/routes/materials/materials.py index 603c4dc7..0d2bffe5 100644 --- a/mp_api/client/routes/materials/materials.py +++ b/mp_api/client/routes/materials/materials.py @@ -71,6 +71,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[MaterialsDoc] | list[dict]: """Query core material docs using a variety of search criteria. @@ -98,6 +99,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in MaterialsCoreDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([MaterialsDoc], [dict]) List of material documents or dictionaries. @@ -172,6 +176,7 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) def find_structure( diff --git a/mp_api/client/routes/materials/oxidation_states.py b/mp_api/client/routes/materials/oxidation_states.py index a27c133d..013a85e3 100644 --- a/mp_api/client/routes/materials/oxidation_states.py +++ b/mp_api/client/routes/materials/oxidation_states.py @@ -23,6 +23,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[OxidationStateDoc] | list[dict]: """Query oxidation state docs using a variety of search criteria. @@ -40,6 +41,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in OxidationStateDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([OxidationStateDoc], [dict]) List of oxidation state documents or dictionaries. @@ -79,4 +83,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/piezo.py b/mp_api/client/routes/materials/piezo.py index 2e7070c7..75d2a41b 100644 --- a/mp_api/client/routes/materials/piezo.py +++ b/mp_api/client/routes/materials/piezo.py @@ -21,6 +21,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[PiezoelectricDoc] | list[dict]: """Query piezoelectric data using a variety of search criteria. @@ -34,6 +35,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in PiezoDoc to return data for. Default is material_id and last_updated if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([PiezoDoc], [dict]) List of piezoelectric documents @@ -66,4 +70,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/provenance.py b/mp_api/client/routes/materials/provenance.py index bd8652e0..6238b136 100644 --- a/mp_api/client/routes/materials/provenance.py +++ b/mp_api/client/routes/materials/provenance.py @@ -19,6 +19,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[ProvenanceDoc] | list[dict]: """Query provenance docs using a variety of search criteria. @@ -31,6 +32,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in ProvenanceDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([ProvenanceDoc], [dict]) List of provenance documents or dictionaries. @@ -54,4 +58,5 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/robocrys.py b/mp_api/client/routes/materials/robocrys.py index 532429b5..4000b821 100644 --- a/mp_api/client/routes/materials/robocrys.py +++ b/mp_api/client/routes/materials/robocrys.py @@ -49,6 +49,7 @@ def search_docs( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[RobocrystallogapherDoc] | list[dict]: """Query robocrystallographer docs using a variety of search criteria. @@ -60,6 +61,9 @@ def search_docs( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in RobocrystallogapherDoc to return data for. Default is material_id, last_updated, and formula_pretty if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([RobocrystallogapherDoc], [dict]) List of robocrystallographer documents or dictionaries. @@ -83,4 +87,5 @@ def search_docs( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/summary.py b/mp_api/client/routes/materials/summary.py index fad14575..b7da8888 100644 --- a/mp_api/client/routes/materials/summary.py +++ b/mp_api/client/routes/materials/summary.py @@ -74,6 +74,7 @@ def search( # noqa: D417 chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, _page: int | None = None, _sort_fields: str | None = None, **kwargs, @@ -155,6 +156,9 @@ def search( # noqa: D417 Default is material_id if all_fields is False. _page (int or None) : Page of the results to skip to. _sort_fields (str or None) : Field to sort on. Including a leading "-" sign will reverse sort order. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([SummaryDoc], [dict]) List of SummaryDoc documents or dictionaries. @@ -376,6 +380,7 @@ def search( # noqa: D417 fields=fields, **query_params, **{sk: symm_params[i] for i, sk in enumerate(ordered_symm_key)}, + db_version=db_version, ) for symm_params in product( *[batched_symm_query[k] for k in ordered_symm_key] @@ -389,4 +394,5 @@ def search( # noqa: D417 all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) diff --git a/mp_api/client/routes/materials/surface_properties.py b/mp_api/client/routes/materials/surface_properties.py index b8742084..623aa396 100644 --- a/mp_api/client/routes/materials/surface_properties.py +++ b/mp_api/client/routes/materials/surface_properties.py @@ -12,7 +12,6 @@ class SurfacePropertiesRester(BaseRester): suffix = "materials/surface_properties" document_model = SurfacePropDoc # type: ignore primary_key = "material_id" - delta_backed = False def search( self, diff --git a/mp_api/client/routes/materials/synthesis.py b/mp_api/client/routes/materials/synthesis.py index bb0f5750..8ef3355b 100644 --- a/mp_api/client/routes/materials/synthesis.py +++ b/mp_api/client/routes/materials/synthesis.py @@ -1,7 +1,10 @@ from __future__ import annotations +import pyarrow as pa +from emmet.core.arrow import arrowize from emmet.core.synthesis import ( OperationTypeEnum, + SynthesisRecipe, SynthesisSearchResultModel, SynthesisTypeEnum, ) @@ -12,7 +15,12 @@ class SynthesisRester(BaseRester): suffix = "materials/synthesis" document_model = SynthesisSearchResultModel # type: ignore - delta_backed = False + + def _download_schema(self) -> pa.Schema: + """Full downloads hold recipes only: `search_score` and `highlights` + are added by the text search and aren't stored on S3 (they load as None). + """ + return pa.schema(arrowize(SynthesisRecipe)) def search( self, @@ -49,6 +57,8 @@ def search( num_chunks (int | None): Maximum number of chunks of data to yield. None will yield all possible. chunk_size (int | None): Number of data entries per chunk. + With no search terms and `num_chunks=None`, every recipe is downloaded from + S3 as a local dataset instead; `search_score` and `highlights` are then None. Returns: ([SynthesisSearchResultModel], [dict]): List of synthesis documents or dictionaries. diff --git a/mp_api/client/routes/materials/thermo.py b/mp_api/client/routes/materials/thermo.py index 3d4985e5..e7fd846e 100644 --- a/mp_api/client/routes/materials/thermo.py +++ b/mp_api/client/routes/materials/thermo.py @@ -12,9 +12,10 @@ from pymatgen.core import Element from mp_api.client.core import BaseRester +from mp_api.client.core.client import LATEST_DB_VERSION, _normalize_db_version from mp_api.client.core.exceptions import MPRestError from mp_api.client.core.settings import DEFAULT_THERMOTYPE -from mp_api.client.core.utils import validate_ids +from mp_api.client.core.utils import to_partition_version, validate_ids if TYPE_CHECKING: from collections.abc import Sequence @@ -73,6 +74,7 @@ def search( chunk_size: int = 1000, all_fields: bool = True, fields: list[str] | None = None, + db_version: str | None = None, ) -> list[ThermoDoc] | list[dict]: """Query core thermo docs using a variety of search criteria. @@ -102,6 +104,9 @@ def search( all_fields (bool): Whether to return all fields in the document. Defaults to True. fields (list[str]): List of fields in ThermoDoc to return data for. Default is material_id and last_updated if all_fields is False. + db_version (str | None): Database version to download when no filters are given + (full dataset), e.g. "2026.04.13" or "latest". Defaults to the rester's version. + See `available_db_versions()`. Returns: ([ThermoDoc], [dict]) List of thermo documents or dictionaries. @@ -183,10 +188,14 @@ def search( all_fields=all_fields, fields=fields, **query_params, + db_version=db_version, ) def get_phase_diagram_from_chemsys( - self, chemsys: str, thermo_type: ThermoType | str = DEFAULT_THERMOTYPE + self, + chemsys: str, + thermo_type: ThermoType | str = DEFAULT_THERMOTYPE, + db_version: str | None = None, ) -> PhaseDiagram: """Get a pre-computed phase diagram for a given chemsys. @@ -194,6 +203,8 @@ def get_phase_diagram_from_chemsys( chemsys (str): A chemical system (e.g. Li-Fe-O) thermo_type (ThermoType): The thermo type for the phase diagram. Defaults to ThermoType.GGA_GGA_U_R2SCAN. + db_version (str | None): Database version of the phase diagram, e.g. + "2026.04.13" or "latest". Defaults to the rester's version. Returns: (PhaseDiagram): Pymatgen phase diagram object. @@ -201,11 +212,16 @@ def get_phase_diagram_from_chemsys( validated_thermo_type = self._check_thermo_types([thermo_type]).pop() sorted_chemsys = "-".join(sorted(chemsys.split("-"))) - version = self.db_version.replace(".", "-") pd_lbl, _ = self._get_delta_table( "materialsproject-build", "objects/phase-diagrams", label="phase_diagrams" ) + requested = _normalize_db_version(db_version) or self.db_version + if requested == LATEST_DB_VERSION: + counts = self.delta_catalog.partition_row_counts(pd_lbl) or {} + version = self._resolve_db_version(requested, counts, "phase-diagrams") + else: + version = to_partition_version(requested) query = f""" SELECT phase_diagram diff --git a/mp_api/client/routes/materials/xas.py b/mp_api/client/routes/materials/xas.py index 646e7c4b..eb0cdf21 100644 --- a/mp_api/client/routes/materials/xas.py +++ b/mp_api/client/routes/materials/xas.py @@ -19,7 +19,6 @@ class XASRester(BaseRester): suffix = "materials/xas" document_model = XASDoc # type: ignore primary_key = "task_id" - delta_backed = False def search( self, diff --git a/tests/client/core/test_delta_versions.py b/tests/client/core/test_delta_versions.py new file mode 100644 index 00000000..30bff038 --- /dev/null +++ b/tests/client/core/test_delta_versions.py @@ -0,0 +1,902 @@ +"""Full downloads of DeltaTables partitioned by database version, without network access. + +The "remote" tables are local DeltaTables; `_get_delta_table` is pointed at +them through the rester's DeltaCatalog. +""" + +import inspect +import io +import logging +import os +import re + +import pyarrow as pa +import pytest +from deltalake import DeltaTable, write_deltalake +from pydantic import BaseModel +from rich.console import Console + +import mp_api.client.core._display as display +import mp_api.client.core.client as client_module +from mp_api.client.core.client import BaseRester +from mp_api.client.core.delta import DeltaCatalog +from mp_api.client.core.exceptions import MPRestError +from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS +from mp_api.client.core.utils import MPDataset, to_db_version, to_partition_version + + +class BuilderMeta(BaseModel): + license: str + + +class Doc(BaseModel): + material_id: str + nsites: int + builder_meta: BuilderMeta + + +def remote_rows(version: str, n: int, n_nc: int = 0) -> pa.Table: + """`n` documents for `version`, the last `n_nc` of them BY-NC licensed.""" + return pa.table( + { + "material_id": [f"mp-{version}-{i}" for i in range(n)], + "nsites": list(range(n)), + "builder_meta": [ + {"license": "BY-NC" if i >= n - n_nc else "BY-4.0"} for i in range(n) + ], + "version": [version] * n, + } + ) + + +@pytest.fixture +def remote(tmp_path): + """A remote table with two versions: 10 rows (2 BY-NC), then 6 rows (1 BY-NC).""" + uri = str(tmp_path / "remote" / "chemenv") + write_deltalake(uri, remote_rows("2026-04-13", 10, 2), partition_by=["version"]) + write_deltalake( + uri, remote_rows("2026-09-28", 6, 1), partition_by=["version"], mode="append" + ) + return uri + + +class Rester(BaseRester): + suffix = "materials/chemenv" + document_model = Doc + + +@pytest.fixture +def make_rester(tmp_path, remote, monkeypatch): + monkeypatch.setattr( + BaseRester, + "_get_heartbeat_info", + staticmethod(lambda endpoint: ("2026.09.28", ["gnome_r2scan_statics"])), + ) + catalog = DeltaCatalog() + calls = {"gnome": 0, "get_table": []} + + def get_delta_table( + self, bucket, prefix, connector="s3a", label=None, refresh=False + ): + calls["get_table"].append(prefix) + return catalog.get_table(remote, label or "chemenv", refresh=refresh) + + monkeypatch.setattr(BaseRester, "_get_delta_table", get_delta_table) + + def make(db_version=None, gnome=True, force_renew=False): + def submit(self, **kwargs): + calls["gnome"] += 1 + return {"meta": {"total_doc": 1 if gnome else 0}} + + monkeypatch.setattr(BaseRester, "_submit_requests", submit) + r = Rester( + api_key="a" * 32, + db_version=db_version, + local_dataset_cache=tmp_path / "cache", + force_renew=force_renew, + mute_progress_bars=True, + delta_catalog=catalog, + ) + r.calls = calls + return r + + return make + + +def download(rester) -> MPDataset: + return rester._query_resource(criteria={})["data"] + + +def local_path(tmp_path) -> str: + return str(tmp_path / "cache" / "build" / "collections" / "chemenv") + + +def ids(dataset: MPDataset) -> set[str]: + return set(dataset.pyarrow_dataset.to_table()["material_id"].to_pylist()) + + +# -------------------------------------------------------------------------- +# Version strings +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "given, partition", + [ + ("2026.04.13", "2026-04-13"), + ("2026-04-13", "2026-04-13"), + ("2026_04_13", "2026-04-13"), + ("v2026.04.13", "2026-04-13"), + ("2026.04.13.post1", "2026-04-13-post1"), + ], +) +def test_version_conversion(given, partition): + assert to_partition_version(given) == partition + assert to_db_version(partition) == partition.replace("-", ".") + + +def test_pinned_version_is_normalized(make_rester): + assert make_rester(db_version="2026-04-13").db_version == "2026.04.13" + assert make_rester().db_version == "2026.09.28" # from the heartbeat + + +# -------------------------------------------------------------------------- +# Listing versions +# -------------------------------------------------------------------------- + + +def test_available_db_versions(make_rester): + assert make_rester().available_db_versions() == ["2026.04.13", "2026.09.28"] + + +def point_at(monkeypatch, uri, label="flat"): + """Make `_get_delta_table` load `uri` on the rester's own catalog.""" + monkeypatch.setattr( + BaseRester, + "_get_delta_table", + lambda self, *a, label=None, refresh=False, **k: self.delta_catalog.get_table( + uri, "flat", refresh=refresh + ), + ) + + +def test_available_db_versions_unversioned(make_rester, tmp_path, monkeypatch): + uri = str(tmp_path / "flat") + write_deltalake(uri, pa.table({"x": [1]})) + point_at(monkeypatch, uri) + with pytest.raises(MPRestError, match="not partitioned by database version"): + make_rester().available_db_versions() + + +def test_available_db_versions_not_delta_backed(make_rester): + r = make_rester() + r.delta_backed = False + with pytest.raises(MPRestError, match="not backed by a DeltaTable"): + r.available_db_versions() + + +def test_partition_row_counts(remote): + catalog = DeltaCatalog() + catalog.get_table(remote, "t") + assert catalog.partition_row_counts("t") == {"2026-04-13": 10, "2026-09-28": 6} + assert catalog.partition_row_counts("t", column="nope") is None + + +# -------------------------------------------------------------------------- +# Downloads +# -------------------------------------------------------------------------- + + +def test_downloads_only_the_current_version(make_rester, tmp_path): + data = download(make_rester()) + + assert data.version == "2026.09.28" + assert len(data) == 6 + assert ids(data) == {f"mp-2026-09-28-{i}" for i in range(6)} + assert "version" not in data.pyarrow_dataset.schema.names + local = DeltaTable(local_path(tmp_path)) + assert local.metadata().partition_columns == ["version"] + assert [p["version"] for p in local.partitions()] == ["2026-09-28"] + + +def test_pinned_older_version(make_rester): + data = download(make_rester(db_version="2026.04.13")) + assert data.version == "2026.04.13" and len(data) == 10 + + +def test_second_version_is_added_as_a_partition(make_rester, tmp_path): + download(make_rester()) + data = download(make_rester(db_version="2026.04.13")) + + assert len(data) == 10 + local = DeltaTable(local_path(tmp_path)) + assert sorted(p["version"] for p in local.partitions()) == [ + "2026-04-13", + "2026-09-28", + ] + # each version reads only its own rows + assert len(MPDataset(local_path(tmp_path), Doc, False, version="2026.09.28")) == 6 + assert len(MPDataset(local_path(tmp_path), Doc, False)) == 16 + + +def test_existing_version_is_returned_without_downloading(make_rester, caplog): + download(make_rester()) + r = make_rester() + with caplog.at_level(logging.WARNING, logger="mp_api.client.core.client"): + data = download(r) + + assert len(data) == 6 + assert "already exists" in caplog.text + assert r.calls["gnome"] == 1 # only the first download asked the API + + +def test_force_renew_replaces_only_that_version(make_rester, tmp_path): + download(make_rester(db_version="2026.04.13")) + download(make_rester()) + path = local_path(tmp_path) + old_files = set(os.listdir(os.path.join(path, "version=2026-09-28"))) + + data = download(make_rester(force_renew=True)) + + assert len(data) == 6 + new_files = set(os.listdir(os.path.join(path, "version=2026-09-28"))) + assert new_files and not (new_files & old_files) # replaced files are vacuumed + assert len(MPDataset(path, Doc, False, version="2026.04.13")) == 10 + + +def test_missing_version_lists_available(make_rester): + with pytest.raises(MPRestError) as exc: + download(make_rester(db_version="2030.01.01")) + assert "2030.01.01 is not available" in str(exc.value) + assert "2026.04.13, 2026.09.28" in str(exc.value) + + +def test_access_controlled_rows_are_filtered(make_rester): + assert len(download(make_rester(gnome=False))) == 5 # 6 minus 1 BY-NC + + +def one_row_batches(reader: pa.RecordBatchReader) -> pa.RecordBatchReader: + table = reader.read_all() + return pa.RecordBatchReader.from_batches( + table.schema, table.to_batches(max_chunksize=1) + ) + + +def test_multiple_flushes_are_one_commit(make_rester, tmp_path, monkeypatch): + monkeypatch.setattr(MAPI_CLIENT_SETTINGS, "DATASET_FLUSH_THRESHOLD", 1) + r = make_rester(db_version="2026.04.13") + stream = r.delta_catalog.execute_stream + + def small_batches(sql): # one row per batch, so each flushes separately + return one_row_batches(stream(sql)) + + monkeypatch.setattr(r.delta_catalog, "execute_stream", small_batches) + data = download(r) + + assert len(data) == 10 + assert len(data.pyarrow_dataset.files) == 10 + local = DeltaTable(local_path(tmp_path)) + assert local.version() == 0 # created in a single commit + + +def test_interrupted_download_leaves_table_unchanged( + make_rester, tmp_path, monkeypatch +): + download(make_rester()) + path = local_path(tmp_path) + before = DeltaTable(path).version() + files_before = sorted( + os.path.relpath(os.path.join(d, f), path) + for d, _, fs in os.walk(path) + for f in fs + if "_delta_log" not in d + ) + + monkeypatch.setattr(MAPI_CLIENT_SETTINGS, "DATASET_FLUSH_THRESHOLD", 1) + r = make_rester(db_version="2026.04.13") + stream = r.delta_catalog.execute_stream + + def failing(sql): # fails after the first batch has been flushed + reader = one_row_batches(stream(sql)) + + def batches(): + for i, batch in enumerate(reader): + if i == 2: + raise KeyboardInterrupt + yield batch + + return pa.RecordBatchReader.from_batches(reader.schema, batches()) + + monkeypatch.setattr(r.delta_catalog, "execute_stream", failing) + flushed = [] + write = client_module.ds.write_dataset + monkeypatch.setattr( + client_module.ds, + "write_dataset", + lambda *a, **k: flushed.append(1) or write(*a, **k), + ) + with pytest.raises(KeyboardInterrupt): + download(r) + assert flushed # files were written before the interruption + + assert DeltaTable(path).version() == before + files_after = sorted( + os.path.relpath(os.path.join(d, f), path) + for d, _, fs in os.walk(path) + for f in fs + if "_delta_log" not in d + ) + assert files_after == files_before # written-but-uncommitted files removed + + +def test_legacy_unpartitioned_local_table(make_rester, tmp_path): + path = local_path(tmp_path) + write_deltalake(path, pa.table({"material_id": ["old"]})) + + with pytest.raises(MPRestError, match="not partitioned by database version"): + download(make_rester()) + + data = download(make_rester(force_renew=True)) + assert len(data) == 6 and data.version == "2026.09.28" + + +def test_legacy_non_delta_local_dir(make_rester, tmp_path): + path = local_path(tmp_path) + os.makedirs(path) + pa.parquet.write_table(pa.table({"x": [1]}), os.path.join(path, "a.parquet")) + + with pytest.raises(MPRestError, match="is not a DeltaTable"): + download(make_rester()) + + +def test_unversioned_remote_table(make_rester, tmp_path, monkeypatch): + uri = str(tmp_path / "flat") + rows = remote_rows("x", 4).drop_columns(["version"]) + write_deltalake(uri, rows) + point_at(monkeypatch, uri) + data = download(make_rester()) + assert len(data) == 4 and data.version is None + assert DeltaTable(local_path(tmp_path)).metadata().partition_columns == [] + + # force_renew replaces the whole (unversioned) table + write_deltalake( + uri, remote_rows("x", 7).drop_columns(["version"]), mode="overwrite" + ) + assert len(download(make_rester(force_renew=True))) == 7 + + +# -------------------------------------------------------------------------- +# MPDataset +# -------------------------------------------------------------------------- + + +def test_mpdataset_skips_files_not_in_the_log(make_rester, tmp_path): + download(make_rester()) + path = local_path(tmp_path) + stray = os.path.join(path, "version=2026-09-28", "stray.parquet") + pa.parquet.write_table( + remote_rows("2026-09-28", 3).drop_columns(["version"]), stray + ) + + assert len(MPDataset(path, Doc, False, version="2026.09.28")) == 6 + + +def test_mpdataset_version_with_no_rows(make_rester, tmp_path): + download(make_rester()) + data = MPDataset(local_path(tmp_path), Doc, False, version="2001.01.01") + assert len(data) == 0 + assert "material_id" in data.pyarrow_dataset.schema.names + + +# -------------------------------------------------------------------------- +# Counting status line +# -------------------------------------------------------------------------- + + +def plain(text: str) -> str: + return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text) + + +@pytest.fixture +def term(): + buf = io.StringIO() + display.set_console( + Console(file=buf, force_terminal=True, force_jupyter=False, width=160) + ) + yield buf + display.set_console(None) + + +def test_count_shows_status_then_removes_it(make_rester, term, monkeypatch): + r = make_rester(gnome=False) + r.mute_progress_bars = False + execute = r.delta_catalog.execute + seen = {} + + def spy(sql, label=None): + if "COUNT(*)" in sql: + tasks = display._STATE.progress.tasks + seen["status"] = [t.description for t in tasks if t.fields.get("status")] + return execute(sql, label=label) + + monkeypatch.setattr(r.delta_catalog, "execute", spy) + data = download(r) + + assert len(data) == 5 + assert seen["status"] and "Counting chemenv (v2026.09.28)" in seen["status"][0] + assert display._STATE.progress is None # status and bar both gone + assert "✓ Downloaded 5 Doc documents (v2026.09.28)" in plain(term.getvalue()) + + +def test_count_logs_once_without_live_display(make_rester, caplog): + r = make_rester(gnome=False) # muted progress bars -> no live display + with caplog.at_level(logging.WARNING, logger="mp_api.client.core.client"): + download(r) + # (caplog may see a record twice: pytest's handler is on both the root + # and, via the default handler's forwarding, mp_api.client) + messages = {id(rec) for rec in caplog.records if "Counting chemenv" in rec.message} + assert len(messages) == 1 + + +def test_no_count_query_with_full_access(make_rester, monkeypatch): + r = make_rester(gnome=True) + execute = r.delta_catalog.execute + queries = [] + monkeypatch.setattr( + r.delta_catalog, + "execute", + lambda sql, label=None: queries.append(sql) or execute(sql, label=label), + ) + download(r) + assert not any("COUNT" in q for q in queries) # read from the Delta log + + +def test_failed_count_downloads_without_total(make_rester, monkeypatch, caplog): + r = make_rester(gnome=False) + + def fail(sql, label=None): + raise RuntimeError("S3 timeout") + + monkeypatch.setattr(r.delta_catalog, "execute", fail) + with caplog.at_level(logging.WARNING, logger="mp_api.client.core.client"): + data = download(r) + assert len(data) == 5 + assert "downloading without a total" in caplog.text + + +def test_status_helper_without_display_and_logger_is_silent(caplog): + with caplog.at_level(logging.DEBUG): + with display.status("quiet", enabled=False): + pass + assert caplog.text == "" + + +# -------------------------------------------------------------------------- +# Log output after a download +# -------------------------------------------------------------------------- + + +def info_messages(caplog) -> list[str]: + return list( + dict.fromkeys( # caplog may hold a record twice, see above + r.getMessage() for r in caplog.records if r.levelno >= logging.INFO + ) + ) + + +def test_path_shown_once_with_progress_bar(make_rester, term, caplog): + r = make_rester() + r.mute_progress_bars = False + with caplog.at_level(logging.DEBUG, logger="mp_api.client.core.client"): + download(r) + + out = plain(term.getvalue()) + assert "✓ Downloaded 6 Doc documents (v2026.09.28) to" in out + assert not any("written to" in m for m in info_messages(caplog)) + debug = [r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG] + assert any("delta-rs and pyarrow documentation" in m for m in debug) + + +def test_path_logged_when_progress_muted(make_rester, caplog): + with caplog.at_level(logging.INFO, logger="mp_api.client.core.client"): + download(make_rester()) # muted: no summary line + + messages = info_messages(caplog) + written = [m for m in messages if "written to" in m] + assert len(written) == 1 + assert written[0].startswith("Dataset for chemenv (v2026.09.28) written to ") + assert not any("delta-rs and pyarrow documentation" in m for m in messages) + + +def test_bar_replaced_by_status_while_committing(make_rester, term, monkeypatch): + r = make_rester() + r.mute_progress_bars = False + commit = BaseRester._commit_delta_download + seen = {} + + def spy(*args, **kwargs): + tasks = display._STATE.progress.tasks + seen["visible"] = [ + (t.description, bool(t.fields.get("status"))) for t in tasks if t.visible + ] + return commit(*args, **kwargs) + + monkeypatch.setattr(BaseRester, "_commit_delta_download", staticmethod(spy)) + download(r) + + # only the status line is visible during the commit, the bar is hidden + assert len(seen["visible"]) == 1 + description, is_status = seen["visible"][0] + assert is_status and "to the local DeltaTable" in description + # the summary is still printed, after the commit + assert "✓ Downloaded 6 Doc documents (v2026.09.28)" in plain(term.getvalue()) + assert display._STATE.progress is None + + +def test_hidden_bar_still_summarizes(term): + with display.progress_bar("work", total=3, summary="Did {completed}") as bar: + bar.update(3) + bar.hide() + assert not display._STATE.progress.tasks[0].visible + bar.update(1) # counted, not redrawn + assert not display._STATE.progress.tasks[0].visible + assert "✓ Did 4" in plain(term.getvalue()) + + +# -------------------------------------------------------------------------- +# Per-call db_version +# -------------------------------------------------------------------------- + + +def test_per_call_version_does_not_change_rester(make_rester): + r = make_rester() # rester default: 2026.09.28 (heartbeat) + old = r._query_resource(criteria={}, db_version="2026.04.13")["data"] + current = download(r) + + assert (old.version, len(old)) == ("2026.04.13", 10) + assert (current.version, len(current)) == ("2026.09.28", 6) + assert r.db_version == "2026.09.28" + + +def test_search_passes_db_version(make_rester): + data = make_rester()._search(db_version="2026-04-13") + assert data.version == "2026.04.13" and len(data) == 10 + + +@pytest.mark.parametrize("where", ["call", "rester"]) +def test_latest_resolves_to_newest_partition(make_rester, monkeypatch, where): + # The API still serves the older version, S3 already has a newer one + monkeypatch.setattr( + BaseRester, + "_get_heartbeat_info", + staticmethod(lambda endpoint: ("2026.04.13", ["gnome_r2scan_statics"])), + ) + if where == "call": + r = make_rester() + data = r._query_resource(criteria={}, db_version="LATEST")["data"] + assert r.db_version == "2026.04.13" + else: + r = make_rester(db_version="latest") + assert r.db_version == "latest" + data = download(r) + assert data.version == "2026.09.28" and len(data) == 6 + + +def test_missing_per_call_version(make_rester): + with pytest.raises(MPRestError, match="2030.01.01 is not available"): + make_rester()._query_resource(criteria={}, db_version="2030.01.01") + + +def test_filtered_query_with_other_version_raises(make_rester, monkeypatch): + r = make_rester() + monkeypatch.setattr( + r, "_submit_requests", lambda **kw: pytest.fail("REST must not be queried") + ) + with pytest.raises(MPRestError, match="only applies to full dataset downloads"): + r._query_resource(criteria={"nsites_min": 1}, db_version="2026.04.13") + + +def test_filtered_query_with_current_version_goes_to_rest(make_rester, monkeypatch): + r = make_rester() + seen = {} + monkeypatch.setattr( + r, "_submit_requests", lambda **kw: seen.update(kw) or {"data": [], "meta": {}} + ) + r._query_resource(criteria={"nsites_min": 1}, db_version="2026-09-28") + assert seen["criteria"]["nsites_min"] == 1 + + +def test_filtered_query_with_latest(make_rester, monkeypatch): + r = make_rester() + monkeypatch.setattr(r, "_submit_requests", lambda **kw: {"data": [], "meta": {}}) + # newest on S3 == the API's version -> fine + r._query_resource(criteria={"nsites_min": 1}, db_version="latest") + + r.current_db_version = "2026.04.13" # API behind S3 -> can't serve "latest" + with pytest.raises(MPRestError, match="serves database version 2026.04.13"): + r._query_resource(criteria={"nsites_min": 1}, db_version="latest") + + +def test_unversioned_dataset_ignores_per_call_version( + make_rester, tmp_path, monkeypatch, caplog +): + uri = str(tmp_path / "flat") + write_deltalake(uri, remote_rows("x", 4).drop_columns(["version"])) + point_at(monkeypatch, uri) + with caplog.at_level(logging.WARNING, logger="mp_api.client.core.client"): + data = make_rester()._query_resource(criteria={}, db_version="2026.04.13") + assert len(data["data"]) == 4 + assert "has a single version, ignoring db_version='2026.04.13'" in caplog.text + + +def _route_classes(): + """Every rester class reachable from MPRester, keyed by suffix.""" + from mp_api.client.mprester import GENERIC_RESTERS, RESTER_LAYOUT + + classes = {} + for lazy in (RESTER_LAYOUT | GENERIC_RESTERS).values(): + lazy._load() + cls = lazy._imported + if isinstance(cls, type) and issubclass(cls, BaseRester) and cls.suffix: + classes[cls.suffix] = cls + return classes + + +def _location(cls): + return cls._s3_location(cls.__new__(cls)) + + +@pytest.mark.parametrize( + "suffix, bucket, prefix", + [ + ("materials/summary", "materialsproject-build", "collections/summary"), + ("materials/core", "materialsproject-build", "collections/materials"), + ( + "materials/oxidation_states", + "materialsproject-build", + "collections/oxidation-states", + ), + ("materials/tasks", "materialsproject-parsed", "core/tasks"), + ("materials/xas", "materialsproject-build", "static-collections/xas"), + ( + "materials/surface_properties", + "materialsproject-build", + "static-collections/surface-properties", + ), + ( + "materials/grain_boundaries", + "materialsproject-build", + "static-collections/grain-boundaries", + ), + ( + "materials/synthesis", + "materialsproject-build", + "static-collections/synth-descriptions", + ), + ("materials/eos", "materialsproject-build", "static-collections/eos"), + ("materials/phonon", "materialsproject-build", "static-collections/phonon"), + ("molecules/summary", "materialsproject-build", "static-collections/molecules"), + ("molecules/jcesr", "materialsproject-build", "static-collections/jcesr"), + ], +) +def test_s3_locations(suffix, bucket, prefix): + assert _location(_route_classes()[suffix])[1:] == (bucket, prefix) + + +@pytest.mark.parametrize( + "suffix", + [ + "materials/xas", + "materials/surface_properties", + "materials/grain_boundaries", + "materials/synthesis", + ], +) +def test_migrated_static_collections_are_delta_backed(suffix): + assert _route_classes()[suffix].delta_backed + + +def test_db_version_only_on_versioned_routes(): + """`db_version` is offered by a route's search methods exactly when it can be + honored: the route is delta-backed and its dataset lives under collections/ + (partitioned by version).""" + wrong = [] + for suffix, cls in _route_classes().items(): + versioned = cls.delta_backed and _location(cls)[2].startswith("collections/") + for name in ("search", "search_docs"): + method = cls.__dict__.get(name) or next( + (b.__dict__[name] for b in cls.__mro__[1:-1] if name in b.__dict__ + and b is not BaseRester), + None, + ) + if method is None: + continue + source = inspect.getsource(method) + if "_search(" not in source: + continue # e.g. text searches that never reach S3 + takes = "db_version" in inspect.signature(method).parameters + passes = "db_version=db_version" in source + if takes != versioned or passes != versioned: + wrong.append(f"{suffix}.{name}: takes={takes} passes={passes}") + assert not wrong + assert "db_version" in inspect.signature(BaseRester._search).parameters + + +def test_phase_diagram_version(make_rester, monkeypatch): + r = make_rester() + queries = [] + monkeypatch.setattr( + BaseRester, + "_query_delta_single", + lambda self, q, label=None: queries.append(q) or pa.table({"phase_diagram": []}), + ) + from mp_api.client.routes.materials.thermo import ThermoRester + + thermo = ThermoRester.__new__(ThermoRester) + thermo.__dict__.update(r.__dict__) + thermo.get_phase_diagram_from_chemsys("O-Li") + thermo.get_phase_diagram_from_chemsys("O-Li", db_version="2026.04.13") + thermo.get_phase_diagram_from_chemsys("O-Li", db_version="latest") + versions = [re.search(r"version='([^']+)'", q).group(1) for q in queries] + assert versions == ["2026-09-28", "2026-04-13", "2026-09-28"] + + +# -------------------------------------------------------------------------- +# MPRester.available_db_versions +# -------------------------------------------------------------------------- + + +@pytest.fixture +def mpr(make_rester, tmp_path, remote, monkeypatch): + from mp_api.client import MPRester + + monkeypatch.setattr(MPRester, "get_emmet_version", staticmethod(lambda ep: None)) + flat = str(tmp_path / "flat") + write_deltalake(flat, pa.table({"x": [1]})) + + def get_delta_table(self, bucket, prefix, connector="s3a", label=None, refresh=False): + if prefix.endswith("chemenv") or prefix.endswith("summary"): + name = prefix.rsplit("/", 1)[-1] + return self.delta_catalog.get_table(remote, name, refresh=refresh) + if prefix.endswith("bonds"): + return self.delta_catalog.get_table(flat, "bonds", refresh=refresh) + raise FileNotFoundError(f"no table at {prefix}") + + monkeypatch.setattr(BaseRester, "_get_delta_table", get_delta_table) + return MPRester(api_key="a" * 32, mute_progress_bars=True) + + +def test_mprester_lists_all_versioned_collections(mpr): + assert mpr.available_db_versions() == { + # same remote table used for both; bonds (unversioned) and + # collections that fail to load are left out + "chemenv": ["2026.04.13", "2026.09.28"], + "summary": ["2026.04.13", "2026.09.28"], + } + + +def test_mprester_skips_unversioned_routes_without_importing(mpr): + routes = set(mpr._versioned_resters()) + assert not routes & {"tasks", "similarity", "synthesis", "eos", "phonon", "xas"} + assert {"summary", "chemenv", "thermo"} <= routes + + +@pytest.mark.parametrize("name", ["summary", "materials/summary"]) +def test_mprester_single_collection(mpr, name): + assert mpr.available_db_versions(name) == ["2026.04.13", "2026.09.28"] + + +def test_mprester_unknown_collection(mpr): + with pytest.raises(MPRestError, match="Unknown collection 'nope'"): + mpr.available_db_versions("nope") + + +def test_mprester_listing_shows_status(mpr, term, monkeypatch): + mpr.mute_progress_bars = False + calls = [] + original = BaseRester.available_db_versions + + def spy(self): + tasks = display._STATE.progress.tasks + calls.append([t.description for t in tasks if t.fields.get("status")]) + return original(self) + + monkeypatch.setattr(BaseRester, "available_db_versions", spy) + mpr.available_db_versions() + assert calls and all( + any("Fetching available database versions" in d for d in c) for c in calls + ) + assert display._STATE.progress is None + + +def test_mprester_latest_repr_and_warning(make_rester, monkeypatch, caplog): + from mp_api.client import MPRester + + monkeypatch.setattr(MPRester, "get_emmet_version", staticmethod(lambda ep: None)) + with caplog.at_level(logging.WARNING, logger="mp_api.client.mprester"): + m = MPRester(api_key="a" * 32, db_version="Latest") + assert repr(m) == "MPRester(latest)" + assert m.materials.summary.db_version == "latest" + assert "newest database version on S3" in caplog.text + + +# -------------------------------------------------------------------------- +# Static collections: unversioned, partitioned by other columns +# -------------------------------------------------------------------------- + + +class EdgeDoc(BaseModel): + material_id: str + edge: str + absorbing_element: str + + +class EdgeRester(BaseRester): + suffix = "materials/xas" + document_model = EdgeDoc + + +def test_static_collection_partitioned_by_other_columns( + make_rester, tmp_path, monkeypatch +): + uri = str(tmp_path / "xas") + write_deltalake( + uri, + pa.table( + { + "material_id": ["mp-1", "mp-2", "mp-3"], + "edge": ["K", "L3", "K"], + "absorbing_element": ["Fe", "Fe", "O"], + } + ), + partition_by=["edge"], + ) + point_at(monkeypatch, uri) + make_rester() # patches the GNoMe check and heartbeat + r = EdgeRester( + api_key="a" * 32, + local_dataset_cache=tmp_path / "cache", + mute_progress_bars=True, + ) + data = r._query_resource(criteria={})["data"] + + assert data.version is None and len(data) == 3 + table = data.pyarrow_dataset.to_table() + assert sorted(table["edge"].to_pylist()) == ["K", "K", "L3"] # kept as a column + local = DeltaTable(str(tmp_path / "cache" / "build" / "static-collections" / "xas")) + assert local.metadata().partition_columns == [] + + +def test_synthesis_downloads_recipes(make_rester, tmp_path, monkeypatch): + from emmet.core.arrow import arrowize + from emmet.core.synthesis import SynthesisRecipe + + from mp_api.client.routes.materials.synthesis import SynthesisRester + + schema = pa.schema(arrowize(SynthesisRecipe)) + assert "search_score" not in schema.names and "highlights" not in schema.names + uri = str(tmp_path / "synth") + write_deltalake(uri, schema.empty_table(), partition_by=["synthesis_type"]) + point_at(monkeypatch, uri) + make_rester() + r = SynthesisRester( + api_key="a" * 32, + local_dataset_cache=tmp_path / "cache", + mute_progress_bars=True, + ) + assert r._download_schema() == schema + + calls = [] + monkeypatch.setattr( + BaseRester, + "_query_delta_backed", + lambda self, **kw: calls.append(kw) or {"data": "downloaded"}, + ) + assert r.search() == "downloaded" # no search terms: full download + assert calls[0]["prefix"] == "static-collections/synth-descriptions" + + monkeypatch.setattr( + BaseRester, "_submit_requests", lambda self, **kw: {"data": ["rest"], "meta": {}} + ) + assert r.search(keywords=["silicon"]) == ["rest"] # text search stays on REST + assert len(calls) == 1 From 6d7644860764a0fb72e7cfd789eb06060cbf4d41 Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:41:03 -0700 Subject: [PATCH 12/30] more cohesive client->user communication --- mp_api/client/__init__.py | 9 +- mp_api/client/contribs/_logger.py | 14 +- mp_api/client/contribs/client.py | 10 +- mp_api/client/contribs/settings.py | 6 +- mp_api/client/core/_display.py | 224 +++++++++++++--- mp_api/client/core/_oxygen_evolution.py | 6 +- mp_api/client/core/client.py | 27 +- mp_api/client/core/exceptions.py | 12 +- mp_api/client/core/settings.py | 15 +- mp_api/client/core/utils.py | 9 +- mp_api/client/mprester.py | 79 +++--- mp_api/client/routes/materials/electrodes.py | 7 +- .../routes/materials/electronic_structure.py | 6 +- mp_api/client/routes/materials/eos.py | 4 +- mp_api/client/routes/materials/summary.py | 10 +- mp_api/client/routes/materials/xas.py | 4 +- mp_api/client/routes/molecules/jcesr.py | 6 +- mp_api/mcp/server.py | 4 + tests/client/core/test_delta.py | 12 +- tests/client/core/test_delta_versions.py | 62 ++++- tests/client/core/test_display.py | 26 +- tests/client/core/test_quiet.py | 250 ++++++++++++++++++ tests/client/test_client.py | 2 +- tests/client/test_core_client.py | 2 +- tests/client/test_mprester.py | 4 +- tests/conftest.py | 23 ++ 26 files changed, 636 insertions(+), 197 deletions(-) create mode 100644 tests/client/core/test_quiet.py create mode 100644 tests/conftest.py diff --git a/mp_api/client/__init__.py b/mp_api/client/__init__.py index 6fde0e19..52a3c80f 100644 --- a/mp_api/client/__init__.py +++ b/mp_api/client/__init__.py @@ -6,18 +6,19 @@ from importlib.metadata import PackageNotFoundError, version from mp_api.client.core._display import ( - _install_default_logging, - disable_logging, + _configure_from_settings, enable_logging, + is_quiet, + quiet, ) from mp_api.client.core.exceptions import MPRestError from mp_api.client.mprester import MPRester -__all__ = ["MPRestError", "MPRester", "disable_logging", "enable_logging"] +__all__ = ["MPRestError", "MPRester", "enable_logging", "is_quiet", "quiet"] try: __version__ = version("mp_api") except PackageNotFoundError: # pragma: no cover __version__ = os.getenv("SETUPTOOLS_SCM_PRETEND_VERSION", "") -_install_default_logging() +_configure_from_settings() diff --git a/mp_api/client/contribs/_logger.py b/mp_api/client/contribs/_logger.py index f69796d9..a71c9876 100644 --- a/mp_api/client/contribs/_logger.py +++ b/mp_api/client/contribs/_logger.py @@ -1,13 +1,15 @@ """Logger for the MPContribs client. Records go through the `mp_api.client` logger's default handler (stderr), -see `mp_api.client.core._display`. No handlers are attached here. +see `mp_api.client.core._display`. No handlers or level are set here: the +level is configured once, for the whole client, with `MPRESTER_LOG_LEVEL`. """ from __future__ import annotations import logging import os +import warnings from mp_api.client.contribs.settings import MPCC_SETTINGS @@ -33,8 +35,16 @@ def get_logger(name: str = "mp_api.client.contribs") -> CustomLoggerAdapter: process = os.environ.get("SUPERVISOR_PROCESS_NAME") group = os.environ.get("SUPERVISOR_GROUP_NAME") cfg = {"prefix": f"{group}/{process}"} if process and group else {} - logger.setLevel(MPCC_SETTINGS.CLIENT_LOG_LEVEL) return CustomLoggerAdapter(logger, cfg) +if MPCC_SETTINGS.CLIENT_LOG_LEVEL is not None: + warnings.warn( + "MPCONTRIBS_CLIENT_LOG_LEVEL is deprecated and ignored, use MPRESTER_LOG_LEVEL " + "(which sets the level for the whole client) instead.", + FutureWarning, + stacklevel=2, + ) + + MPCC_LOGGER = get_logger() diff --git a/mp_api/client/contribs/client.py b/mp_api/client/contribs/client.py index 88284f8f..55369f82 100644 --- a/mp_api/client/contribs/client.py +++ b/mp_api/client/contribs/client.py @@ -86,8 +86,6 @@ VALID_OPS = {"query", "create", "update", "delete", "download"} VALID_OPS_T = Literal[*VALID_OPS] # type: ignore[valid-type] -warnings.filterwarnings("default", category=DeprecationWarning, module=__name__) - def validate_email(email_string: str) -> None: """Validate user email address. @@ -484,7 +482,7 @@ def __init__( ) else: api_key = kwargs.pop("apikey") - MPCC_LOGGER.warning(api_key_warn) + warnings.warn(api_key_warn, FutureWarning, stacklevel=2) if api_key and len(api_key) != 32: raise MPContribsClientError(f"Invalid API key: {api_key}") @@ -534,9 +532,11 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None: @property def apikey(self) -> str | None: """Handle deprecated `apikey` attr.""" - MPCC_LOGGER.warning( + warnings.warn( "`apikey` has been deprecated in favor of `api_key` for " - " consistency with the Materials Project API client." + "consistency with the Materials Project API client.", + FutureWarning, + stacklevel=2, ) return self.api_key diff --git a/mp_api/client/contribs/settings.py b/mp_api/client/contribs/settings.py index 172c0369..f6dea208 100644 --- a/mp_api/client/contribs/settings.py +++ b/mp_api/client/contribs/settings.py @@ -59,7 +59,11 @@ class ContribsClientSettings(BaseSettings): API_KEY: str | None = Field(None, description="The user's 32-character API key.") - CLIENT_LOG_LEVEL: str = Field("INFO") + CLIENT_LOG_LEVEL: str | None = Field( + None, + description="DEPRECATED and ignored: use MPRESTER_LOG_LEVEL, " + "which sets the level for the whole client.", + ) model_config = SettingsConfigDict(env_prefix="MPCONTRIBS_") diff --git a/mp_api/client/core/_display.py b/mp_api/client/core/_display.py index 575911f1..51448866 100644 --- a/mp_api/client/core/_display.py +++ b/mp_api/client/core/_display.py @@ -1,4 +1,4 @@ -"""Terminal / notebook output for the client: theme, progress bars and logging. +"""Terminal / notebook output for the client: theme, progress bars, logging, quiet mode. All output goes to a single `rich.console.Console` writing to **stderr**, so stdout stays clean (required by e.g. the MCP server's stdio transport). @@ -24,7 +24,55 @@ any ancestor of `mp_api.client`), records are passed to those handlers instead, honouring the application's levels, so nothing is printed twice. -Opt out with `MPRESTER_LOGGING=false` or `mp_api.client.disable_logging()`. +Configuration has a single owner: only the `mp_api.client` logger is given a +level, handler or propagation setting, here. Child loggers (`mp_api.client.*`, +including contribs) never set their own level, so one setting controls all of +them. Use `MPRESTER_LOG_LEVEL` or `enable_logging(level)`. + +Quiet mode +---------- +`quiet()` (or `MPRESTER_QUIET=true`, or `MPRester(quiet=True)`) silences the +client for the whole process: no log records, progress bars, status lines, +notices or `MPRestWarning`s. Deprecation warnings (`FutureWarning`) and +exceptions are still raised. It can be used as a context manager. + +Which channel to use +-------------------- +- The operation failed: raise an exception. +- A deprecated API was used: `warnings.warn(..., FutureWarning)`, which is + shown by default, also when called from other libraries. +- Something the caller can change in their call (an ignored argument, misuse, + a caveat about what the data means): `mp_warning(...)` (an `MPRestWarning`). +- A runtime or data event the caller can't change in code (server + unreachable, data missing, a local dataset reused): `logger.warning/info`. +- Diagnostics: `logger.debug`. +- Transient progress: `progress_bar` / `status`, interactive output only. +- Output the user explicitly asked for (`notify_db_version`): `print_notice`. + +So WARNING-level log records always mean something went wrong, and +`MPRestWarning` / `FutureWarning` always mean the caller can fix something. + +Using mp-api in an application +------------------------------ +E.g. a web server that wants to know about problems and misuse, but no +progress output: + +```python +import logging + +logging.basicConfig(level=logging.WARNING) # or the app's own logging config +logging.captureWarnings(True) # MPRestWarning / FutureWarning into the logs + +from mp_api.client import MPRester + +mpr = MPRester(mute_progress_bars=True) # or MPRESTER_MUTE_PROGRESS_BARS=true +``` + +Client log records go to the application's handlers, at its levels. Without +an application logging config, set `MPRESTER_LOG_LEVEL=WARNING` instead. +Python shows a given warning once per code location; use +`warnings.simplefilter("always", MPRestWarning)` to see every occurrence, or +`"error"` (e.g. in the application's tests) to fail on misuse. """ from __future__ import annotations @@ -32,8 +80,9 @@ import logging import threading import time +import warnings from contextlib import contextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self from rich.console import Console from rich.logging import RichHandler @@ -61,11 +110,13 @@ __all__ = [ "MP_THEME", - "disable_logging", "enable_logging", "get_console", + "is_quiet", + "mp_warning", "print_notice", "progress_bar", + "quiet", "set_console", "status", ] @@ -150,6 +201,8 @@ def print_notice(message: str, style: str | None = None) -> None: message (str) : text to print style (str or None) : theme style, e.g. "mp.warning" """ + if is_quiet(): + return get_console().print(Text(message, style=style or "")) @@ -343,7 +396,7 @@ def progress_bar( ProgressHandle """ console = get_console() - if not enabled or not _is_interactive(console): + if not enabled or is_quiet() or not _is_interactive(console): yield ProgressHandle(total) return @@ -389,29 +442,26 @@ def progress_bar( @contextmanager -def status( - message: str, - *, - enabled: bool = True, - logger: logging.Logger | None = None, -) -> Iterator[None]: +def status(message: str, *, enabled: bool = True) -> Iterator[None]: """Show a transient status line while a slow step runs. In a terminal or notebook with a live display, an amber spinner line is - shown and removed when the block exits. Otherwise (logs, CI, or - `enabled=False`), `message` is logged once as a warning via `logger`, - since a log line can't be taken back. + shown and removed when the block exits. If progress output is enabled but + can't be drawn live (logs, CI, Jupyter without ipywidgets), `message` is + logged once at INFO instead (it's progress, not a problem, so it stays out + of WARNING-level logs). If disabled (muted progress bars) or in quiet mode, + nothing is shown or logged. Args: message (str) : what is happening, e.g. "Counting documents..." - enabled (bool) : if False, skip the spinner and only log - logger (logging.Logger or None) : logger for the fallback warning. - Nothing is logged if None. + enabled (bool) : if False, show nothing """ console = get_console() - if not (enabled and _is_interactive(console) and _can_draw_live(console)): - if logger is not None: - logger.warning(message) + if not enabled or is_quiet(): + yield + return + if not (_is_interactive(console) and _can_draw_live(console)): + logging.getLogger(LOGGER_NAME).info(message) yield return @@ -493,10 +543,21 @@ def _installed_handler(logger: logging.Logger) -> _MPDefaultHandler | None: return next((h for h in logger.handlers if isinstance(h, _MPDefaultHandler)), None) +def _parse_level(level: int | str) -> int: + """Log level as an int, from an int or a name like "warning".""" + if isinstance(level, int): + return level + try: + return logging.getLevelNamesMapping()[level.strip().upper()] + except KeyError: + raise ValueError(f"Unknown log level {level!r}") from None + + def enable_logging(level: int | str | None = None) -> None: - """Show messages from the client on stderr (the default). + """Show messages from the client on stderr (the default), at `level`. - Safe to call repeatedly; only one handler is ever installed. + Safe to call repeatedly; only one handler is ever installed. In quiet + mode the level is remembered and applied when quiet mode ends. Args: level (int, str or None) : minimum level to show, e.g. "WARNING". @@ -505,34 +566,117 @@ def enable_logging(level: int | str | None = None) -> None: from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS logger = logging.getLogger(LOGGER_NAME) + level = _parse_level(level or MAPI_CLIENT_SETTINGS.LOG_LEVEL) with _handler_lock: if _installed_handler(logger) is None: - # Remove the placeholder NullHandler, if any - for h in [h for h in logger.handlers if type(h) is logging.NullHandler]: - logger.removeHandler(h) logger.addHandler(_MPDefaultHandler(logger)) # The handler forwards to the application's handlers itself logger.propagate = False - logger.setLevel(level or MAPI_CLIENT_SETTINGS.LOG_LEVEL) + if _QUIET.enabled: + _QUIET.saved_level = level + else: + logger.setLevel(level) + + +# -------------------------------------------------------------------------- +# Quiet mode +# -------------------------------------------------------------------------- + +# Above CRITICAL: every record from mp_api.client (and its children, which +# never set their own level) is dropped before reaching any handler. +_SILENT = logging.CRITICAL + 1 + + +class _QuietState: + """Process-wide quiet flag. `saved_level` is the log level to restore.""" + + def __init__(self) -> None: + self.enabled = False + self.saved_level: int = logging.NOTSET + + +_QUIET = _QuietState() -def disable_logging() -> None: - """Stop the client printing messages; standard logging propagation resumes.""" +class _QuietContext: + """Returned by `quiet()`; restores the previous state when used with `with`.""" + + def __init__(self, previous: bool) -> None: + self._previous = previous + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc_info: object) -> None: + _set_quiet(self._previous) + + +def _set_quiet(enabled: bool) -> bool: + """Turn quiet mode on or off, returning the previous state.""" logger = logging.getLogger(LOGGER_NAME) with _handler_lock: - if (handler := _installed_handler(logger)) is not None: - logger.removeHandler(handler) - if not logger.handlers: - logger.addHandler(logging.NullHandler()) - logger.propagate = True - logger.setLevel(logging.NOTSET) + previous = _QUIET.enabled + if enabled and not previous: + _QUIET.saved_level = logger.level + logger.setLevel(_SILENT) + elif previous and not enabled: + logger.setLevel(_QUIET.saved_level) + _QUIET.enabled = enabled + return previous + + +def quiet(enabled: bool = True) -> _QuietContext: + """Silence all output from the client in this process. + + While quiet, the client emits no log records (they don't reach the + application's handlers either), progress bars, status lines, notices or + `MPRestWarning`s. Deprecation warnings and exceptions are unaffected. + + Takes effect immediately; as a context manager, the previous state is + restored on exit: + + ```python + from mp_api.client import quiet + + quiet() # for the rest of the process + with quiet(): # only within the block + ... + ``` + + Args: + enabled (bool) : True to silence the client, False to restore output. + + Returns: + a context manager that restores the previous state on exit + """ + return _QuietContext(_set_quiet(enabled)) + + +def is_quiet() -> bool: + """Whether quiet mode is on, see `quiet()`.""" + return _QUIET.enabled + + +def mp_warning(message: str, stacklevel: int = 1) -> None: + """Emit an `MPRestWarning`, unless in quiet mode. + + For things the caller can change in their call, see the module docstring. + + Args: + message (str) : warning text + stacklevel (int) : as for `warnings.warn`, relative to the caller + """ + if _QUIET.enabled: + return + from mp_api.client.core.exceptions import MPRestWarning + + warnings.warn(message, MPRestWarning, stacklevel=stacklevel + 1) -def _install_default_logging() -> None: - """Install the default handler on import, unless disabled by settings.""" +def _configure_from_settings() -> None: + """Configure client output from settings, on import of `mp_api.client`.""" from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS - if MAPI_CLIENT_SETTINGS.LOGGING: - enable_logging() - else: - logging.getLogger(LOGGER_NAME).addHandler(logging.NullHandler()) + enable_logging() + if MAPI_CLIENT_SETTINGS.QUIET: + quiet(True) diff --git a/mp_api/client/core/_oxygen_evolution.py b/mp_api/client/core/_oxygen_evolution.py index 39d1ec9d..b48cc803 100644 --- a/mp_api/client/core/_oxygen_evolution.py +++ b/mp_api/client/core/_oxygen_evolution.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import warnings from collections.abc import Sequence from io import StringIO from pathlib import Path @@ -16,7 +15,7 @@ from scipy.constants import Avogadro, Boltzmann, atm, elementary_charge from scipy.interpolate import make_splrep, splev -from mp_api.client.core.exceptions import MPRestWarning +from mp_api.client.core._display import mp_warning DEFAULT_CACHE_FILE = Path(__file__).absolute().parent / "JANAF_O2_data.json" # O2 partial pressure at ambient conditions, in MPa @@ -122,11 +121,10 @@ def mu_to_temp_spline( (mu_arr < min(NIST_JANAF_O2_MU_T["mu-mu_0K"])) | (mu_arr > max(NIST_JANAF_O2_MU_T["mu-mu_0K"])) ): - warnings.warn( + mp_warning( "Some of the input chemical potential values are " "outside the fitting range - extrapolation will be inaccurate.", stacklevel=2, - category=MPRestWarning, ) return splev(mu_arr, self.mu_to_temp_spline_params()) diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py index 1da5b289..d7d9eb8a 100644 --- a/mp_api/client/core/client.py +++ b/mp_api/client/core/client.py @@ -47,11 +47,10 @@ from urllib3.util.retry import Retry from mp_api.client._server_utils import get_consumer, get_user_api_key, is_dev_env -from mp_api.client.core._display import ProgressHandle, progress_bar, status +from mp_api.client.core._display import ProgressHandle, mp_warning, progress_bar, status from mp_api.client.core.delta import DeltaCatalog from mp_api.client.core.exceptions import ( MPRestError, - MPRestWarning, _emit_status_warning, ) from mp_api.client.core.schemas import _convert_to_model, _DictLikeAccess @@ -139,7 +138,7 @@ def __init__(self, catalog: DeltaCatalog | None = None, _warn: bool = True) -> N "QueryBuilderWithCache is deprecated and will be removed in a future " "release. Pass `delta_catalog=DeltaCatalog()` " "(from mp_api.client.core.delta) to MPRester instead.", - category=DeprecationWarning, + FutureWarning, stacklevel=2, ) self.catalog: DeltaCatalog = catalog if catalog is not None else DeltaCatalog() @@ -256,8 +255,8 @@ def __init__( warnings.warn( "Ignoring `monty_decode`, as it is no longer a supported option in `mp_api`." "The client by default returns results consistent with `monty_decode=True`.", + FutureWarning, stacklevel=2, - category=MPRestWarning, ) @property @@ -286,7 +285,7 @@ def query_builder(self) -> QueryBuilderWithCache: """Deprecated: use `delta_catalog`.""" warnings.warn( "`query_builder` is deprecated, use `delta_catalog` instead.", - category=DeprecationWarning, + FutureWarning, stacklevel=2, ) if self._query_builder is None: @@ -706,12 +705,10 @@ def _get_delta_table( ) if stored_label != qb_label: - warnings.warn( + logger.debug( f"DeltaTable with URI {uri} already found with different label: " f"Stored label = {stored_label}; submitted label {qb_label}. " - "Using stored DeltaTable.", - category=MPRestWarning, - stacklevel=2, + "Using stored DeltaTable." ) return stored_label, delta_table @@ -1231,7 +1228,6 @@ def _count_delta_docs( f"Counting {description} documents (excluding access-controlled " "entries), this can take a while...", enabled=not self.mute_progress_bars, - logger=logger, ): result = self.delta_catalog.execute( f"SELECT COUNT(*) AS n FROM {tbl_lbl} {predicate}", label=tbl_lbl @@ -1423,9 +1419,8 @@ def _query_resource( ) if fields: - warnings.warn( + mp_warning( "Ignoring `fields` argument: All fields are always included when no query is provided.", - category=MPRestWarning, stacklevel=2, ) @@ -1739,11 +1734,10 @@ def open_bar() -> ProgressHandle: # Warning to select specific fields only for many results if criteria.get("_all_fields", False) and (total_num_docs / chunk_size > 10): - warnings.warn( + mp_warning( f"Use the 'fields' argument to select only fields of interest to speed " f"up data retrieval for large queries. " f"Choose from: {self.available_fields}", - category=MPRestWarning, stacklevel=2, ) @@ -2020,7 +2014,7 @@ def get_data_by_id( ) -> BaseModel | dict[str, Any] | None: warnings.warn( "get_data_by_id is deprecated and will be removed soon. Please use the search method instead.", - DeprecationWarning, + FutureWarning, stacklevel=2, ) @@ -2107,10 +2101,9 @@ def count(self, criteria: dict | None = None) -> int: criteria["deprecated"] = True results = self._query_resource(criteria=criteria, **query_kwargs) cnt += results["meta"]["total_doc"] - warnings.warn( + mp_warning( "Omitting a query also includes deprecated documents in the results. " "Make sure to post-filter them out.", - category=MPRestWarning, stacklevel=2, ) diff --git a/mp_api/client/core/exceptions.py b/mp_api/client/core/exceptions.py index 043a0ccf..26d999e7 100644 --- a/mp_api/client/core/exceptions.py +++ b/mp_api/client/core/exceptions.py @@ -2,7 +2,9 @@ from __future__ import annotations -import warnings +import logging + +logger = logging.getLogger(__name__) class MPRestError(Exception): @@ -26,12 +28,10 @@ def _render_traceback_(self) -> list[str]: def _emit_status_warning() -> None: - """Emit a warning if client can't hear a heartbeat.""" - warnings.warn( + """Log a warning if client can't hear a heartbeat.""" + logger.warning( "Cannot listen to heartbeat, check Materials Project " - "status page: https://status.materialsproject.org/", - category=MPRestWarning, - stacklevel=2, + "status page: https://status.materialsproject.org/" ) diff --git a/mp_api/client/core/settings.py b/mp_api/client/core/settings.py index b22dacf1..8dfbb692 100644 --- a/mp_api/client/core/settings.py +++ b/mp_api/client/core/settings.py @@ -53,14 +53,17 @@ class MAPIClientSettings(BaseSettings): description="Whether to mute progress bars when data is retrieved.", ) - LOGGING: bool = Field( - True, - description="Whether the client shows its log messages on stderr by default. " - "If the application configures logging, messages go to its handlers instead.", + LOG_LEVEL: str = Field( + "INFO", + description="Minimum level of client log messages to show, for the whole " + "client (including MPContribs). If the application configures logging, " + "messages go to its handlers instead.", ) - LOG_LEVEL: str = Field( - "INFO", description="Minimum level of client log messages to show." + QUIET: bool = Field( + False, + description="Silence all client output: log messages, progress bars, " + "notices and MPRestWarnings. Deprecation warnings and errors are still raised.", ) MIN_EMMET_VERSION: str = Field( diff --git a/mp_api/client/core/utils.py b/mp_api/client/core/utils.py index 2aad1026..f4d205d2 100644 --- a/mp_api/client/core/utils.py +++ b/mp_api/client/core/utils.py @@ -19,12 +19,12 @@ from monty.json import MontyDecoder from packaging.version import parse as parse_version +from mp_api.client.core._display import mp_warning from mp_api.client.core.exceptions import ( MPDatasetIndexingWarning, MPDatasetIterationWarning, MPDatasetSlicingWarning, MPRestError, - MPRestWarning, ) from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS @@ -114,10 +114,9 @@ def validate_api_key(api_key: str | None = None) -> str | None: # The web server requires the client to initialize without an API key. # Only warn the user if the API key cannot be identified to permit # the web server to run. - warnings.warn( + mp_warning( "No API key found, please set explicitly or in " "the `MP_API_KEY` environment variable.", - category=MPRestWarning, stacklevel=2, ) @@ -346,9 +345,7 @@ def _open_dataset(self) -> ds.Dataset: ) ) # Paths in the log are relative to the table root and URL-encoded - files = [ - str(self._path / unquote(rel)) for rel in actions["path"].to_pylist() - ] + files = [str(self._path / unquote(rel)) for rel in actions["path"].to_pylist()] return ds.dataset( files, schema=self._file_schema(table, files), format="parquet" ) diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index dd8430e4..8a8cc7bb 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -31,7 +31,8 @@ from pymatgen.symmetry.analyzer import SpacegroupAnalyzer from requests import Session, get -from mp_api.client.core._display import print_notice, status +from mp_api.client.core._display import mp_warning, print_notice, status +from mp_api.client.core._display import quiet as set_quiet from mp_api.client.core._oxygen_evolution import OxygenEvolution from mp_api.client.core.client import ( LATEST_DB_VERSION, @@ -42,7 +43,6 @@ ) from mp_api.client.core.exceptions import ( MPRestError, - MPRestWarning, _emit_status_warning, ) from mp_api.client.core.settings import ( @@ -138,6 +138,7 @@ def __init__( query_builder: QueryBuilderWithCache | None = None, delta_catalog: DeltaCatalog | None = None, notify_db_version: bool = False, + quiet: bool | None = None, **kwargs, ): """Initialize the MPRester. @@ -186,8 +187,15 @@ def __init__( materialsproject.org and are not associated with your API key, so be aware that a notification may not be presented if you run MPRester from multiple computing environments. + quiet (bool or None): If True, silence all client output for the whole + process: log messages, progress bars, notices and MPRestWarnings, see + `mp_api.client.quiet()`. False or None leave the current setting + (`MPRESTER_QUIET`, or an earlier `quiet()` call) unchanged. **kwargs: access to legacy kwargs that may be in the process of being deprecated """ + if quiet: + # Before anything else, so nothing is printed while connecting + set_quiet(True) super().__init__( api_key=api_key, endpoint=endpoint, @@ -249,11 +257,9 @@ def __init__( version.parse(emmet_version.base_version) < version.parse(MAPI_CLIENT_SETTINGS.MIN_EMMET_VERSION) ): - warnings.warn( + logger.warning( "The installed version of the mp-api client may not be compatible with the API server. " - "Please install a previous version if any problems occur.", - category=MPRestWarning, - stacklevel=2, + "Please install a previous version if any problems occur." ) current_db_version = self._get_heartbeat_info(self.endpoint)[0] @@ -334,18 +340,13 @@ def contribs(self): except ImportError: self._contribs = None - warnings.warn( + mp_warning( "Run `pip install 'mp-api[contribs]'` to make use of the MPContribs client.", - category=MPRestWarning, stacklevel=2, ) except Exception as error: self._contribs = None - warnings.warn( - f"Problem loading MPContribs client: {error}", - category=MPRestWarning, - stacklevel=2, - ) + logger.warning(f"Problem loading MPContribs client: {error}") return self._contribs def __getattr__(self, attr): @@ -353,7 +354,7 @@ def __getattr__(self, attr): warnings.warn( f"Accessing {attr} data through MPRester.{attr} is deprecated. " f"Please use MPRester.materials.{attr} instead.", - DeprecationWarning, + FutureWarning, stacklevel=2, ) return getattr(super().__getattribute__("materials"), attr) @@ -439,13 +440,14 @@ def _list(route: str) -> tuple[str, list[str] | None]: logger.debug(f"No database versions for {route}: {exc}") return route, None - with status( - f"Fetching available database versions for {len(resters)} datasets...", - enabled=not self.mute_progress_bars, - logger=logger, + with ( + status( + f"Fetching available database versions for {len(resters)} datasets...", + enabled=not self.mute_progress_bars, + ), + ThreadPoolExecutor(max_workers=min(8, len(resters) or 1)) as pool, ): - with ThreadPoolExecutor(max_workers=min(8, len(resters) or 1)) as pool: - found = list(pool.map(_list, sorted(resters))) + found = list(pool.map(_list, sorted(resters))) return {route: versions for route, versions in found if versions} def get_task_ids_associated_with_material_id( @@ -526,8 +528,8 @@ def get_database_version(self) -> str | None: warnings.warn( "`get_database_version` has been deprecated in favor of " "MPRester().db_version.", + FutureWarning, stacklevel=2, - category=MPRestWarning, ) return self.db_version @@ -571,11 +573,9 @@ def get_material_id_from_task_id(self, task_id: str) -> str | None: raise ValueError( f"Multiple documents return for {task_id}, this should not happen, please report it!" ) - warnings.warn( + logger.warning( f"No material found containing task {task_id}. " - "Please report it if you suspect a task has gone missing.", - category=MPRestWarning, - stacklevel=2, + "Please report it if you suspect a task has gone missing." ) return None @@ -741,7 +741,7 @@ def get_entries( warnings.warn( "The `inc_structure` argument is deprecated as final structures " "are always included in all returned ComputedStructureEntry objects.", - category=DeprecationWarning, + FutureWarning, stacklevel=2, ) @@ -1286,13 +1286,12 @@ def get_entries_in_chemsys( all_chemsyses = _all_subchemsyses(elements_set) if additional_criteria is None: - warnings.warn( + mp_warning( "The default thermo type when retrieving entries has been changed from " "the mixed/corrected PBE GGA and GGA+U hull (`thermo_type = GGA_GGA+U`) " "to the joint PBE GGA / GGA+U / r2SCAN hull (`thermo_type = GGA_GGA+U_R2SCAN`). " "To use the older behavior, call `get_entries_in_chemsys` with " '`additional_criteria = {"thermo_types": ["GGA_GGA+U"]}`', - category=MPRestWarning, stacklevel=2, ) @@ -1343,13 +1342,12 @@ def get_entries_in_chemsys( MaterialsProjectDFTMixingScheme, ) - warnings.warn( + mp_warning( "Reconstructing a common energy scale for these entries with the " "GGA(+U)/r2SCAN mixing scheme, as the Materials Project has no pre-built " "phase diagram to serve for this query. Energies and hull distances may " "differ slightly from https://materialsproject.org, and entries the mixing " "scheme cannot place are dropped.", - category=MPRestWarning, stacklevel=2, ) entries = MaterialsProjectDFTMixingScheme().process_entries( @@ -1381,13 +1379,12 @@ def get_entries_in_chemsys( ] else: # non-consistent if mixed: - warnings.warn( + mp_warning( "Mixed GGA(+U)/r2SCAN entries can only be placed on a common energy scale " "with `compatible_only = True`, so these uncorrected entries are not " "suitable for constructing a phase diagram. Either use " "`compatible_only = True`, or request a single functional with " '`additional_criteria = {"thermo_types": ["GGA_GGA+U"]}`.', - category=MPRestWarning, stacklevel=2, ) @@ -1598,12 +1595,11 @@ def get_download_info( NoMaD repository. Each zip archive will contain a manifest.json with metadata info, e.g. the task/external_ids that belong to a directory. """ - warnings.warn( + mp_warning( "Full downloads of raw data are being transitioned to " "Materials Project's AWS S3 OpenData buckets. " "These features for accessing legacy raw data via NOMAD " "are maintained but may not be supported in the future.", - category=MPRestWarning, stacklevel=2, ) @@ -1701,12 +1697,10 @@ def _check_nomad_exist(url) -> bool: @staticmethod def _print_help_message(nomad_exist_task_ids, task_ids, file_patterns, calc_types): non_exist_ids = set(task_ids) - set(nomad_exist_task_ids) - warnings.warn( + logger.warning( f"For file patterns [{file_patterns}] and calc_types [{calc_types}], \n" f"the following ids are not found on NOMAD [{list(non_exist_ids)}]. \n" - f"If you need to upload them, please contact Patrick Huck at phuck@lbl.gov", - category=MPRestWarning, - stacklevel=2, + f"If you need to upload them, please contact Patrick Huck at phuck@lbl.gov" ) def query(*args, **kwargs): @@ -1925,11 +1919,9 @@ def get_stability( pd = None if not pd: - warnings.warn( + logger.warning( f"No phase diagram data available for chemical system {chemsys_str} " - f"and thermo type {thermo_type_valid_str}.", - category=MPRestWarning, - stacklevel=2, + f"and thermo type {thermo_type_valid_str}." ) return None @@ -2053,9 +2045,8 @@ def _db_version_check(self) -> None: ) if old_db_version: - warnings.warn( + mp_warning( "Materials Project database version has changed " f"from v{old_db_version} to v{self.db_version}.", - category=MPRestWarning, stacklevel=2, ) diff --git a/mp_api/client/routes/materials/electrodes.py b/mp_api/client/routes/materials/electrodes.py index 8527a5e0..c9062a96 100644 --- a/mp_api/client/routes/materials/electrodes.py +++ b/mp_api/client/routes/materials/electrodes.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from collections import defaultdict from emmet.core.electrode import ( @@ -11,7 +10,8 @@ from pymatgen.core.periodic_table import Element from mp_api.client.core import BaseRester -from mp_api.client.core.exceptions import MPRestError, MPRestWarning +from mp_api.client.core._display import mp_warning +from mp_api.client.core.exceptions import MPRestError class BaseElectrodeRester(BaseRester): @@ -145,9 +145,8 @@ def search( if query_params.pop(entry, None) is not None } if ignored_fields: - warnings.warn( + mp_warning( f"Ignoring fields {', '.join(ignored_fields)} which are not valid options for {self.__class__.__name__}", - category=MPRestWarning, stacklevel=2, ) diff --git a/mp_api/client/routes/materials/electronic_structure.py b/mp_api/client/routes/materials/electronic_structure.py index c67e5960..4b528f99 100644 --- a/mp_api/client/routes/materials/electronic_structure.py +++ b/mp_api/client/routes/materials/electronic_structure.py @@ -33,7 +33,7 @@ def search_electronic_structure_docs(self, *args, **kwargs): # pragma: no cover warnings.warn( "MPRester.electronic_structure.search_electronic_structure_docs is deprecated. " "Please use MPRester.electronic_structure.search instead.", - DeprecationWarning, + FutureWarning, stacklevel=2, ) @@ -183,7 +183,7 @@ def search_bandstructure_summary(self, *args, **kwargs): # pragma: no cover warnings.warn( "MPRester.electronic_structure_bandstructure.search_bandstructure_summary is deprecated. " "Please use MPRester.electronic_structure_bandstructure.search instead.", - DeprecationWarning, + FutureWarning, stacklevel=2, ) @@ -418,7 +418,7 @@ def search_dos_summary(self, *args, **kwargs): # pragma: no cover warnings.warn( "MPRester.electronic_structure_dos.search_dos_summary is deprecated. " "Please use MPRester.electronic_structure_dos.search instead.", - DeprecationWarning, + FutureWarning, stacklevel=2, ) diff --git a/mp_api/client/routes/materials/eos.py b/mp_api/client/routes/materials/eos.py index 032c1ffd..45897e61 100644 --- a/mp_api/client/routes/materials/eos.py +++ b/mp_api/client/routes/materials/eos.py @@ -5,7 +5,7 @@ from emmet.core.eos import EOSDoc -from mp_api.client.core import BaseRester, MPRestError, MPRestWarning +from mp_api.client.core import BaseRester, MPRestError from mp_api.client.core.utils import validate_ids @@ -53,8 +53,8 @@ def search( warnings.warn( "`material_id` has been replaced by `task_id` in the EOS endpoint. " "Please migrate to using the newer field name.", + FutureWarning, stacklevel=2, - category=MPRestWarning, ) if task_ids: diff --git a/mp_api/client/routes/materials/summary.py b/mp_api/client/routes/materials/summary.py index b7da8888..0e1c842f 100644 --- a/mp_api/client/routes/materials/summary.py +++ b/mp_api/client/routes/materials/summary.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from collections import defaultdict from itertools import chain, product @@ -8,7 +7,8 @@ from emmet.core.symmetry import CrystalSystem from pymatgen.analysis.magnetism import Ordering -from mp_api.client.core import BaseRester, MPRestError, MPRestWarning +from mp_api.client.core import BaseRester, MPRestError +from mp_api.client.core._display import mp_warning from mp_api.client.core.utils import validate_ids @@ -282,11 +282,7 @@ def search( # noqa: D417 if exc_strs: raise MPRestError("\n".join([*warning_strs, *exc_strs, *warn_ref_strs])) if warn_ref_strs: - warnings.warn( - "\n".join([*warning_strs, *warn_ref_strs]), - category=MPRestWarning, - stacklevel=2, - ) + mp_warning("\n".join([*warning_strs, *warn_ref_strs]), stacklevel=2) for param, value in user_settings.items(): if param in {"_page", "_sort_fields"}: diff --git a/mp_api/client/routes/materials/xas.py b/mp_api/client/routes/materials/xas.py index eb0cdf21..65d1e5dd 100644 --- a/mp_api/client/routes/materials/xas.py +++ b/mp_api/client/routes/materials/xas.py @@ -6,7 +6,7 @@ from emmet.core.xas import XASDoc, validate_xas_spectrum_id from pymatgen.core.periodic_table import Element -from mp_api.client.core import BaseRester, MPRestWarning +from mp_api.client.core import BaseRester from mp_api.client.core.exceptions import MPRestError if TYPE_CHECKING: @@ -76,8 +76,8 @@ def search( "`material_id` has been replaced by `task_id` in the xas endpoint. " "Please migrate to using the newer field name and the `task_ids` kwarg " "for searching.", + FutureWarning, stacklevel=2, - category=MPRestWarning, ) _locals = locals() diff --git a/mp_api/client/routes/molecules/jcesr.py b/mp_api/client/routes/molecules/jcesr.py index fb963e32..23b3564f 100644 --- a/mp_api/client/routes/molecules/jcesr.py +++ b/mp_api/client/routes/molecules/jcesr.py @@ -1,13 +1,12 @@ from __future__ import annotations -import warnings from collections import defaultdict from emmet.core.molecules_jcesr import MoleculesDoc from pymatgen.core.periodic_table import Element from mp_api.client.core import BaseRester -from mp_api.client.core.exceptions import MPRestWarning +from mp_api.client.core._display import mp_warning from mp_api.client.core.utils import validate_ids @@ -19,10 +18,9 @@ class JcesrMoleculesRester(BaseRester): def __init__(self, **kwargs): """Throw deprecation warning when JCESR client is initialized.""" - warnings.warn( + mp_warning( "NOTE: You are accessing the unmaintained legacy molecules data, " "please use MPRester.molecules.summary.", - category=MPRestWarning, stacklevel=2, ) super().__init__(**kwargs) diff --git a/mp_api/mcp/server.py b/mp_api/mcp/server.py index 659404f2..ce7858d2 100644 --- a/mp_api/mcp/server.py +++ b/mp_api/mcp/server.py @@ -8,6 +8,7 @@ from fastmcp import FastMCP from fastmcp.server.server import Transport +from mp_api.client.core._display import quiet from mp_api.client.core.exceptions import MPRestError from mp_api.mcp.tools import MPCoreMCP @@ -79,6 +80,9 @@ def parse_server_args(args: Sequence[str] | None = None) -> dict[str, Any]: def _run_mp_mcp_server() -> None: + # The server must not write client output (stdio transport): silence the + # client here, when serving, rather than on import or client creation. + quiet() mcp.run(**parse_server_args()) diff --git a/tests/client/core/test_delta.py b/tests/client/core/test_delta.py index 3677cdc4..f7f0b925 100644 --- a/tests/client/core/test_delta.py +++ b/tests/client/core/test_delta.py @@ -4,6 +4,7 @@ these tests need no network access, API key, or files on disk. """ +import logging import threading import warnings @@ -445,7 +446,7 @@ def test_rester_uses_provided_catalog(): def test_query_builder_with_cache_is_deprecated_but_shared(): - with pytest.warns(DeprecationWarning, match="QueryBuilderWithCache"): + with pytest.warns(FutureWarning, match="QueryBuilderWithCache"): qb = QueryBuilderWithCache() r1 = _Rester(api_key="a" * 32, query_builder=qb) r2 = _Rester(api_key="a" * 32, query_builder=qb) @@ -454,7 +455,7 @@ def test_query_builder_with_cache_is_deprecated_but_shared(): def test_query_builder_with_cache_register_and_introspect(): with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + warnings.simplefilter("ignore", FutureWarning) qb = QueryBuilderWithCache() table = FakeDeltaTable("s3a://bucket/x/") assert qb.register("x", table) is qb @@ -464,7 +465,7 @@ def test_query_builder_with_cache_register_and_introspect(): def test_query_builder_property_is_deprecated(): rester = _Rester(api_key="a" * 32) - with pytest.warns(DeprecationWarning, match="delta_catalog"): + with pytest.warns(FutureWarning, match="delta_catalog"): qb = rester.query_builder assert qb.catalog is rester.delta_catalog @@ -479,14 +480,15 @@ def test_sub_resters_share_parent_catalog(no_heartbeat): assert materials.phonon.summary_rester.delta_catalog is cat -def test_get_delta_table_warns_on_label_mismatch(no_heartbeat): +def test_get_delta_table_logs_label_mismatch(no_heartbeat, caplog): rester = BaseRester(api_key="a" * 32) lbl, _ = rester._get_delta_table("bucket", "some/prefix", label="first") assert lbl == "first" assert rester.delta_catalog.tables["first"].table_uri == "s3a://bucket/some/prefix/" - with pytest.warns(MPRestWarning, match="different label"): + with caplog.at_level(logging.DEBUG, logger="mp_api.client.core.client"): lbl, _ = rester._get_delta_table("bucket", "some/prefix/", label="second") assert lbl == "first" + assert "different label" in caplog.text def test_query_delta_single_retries_and_wraps_errors(no_heartbeat): diff --git a/tests/client/core/test_delta_versions.py b/tests/client/core/test_delta_versions.py index 30bff038..1818e153 100644 --- a/tests/client/core/test_delta_versions.py +++ b/tests/client/core/test_delta_versions.py @@ -428,14 +428,48 @@ def spy(sql, label=None): assert "✓ Downloaded 5 Doc documents (v2026.09.28)" in plain(term.getvalue()) -def test_count_logs_once_without_live_display(make_rester, caplog): - r = make_rester(gnome=False) # muted progress bars -> no live display - with caplog.at_level(logging.WARNING, logger="mp_api.client.core.client"): +def test_count_silent_when_muted(make_rester, caplog): + r = make_rester(gnome=False) # muted progress bars: no status, no log line + with caplog.at_level(logging.DEBUG, logger="mp_api.client"): + download(r) + assert not any("Counting chemenv" in rec.message for rec in caplog.records) + + +def test_count_logged_once_at_info_without_live_display(make_rester, caplog): + r = make_rester(gnome=False) + r.mute_progress_bars = False # enabled, but the test console isn't a terminal + with caplog.at_level(logging.INFO, logger="mp_api.client"): download(r) # (caplog may see a record twice: pytest's handler is on both the root # and, via the default handler's forwarding, mp_api.client) - messages = {id(rec) for rec in caplog.records if "Counting chemenv" in rec.message} - assert len(messages) == 1 + records = { + id(rec): rec for rec in caplog.records if "Counting chemenv" in rec.message + } + assert len(records) == 1 + assert next(iter(records.values())).levelno == logging.INFO + + +def test_warning_level_app_sees_no_progress_notes(make_rester, monkeypatch): + """A WARNING-level application (e.g. a web server, no terminal) with progress + bars enabled gets real warnings only, not progress notes like 'Counting...'.""" + records: list[logging.LogRecord] = [] + + class Recorder(logging.Handler): + def emit(self, record): + records.append(record) + + root = logging.getLogger() + handler = Recorder() + monkeypatch.setattr(root, "handlers", [handler]) + monkeypatch.setattr(root, "level", logging.WARNING) + + r = make_rester(gnome=False) + r.mute_progress_bars = False + download(r) + logging.getLogger("mp_api.client.core.client").warning("a real problem") + + messages = [rec.getMessage() for rec in records] + assert messages == ["a real problem"] def test_no_count_query_with_full_access(make_rester, monkeypatch): @@ -705,8 +739,11 @@ def test_db_version_only_on_versioned_routes(): versioned = cls.delta_backed and _location(cls)[2].startswith("collections/") for name in ("search", "search_docs"): method = cls.__dict__.get(name) or next( - (b.__dict__[name] for b in cls.__mro__[1:-1] if name in b.__dict__ - and b is not BaseRester), + ( + b.__dict__[name] + for b in cls.__mro__[1:-1] + if name in b.__dict__ and b is not BaseRester + ), None, ) if method is None: @@ -728,7 +765,8 @@ def test_phase_diagram_version(make_rester, monkeypatch): monkeypatch.setattr( BaseRester, "_query_delta_single", - lambda self, q, label=None: queries.append(q) or pa.table({"phase_diagram": []}), + lambda self, q, label=None: queries.append(q) + or pa.table({"phase_diagram": []}), ) from mp_api.client.routes.materials.thermo import ThermoRester @@ -754,7 +792,9 @@ def mpr(make_rester, tmp_path, remote, monkeypatch): flat = str(tmp_path / "flat") write_deltalake(flat, pa.table({"x": [1]})) - def get_delta_table(self, bucket, prefix, connector="s3a", label=None, refresh=False): + def get_delta_table( + self, bucket, prefix, connector="s3a", label=None, refresh=False + ): if prefix.endswith("chemenv") or prefix.endswith("summary"): name = prefix.rsplit("/", 1)[-1] return self.delta_catalog.get_table(remote, name, refresh=refresh) @@ -896,7 +936,9 @@ def test_synthesis_downloads_recipes(make_rester, tmp_path, monkeypatch): assert calls[0]["prefix"] == "static-collections/synth-descriptions" monkeypatch.setattr( - BaseRester, "_submit_requests", lambda self, **kw: {"data": ["rest"], "meta": {}} + BaseRester, + "_submit_requests", + lambda self, **kw: {"data": ["rest"], "meta": {}}, ) assert r.search(keywords=["silicon"]) == ["rest"] # text search stays on REST assert len(calls) == 1 diff --git a/tests/client/core/test_display.py b/tests/client/core/test_display.py index 69ae2894..0d0fbc64 100644 --- a/tests/client/core/test_display.py +++ b/tests/client/core/test_display.py @@ -15,7 +15,6 @@ import mp_api.client.core._display as display from mp_api.client.core._display import ( MP_THEME, - disable_logging, enable_logging, get_console, print_notice, @@ -231,17 +230,14 @@ def test_enable_logging_is_idempotent(): enable_logging() -def test_disable_and_reenable_logging(): - disable_logging() +def test_enable_logging_rejects_unknown_level(): + with pytest.raises(ValueError, match="Unknown log level 'LOUD'"): + enable_logging("LOUD") + enable_logging("warning") # case-insensitive try: - assert _installed() == [] - assert _client_logger().propagate - assert any( - isinstance(h, logging.NullHandler) for h in _client_logger().handlers - ) + assert _client_logger().level == logging.WARNING finally: enable_logging() - assert len(_installed()) == 1 def test_logging_plain_when_not_a_terminal(pipe, no_root_handlers): @@ -292,18 +288,6 @@ def test_contribs_logger_uses_shared_handler(pipe, no_root_handlers, capsys): assert "mp_api.client.contribs - INFO - project created" in pipe.getvalue() -def test_logging_setting_off(monkeypatch): - from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS - - disable_logging() - monkeypatch.setattr(MAPI_CLIENT_SETTINGS, "LOGGING", False) - try: - display._install_default_logging() - assert _installed() == [] - finally: - enable_logging() - - def test_logging_from_threads_during_progress(term, no_root_handlers): log = logging.getLogger("mp_api.client.core.delta") with progress_bar("x", total=4, summary="done") as bar: diff --git a/tests/client/core/test_quiet.py b/tests/client/core/test_quiet.py new file mode 100644 index 00000000..749c91dd --- /dev/null +++ b/tests/client/core/test_quiet.py @@ -0,0 +1,250 @@ +"""Quiet mode and the single-owner logging configuration. No network access.""" + +import io +import logging +import subprocess +import sys +import warnings + +import pytest +from rich.console import Console + +import mp_api.client.core._display as display +from mp_api.client import enable_logging, is_quiet, quiet +from mp_api.client.core._display import mp_warning, print_notice, progress_bar, status +from mp_api.client.core.exceptions import MPRestWarning + +CLIENT = "mp_api.client" + + +@pytest.fixture +def term(): + buf = io.StringIO() + display.set_console( + Console(file=buf, force_terminal=True, force_jupyter=False, width=120) + ) + yield buf + display.set_console(None) + + +@pytest.fixture +def app_handler(): + """An application's root handler, recording everything that reaches it.""" + records: list[logging.LogRecord] = [] + + class Recorder(logging.Handler): + def emit(self, record): + records.append(record) + + root = logging.getLogger() + handler = Recorder(level=logging.DEBUG) + root.addHandler(handler) + old_level = root.level + root.setLevel(logging.DEBUG) + yield records + root.removeHandler(handler) + root.setLevel(old_level) + + +def emit_everything(): + """One of each kind of client output.""" + logging.getLogger("mp_api.client.core.client").warning("core warning") + logging.getLogger("mp_api.client.core.client").error("core error") + from mp_api.client.contribs._logger import MPCC_LOGGER + + MPCC_LOGGER.warning("contribs warning") + print_notice("a notice") + with progress_bar("a bar", total=2, summary="Did {completed}") as bar: + bar.update(2) + with status("a status line"): + pass + mp_warning("an advisory") + + +# -------------------------------------------------------------------------- +# Single owner of logging configuration +# -------------------------------------------------------------------------- + + +def test_no_child_logger_sets_its_own_level(): + """Only `mp_api.client` is configured; children inherit its level.""" + import mp_api.client.contribs._logger # noqa: F401 - registers its logger + import mp_api.client.mprester # noqa: F401 + + configured = { + name: logger.level + for name, logger in logging.Logger.manager.loggerDict.items() + if name.startswith(CLIENT + ".") + and isinstance(logger, logging.Logger) + and logger.level != logging.NOTSET + } + assert configured == {} + + +def test_one_level_controls_contribs(app_handler): + from mp_api.client.contribs._logger import MPCC_LOGGER + + enable_logging("ERROR") + try: + MPCC_LOGGER.warning("hidden") + MPCC_LOGGER.error("shown") + finally: + enable_logging() + assert [r.getMessage() for r in app_handler] == ["shown"] + + +def test_contribs_import_does_not_change_warning_filters(): + code = ( + "import warnings, mp_api.client; before = list(warnings.filters); " + "import mp_api.client.contribs.client; " + "assert warnings.filters == before, 'filters changed'" + ) + subprocess.run([sys.executable, "-c", code], check=True) + + +def test_deprecated_contribs_log_level_setting_warns(): + code = ( + "import warnings; warnings.simplefilter('error', FutureWarning)\n" + "import mp_api.client.contribs._logger" + ) + result = subprocess.run( + [sys.executable, "-c", code], + env={**_env(), "MPCONTRIBS_CLIENT_LOG_LEVEL": "DEBUG"}, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "MPCONTRIBS_CLIENT_LOG_LEVEL is deprecated" in result.stderr + + +def test_disable_logging_is_gone(): + import mp_api.client + + assert not hasattr(mp_api.client, "disable_logging") + assert not hasattr(display, "disable_logging") + + +# -------------------------------------------------------------------------- +# Quiet mode +# -------------------------------------------------------------------------- + + +def test_quiet_silences_everything(term, app_handler): + with quiet(): + assert is_quiet() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + emit_everything() + + assert term.getvalue() == "" + assert app_handler == [] + assert caught == [] + assert not is_quiet() + + +def test_outside_quiet_everything_is_emitted(term, app_handler): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + emit_everything() + + messages = [r.getMessage() for r in app_handler] + assert {"core warning", "core error", "contribs warning"} <= set(messages) + assert "a notice" in term.getvalue() and "✓ Did 2" in term.getvalue() + assert [str(w.message) for w in caught] == ["an advisory"] + + +def test_quiet_keeps_deprecations_and_errors(): + with quiet(): + with pytest.warns(FutureWarning, match="old"): + warnings.warn("old", FutureWarning, stacklevel=1) + with pytest.raises(ValueError): + raise ValueError("still raised") + + +def test_quiet_restores_previous_state_and_nests(): + enable_logging("WARNING") + try: + with quiet(): + assert logging.getLogger(CLIENT).level > logging.CRITICAL + with quiet(): + pass + assert is_quiet() # inner exit restores the outer (quiet) state + with quiet(False): + assert not is_quiet() + assert logging.getLogger(CLIENT).level == logging.WARNING + assert is_quiet() + assert not is_quiet() + assert logging.getLogger(CLIENT).level == logging.WARNING + finally: + enable_logging() + + +def test_quiet_as_plain_call(): + quiet() + try: + assert is_quiet() + finally: + quiet(False) + assert not is_quiet() + + +def test_enable_logging_while_quiet_applies_after(): + with quiet(): + enable_logging("ERROR") + assert logging.getLogger(CLIENT).level > logging.CRITICAL + try: + assert logging.getLogger(CLIENT).level == logging.ERROR + finally: + enable_logging() + + +def test_quiet_does_not_touch_warning_filters(): + before = list(warnings.filters) + with quiet(): + assert warnings.filters == before + assert warnings.filters == before + + +def test_mprester_quiet_kwarg(monkeypatch, term): + from mp_api.client import MPRester + from mp_api.client.core.client import _Rester + + monkeypatch.setattr( + _Rester, "_get_heartbeat_info", staticmethod(lambda ep: ("2026.04.13", [])) + ) + monkeypatch.setattr(MPRester, "get_emmet_version", staticmethod(lambda ep: None)) + + MPRester(api_key="a" * 32, quiet=False) + assert not is_quiet() + MPRester(api_key="a" * 32, quiet=True) + assert is_quiet() + MPRester(api_key="a" * 32, quiet=False) # doesn't undo it + assert is_quiet() + + +def test_quiet_setting_at_import(): + code = ( + "import logging, mp_api.client as c\n" + "assert c.is_quiet()\n" + "logging.getLogger('mp_api.client.core').warning('nope')\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + env={**_env(), "MPRESTER_QUIET": "true"}, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stderr == "" and result.stdout == "" + + +def test_status_silent_when_disabled(term, app_handler): + with status("muted", enabled=False): + pass + assert term.getvalue() == "" and app_handler == [] + + +def _env() -> dict[str, str]: + import os + + return {k: v for k, v in os.environ.items() if not k.startswith("MPRESTER_")} diff --git a/tests/client/test_client.py b/tests/client/test_client.py index caf786f8..58f3cfff 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -103,7 +103,7 @@ def test_generic_get_methods(rester): docs = getattr(rester, search_method)( **{key + "s": [key_only_resters[name]]}, fields=[key] ) - with pytest.warns(DeprecationWarning, match="get_data_by_id is deprecated"): + with pytest.warns(FutureWarning, match="get_data_by_id is deprecated"): _ = rester.get_data_by_id(key_only_resters[name], fields=[key]) assert docs_check(docs) diff --git a/tests/client/test_core_client.py b/tests/client/test_core_client.py index 88cee950..318bedce 100644 --- a/tests/client/test_core_client.py +++ b/tests/client/test_core_client.py @@ -87,7 +87,7 @@ def test_fields_not_requested_excludes_requested_fields(mpr): def test_warnings_exceptions(): - with pytest.warns(MPRestWarning, match="Ignoring `monty_decode`"): + with pytest.warns(FutureWarning, match="Ignoring `monty_decode`"): MaterialsRester(monty_decode=True) with pytest.raises(MPRestError, match="Chunk size must be greater than zero"): diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py index 3dd0b035..810be8f6 100644 --- a/tests/client/test_mprester.py +++ b/tests/client/test_mprester.py @@ -219,7 +219,7 @@ def test_get_entries(self, mpr): syms = ["Li", "Fe", "O"] chemsys = "Li-Fe-O" with pytest.warns( - DeprecationWarning, match="The `inc_structure` argument is deprecated" + FutureWarning, match="The `inc_structure` argument is deprecated" ): entries = mpr.get_entries(thermo_docs[0].chemsys, inc_structure=False) @@ -896,7 +896,7 @@ def test_warnings_exceptions(self): for attr in mpr._deprecated_attributes: with pytest.warns( - DeprecationWarning, match=r"Accessing.*data through MPRester\..*" + FutureWarning, match=r"Accessing.*data through MPRester\..*" ): getattr(mpr, attr, None) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..4ffb6800 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Test-wide fixtures.""" + +import logging + +import pytest + +import mp_api.client.core._display as display + + +@pytest.fixture(autouse=True) +def _restore_client_output(): + """Undo process-wide output settings a test changed (quiet mode, log level). + + Quiet mode is deliberately process-wide, so e.g. creating an MCP client + (which requests quiet) would otherwise silence every later test. + """ + logger = logging.getLogger(display.LOGGER_NAME) + was_quiet = display.is_quiet() + level = logger.level if not was_quiet else display._QUIET.saved_level + yield + display.quiet(was_quiet) + if not was_quiet: + logger.setLevel(level) From 77289df2d5d453c6db4cbffda118d31b45064e44 Mon Sep 17 00:00:00 2001 From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:04:41 -0700 Subject: [PATCH 13/30] bit of styling for db version overview --- mp_api/client/core/_display.py | 165 ++++++++++++++++++++++- mp_api/client/mprester.py | 19 ++- tests/client/core/test_delta_versions.py | 7 +- tests/client/core/test_quiet.py | 103 ++++++++++++++ 4 files changed, 287 insertions(+), 7 deletions(-) diff --git a/mp_api/client/core/_display.py b/mp_api/client/core/_display.py index 51448866..cabac5fb 100644 --- a/mp_api/client/core/_display.py +++ b/mp_api/client/core/_display.py @@ -77,6 +77,7 @@ from __future__ import annotations +import io import logging import threading import time @@ -98,18 +99,20 @@ TimeRemainingColumn, ) from rich.spinner import Spinner -from rich.table import Column +from rich.table import Column, Table from rich.text import Text from rich.theme import Theme if TYPE_CHECKING: from collections.abc import Iterable, Iterator + from typing import Any, Literal from rich.console import RenderableType from rich.progress import Task __all__ = [ "MP_THEME", + "DatabaseVersions", "enable_logging", "get_console", "is_quiet", @@ -135,6 +138,11 @@ "mp.muted": "#7A7A7A", # Bulma .has-text-grey "mp.title": "bold underline", "mp.field": "bold", + "mp.table.header": "bold #008F83", # brand dark + # database versions, relative to the one the API serves + "mp.version.current": "bold #48C78E", # success green + "mp.version.newer": "#3960E3", # accent blue + "mp.version.older": "#7A7A7A", # muted grey # rich built-ins used by progress bars / logging "progress.description": "default", "progress.download": "#939FC8", @@ -434,6 +442,161 @@ def progress_bar( ) +# -------------------------------------------------------------------------- +# Tables +# -------------------------------------------------------------------------- + + +class DatabaseVersions(dict[str, list[str]]): + """Database versions per dataset: a plain `dict` that displays as a table. + + Behaves exactly like `dict[str, list[str]]` (indexing, iteration, + equality, JSON). Only its representation differs: evaluating it in a REPL + or notebook shows a table. Versions are coloured relative to the one the + API serves: that one green and marked `*`, newer ones blue, older ones grey. + """ + + def __init__( + self, versions: dict[str, list[str]], current: str | None = None + ) -> None: + """Create the mapping. + + Args: + versions (dict of str to list of str) : dataset name to versions + current (str or None) : version the API serves, marked in the table + """ + super().__init__(versions) + self.current = current + + def _style(self, version: str) -> str: + """Theme style for a version, relative to the version the API serves.""" + if not self.current: + return "" + if version == self.current: + return "mp.version.current" + # Versions are dates (with optional -postN suffixes), so they sort as text + return "mp.version.newer" if version > self.current else "mp.version.older" + + def _legend(self, styled: bool) -> Text | None: + """Caption explaining the markers / colours present in the table.""" + if not self.current: + return None + seen = {self._style(v) for versions in self.values() for v in versions} + parts: list[str | tuple[str, str]] = [] + if "mp.version.current" in seen: + parts += [(f"{self.current}*", "mp.version.current"), " served by the API"] + if styled and "mp.version.newer" in seen: + parts += [" ", ("newer", "mp.version.newer")] + if styled and "mp.version.older" in seen: + parts += [" ", ("older", "mp.version.older")] + return Text.assemble(*parts) if parts else None + + def _table(self, styled: bool) -> Table: + def st(style: str) -> str: + return style if styled else "" + + table = Table( + title="Database versions available on S3", + title_style=st("bold"), + header_style=st("mp.table.header"), + border_style=st("mp.muted"), + caption=self._legend(styled), + caption_style=st("mp.muted"), + ) + # default foreground: white on dark terminals, still readable on light ones + table.add_column("Dataset", no_wrap=True) + table.add_column("Versions") + for name, versions in self.items(): + cells = [ + Text( + f"{v}*" if v == self.current else v, + style=st(self._style(v)), + ) + for v in versions + ] + table.add_row(name, Text(", ").join(cells)) + return table + + def _render(self, color: bool, width: int = 200) -> str: + """The table as text, with ANSI colours if `color`.""" + buf = io.StringIO() + Console( + file=buf, + width=width, + theme=MP_THEME, + force_terminal=color, + color_system=_color_system() if color else None, + force_jupyter=False, + ).print(self._table(styled=color)) + return buf.getvalue().rstrip("\n") + + def __rich__(self) -> Table: + return self._table(styled=True) + + def __repr__(self) -> str: + """The table. Coloured only in an interactive session on a colour + terminal, so a repr in logs or files never contains ANSI codes. + """ + if not self: + return "DatabaseVersions({})" + if _interactive_color(): + return self._render(color=True, width=get_console().width) + return self._render(color=False) + + def __str__(self) -> str: + return self._render(color=False) if self else "DatabaseVersions({})" + + def _repr_pretty_(self, p: Any, cycle: bool) -> None: + """IPython terminal: the table (IPython would otherwise pretty-print a dict).""" + p.text(self.__repr__()) + + def _repr_html_(self) -> str: + """Jupyter: the styled table.""" + console = Console( + file=io.StringIO(), + record=True, + width=200, + theme=MP_THEME, + force_jupyter=False, + ) + console.print(self._table(styled=True)) + return console.export_html(inline_styles=True, code_format="
{code}
") + + +def _color_system() -> Literal["standard", "256", "truecolor"]: + """Colour depth of the user's terminal, for reprs rendered to a string.""" + detected = get_console().color_system + if detected == "truecolor": + return "truecolor" + return "256" if detected == "256" else "standard" + + +def _interactive_color() -> bool: + """Whether a repr may contain colours: an interactive Python/IPython session + whose stdout is a colour terminal, and NO_COLOR isn't set. + """ + import os + import sys + + interactive = hasattr(sys, "ps1") or bool(sys.flags.interactive) + try: + from IPython import get_ipython # type: ignore[attr-defined] + + shell = get_ipython() + interactive = interactive or ( + shell is not None and type(shell).__name__ == "TerminalInteractiveShell" + ) + except ImportError: + pass + return ( + interactive + and sys.stdout is not None + and sys.stdout.isatty() + and "NO_COLOR" not in os.environ + and os.environ.get("TERM") != "dumb" + ) + + # -------------------------------------------------------------------------- # Logging # -------------------------------------------------------------------------- diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py index 8a8cc7bb..68903627 100644 --- a/mp_api/client/mprester.py +++ b/mp_api/client/mprester.py @@ -31,7 +31,12 @@ from pymatgen.symmetry.analyzer import SpacegroupAnalyzer from requests import Session, get -from mp_api.client.core._display import mp_warning, print_notice, status +from mp_api.client.core._display import ( + DatabaseVersions, + mp_warning, + print_notice, + status, +) from mp_api.client.core._display import quiet as set_quiet from mp_api.client.core._oxygen_evolution import OxygenEvolution from mp_api.client.core.client import ( @@ -402,7 +407,7 @@ def _versioned_resters(self) -> dict[str, BaseRester]: def available_db_versions( self, collection: str | None = None - ) -> dict[str, list[str]] | list[str]: + ) -> DatabaseVersions | list[str]: """Database versions available for full dataset downloads on S3. Read from the DeltaTable logs, no data is downloaded. Pass any of these @@ -417,8 +422,9 @@ def available_db_versions( Returns: list of str for one collection, e.g. ["2026.04.13", "2026.09.28"], - otherwise dict of collection to list of str. Collections that - couldn't be loaded, or have a single version, are left out. + otherwise a dict of collection to list of str, which displays as a + table in a REPL or notebook. Collections that couldn't be loaded, or + have a single version, are left out. Raises: MPRestError: if `collection` is unknown or has a single version. @@ -448,7 +454,10 @@ def _list(route: str) -> tuple[str, list[str] | None]: ThreadPoolExecutor(max_workers=min(8, len(resters) or 1)) as pool, ): found = list(pool.map(_list, sorted(resters))) - return {route: versions for route, versions in found if versions} + return DatabaseVersions( + {route: versions for route, versions in found if versions}, + current=self.current_db_version, + ) def get_task_ids_associated_with_material_id( self, material_id: str, calc_types: list[CalcType] | None = None diff --git a/tests/client/core/test_delta_versions.py b/tests/client/core/test_delta_versions.py index 1818e153..e69ed6a1 100644 --- a/tests/client/core/test_delta_versions.py +++ b/tests/client/core/test_delta_versions.py @@ -807,7 +807,12 @@ def get_delta_table( def test_mprester_lists_all_versioned_collections(mpr): - assert mpr.available_db_versions() == { + from mp_api.client.core._display import DatabaseVersions + + versions = mpr.available_db_versions() + assert isinstance(versions, DatabaseVersions) + assert versions.current == mpr.current_db_version + assert versions == { # same remote table used for both; bonds (unversioned) and # collections that fail to load are left out "chemenv": ["2026.04.13", "2026.09.28"], diff --git a/tests/client/core/test_quiet.py b/tests/client/core/test_quiet.py index 749c91dd..40aeab98 100644 --- a/tests/client/core/test_quiet.py +++ b/tests/client/core/test_quiet.py @@ -248,3 +248,106 @@ def _env() -> dict[str, str]: import os return {k: v for k, v in os.environ.items() if not k.startswith("MPRESTER_")} + + +# -------------------------------------------------------------------------- +# DatabaseVersions display +# -------------------------------------------------------------------------- + + +def test_database_versions_is_a_plain_dict(): + import json + + from mp_api.client.core._display import DatabaseVersions + + data = {"summary": ["2026.04.13", "2026.09.28"], "thermo": ["2026.04.13"]} + v = DatabaseVersions(data, current="2026.04.13") + assert v == data and isinstance(v, dict) + assert json.loads(json.dumps(v)) == data + assert v["thermo"] == ["2026.04.13"] and list(v) == ["summary", "thermo"] + + +def test_database_versions_repr_is_a_table(): + from mp_api.client.core._display import DatabaseVersions + + v = DatabaseVersions( + {"summary": ["2026.04.13", "2026.09.28"], "thermo": ["2026.09.28"]}, + current="2026.04.13", + ) + text = repr(v) # not an interactive colour terminal: plain text + assert "Database versions available on S3" in text + assert "│ summary │ 2026.04.13*, 2026.09.28 │" in text + assert "│ thermo │ 2026.09.28 │" in text + assert "2026.04.13* served by the API" in text + assert "newer" not in text # colour legend only shown with colours + assert "\x1b[" not in text + assert str(v) == text + assert repr(DatabaseVersions({})) == "DatabaseVersions({})" + + +def test_database_versions_no_marker_without_current(): + from mp_api.client.core._display import DatabaseVersions + + text = repr(DatabaseVersions({"summary": ["2026.09.28"]}, current="2026.04.13")) + assert "*" not in text and "served by the API" not in text + + +def test_database_versions_colours(monkeypatch): + from mp_api.client.core._display import MP_THEME, DatabaseVersions + + v = DatabaseVersions( + {"chemenv": ["2025.09.25", "2026.04.13", "2026.09.28"]}, current="2026.04.13" + ) + assert [v._style(x) for x in v["chemenv"]] == [ + "mp.version.older", + "mp.version.current", + "mp.version.newer", + ] + assert DatabaseVersions(v, current=None)._style("2026.04.13") == "" + + def rgb(style): + c = MP_THEME.styles[style].color.triplet + return f"38;2;{c.red};{c.green};{c.blue}" + + monkeypatch.setattr(display, "_interactive_color", lambda: True) + monkeypatch.setattr(display, "_color_system", lambda: "truecolor") + coloured = repr(v) + for style in ("mp.version.current", "mp.version.newer", "mp.version.older"): + assert rgb(style) in coloured + assert "newer" in coloured and "older" in coloured # legend + assert "\x1b[" not in str(v) # str() is always plain + + +def test_database_versions_colour_only_when_interactive(monkeypatch): + import sys + + from mp_api.client.core._display import _interactive_color + + monkeypatch.delattr(sys, "ps1", raising=False) + assert not _interactive_color() # a script, or pytest + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + + class TTY(io.StringIO): + def isatty(self): + return True + + monkeypatch.setattr(sys, "stdout", TTY()) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + assert _interactive_color() + monkeypatch.setenv("NO_COLOR", "1") + assert not _interactive_color() + + +def test_database_versions_notebook_and_ipython(): + pytest.importorskip("IPython") + from IPython.core.formatters import DisplayFormatter + + from mp_api.client.core._display import DatabaseVersions + + v = DatabaseVersions({"summary": ["2026.04.13"]}, current="2026.04.13") + data, _ = DisplayFormatter().format(v) + assert data["text/plain"] == repr(v) # IPython terminal: the table, not a dict + assert data["text/html"].startswith("
")
+    assert "summary" in data["text/html"]
+    assert "color: #48c78e" in data["text/html"]  # current version, green

From 9ed9e8f46df37c9353ce1a586fcf0b03bc9d642e Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Thu, 8 Oct 2026 15:40:34 -0700
Subject: [PATCH 14/30] communicate better to the user about what the client is
 doing when handling entries + corrections

---
 mp_api/_test_utils.py                      |   3 +-
 mp_api/client/core/_display.py             |  29 +-
 mp_api/client/core/_entries.py             | 236 ++++++++++++
 mp_api/client/mprester.py                  | 186 +++++----
 mp_api/client/routes/materials/thermo.py   |  45 ++-
 tests/client/contribs/test_side_effects.py |  41 +-
 tests/client/materials/test_eos.py         |   4 +-
 tests/client/test_entry_progress.py        | 427 +++++++++++++++++++++
 tests/client/test_mprester.py              |  45 ++-
 tests/mcp/test_utils.py                    |   4 +-
 10 files changed, 888 insertions(+), 132 deletions(-)
 create mode 100644 mp_api/client/core/_entries.py
 create mode 100644 tests/client/test_entry_progress.py

diff --git a/mp_api/_test_utils.py b/mp_api/_test_utils.py
index 85a9727d..8eb607f5 100644
--- a/mp_api/_test_utils.py
+++ b/mp_api/_test_utils.py
@@ -58,7 +58,8 @@ def client_search_testing(
     for entry in param_tuples:
         param = entry[0]
 
-        if param not in excluded_params + ["return"]:
+        # `db_version` selects a dataset version rather than filtering docs
+        if param not in excluded_params + ["return", "db_version"]:
             param_type = entry[1]
             q: dict[str, Any] = {"chunk_size": 1, "num_chunks": 1}
 
diff --git a/mp_api/client/core/_display.py b/mp_api/client/core/_display.py
index cabac5fb..b5d6fc50 100644
--- a/mp_api/client/core/_display.py
+++ b/mp_api/client/core/_display.py
@@ -325,8 +325,12 @@ def __init__(
         progress: Progress | None = None,
         task_id: TaskID | None = None,
         delay: float = 0.0,
+        summary: str | None = None,
     ) -> None:
         self.completed: float = 0
+        #: completion line, formatted as in `progress_bar`; can be changed while the
+        #: bar runs, e.g. to report results that are only known at the end
+        self.summary = summary
         self._total = total
         self._progress = progress
         self._task_id = task_id
@@ -385,6 +389,7 @@ def progress_bar(
     delay: float = 0.0,
     unit: str = "docs",
     summary: str | None = None,
+    log: str | None = None,
 ) -> Iterator[ProgressHandle]:
     """Show a progress bar for the duration of a `with` block.
 
@@ -399,13 +404,23 @@ def progress_bar(
             the outermost bar on this thread finishes without error, was
             shown, and completed at least one unit. Formatted with `completed` and `total`, and followed by
             the elapsed time, e.g. "Retrieved {completed:,} documents".
+            Can be replaced through `ProgressHandle.summary` inside the block.
+        log (str or None) : if given, logged once at INFO when progress is
+            enabled but no live bar can be drawn (scripts, servers, CI, Jupyter
+            without ipywidgets), as `status` does. Not logged when disabled or
+            in quiet mode.
 
     Yields:
         ProgressHandle
     """
     console = get_console()
-    if not enabled or is_quiet() or not _is_interactive(console):
-        yield ProgressHandle(total)
+    if not enabled or is_quiet():
+        yield ProgressHandle(total, summary=summary)
+        return
+    if not _is_interactive(console):
+        if log:
+            logging.getLogger(LOGGER_NAME).info(log)
+        yield ProgressHandle(total, summary=summary)
         return
 
     outermost = _STATE.depth() == 0
@@ -413,7 +428,9 @@ def progress_bar(
     try:
         if not _can_draw_live(console):
             # e.g. Jupyter without ipywidgets: no live bar, still summarize
-            handle = ProgressHandle(total)
+            if log:
+                logging.getLogger(LOGGER_NAME).info(log)
+            handle = ProgressHandle(total, summary=summary)
             handle.shown = delay <= 0
             yield handle
         else:
@@ -421,7 +438,7 @@ def progress_bar(
             task_id = progress.add_task(
                 description, total=total, visible=delay <= 0, unit=unit
             )
-            handle = ProgressHandle(total, progress, task_id, delay)
+            handle = ProgressHandle(total, progress, task_id, delay, summary)
             try:
                 yield handle
             finally:
@@ -429,8 +446,8 @@ def progress_bar(
     finally:
         _STATE.local.depth -= 1
 
-    if summary and outermost and handle.shown and handle.completed:
-        message = summary.format(
+    if handle.summary and outermost and handle.shown and handle.completed:
+        message = handle.summary.format(
             completed=_as_int(handle.completed), total=_as_int(handle.total)
         )
         console.print(
diff --git a/mp_api/client/core/_entries.py b/mp_api/client/core/_entries.py
new file mode 100644
index 00000000..e35fa2b8
--- /dev/null
+++ b/mp_api/client/core/_entries.py
@@ -0,0 +1,236 @@
+"""Progress reporting for client-side entry processing.
+
+Rescaling entries to conventional cells and applying pymatgen corrections or
+mixing schemes can take a while for large chemical systems. The helpers here
+do that work behind a progress bar (see `_display.progress_bar`), so callers
+can see what is being done to their entries rather than a silent block.
+
+pymatgen doesn't report progress from `process_entries`, so `_track_calls`
+counts calls to *public* methods it makes once per entry or structure
+(`Compatibility.get_adjustments`, `StructureMatcher.group_structures`). If
+pymatgen changes how often it calls them, only the bar's accuracy suffers: it's
+filled to its total when processing finishes, and the results are unaffected.
+
+The methods are only ever wrapped on private copies (`_private_copy`).
+pymatgen's Compatibility classes are monty `@cached_class`es: constructing one
+with the same arguments returns the same instance process-wide (and
+`copy.copy` returns it too), so wrapping the instance a caller holds would
+leak into other threads and later calls.
+"""
+
+from __future__ import annotations
+
+from contextlib import ExitStack, contextmanager
+from copy import deepcopy
+from typing import TYPE_CHECKING
+
+from pymatgen.entries.computed_entries import ComputedStructureEntry
+from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
+
+from mp_api.client.core._display import progress_bar
+
+if TYPE_CHECKING:
+    from collections.abc import Callable, Iterator, Sequence
+    from typing import Any
+
+    from pymatgen.entries.compatibility import Compatibility
+
+    from mp_api.client.core._display import ProgressHandle
+
+
+def _advance(handle: ProgressHandle, amount: float) -> None:
+    """Advance `handle` by `amount`, without going past its total."""
+    if handle.total is not None:
+        amount = min(amount, handle.total - handle.completed)
+    if amount > 0:
+        handle.update(amount)
+
+
+@contextmanager
+def _track_calls(
+    obj: Any,
+    name: str,
+    handle: ProgressHandle,
+    weight: Callable[..., float] | float = 1.0,
+) -> Iterator[None]:
+    """Advance `handle` each time a call to `obj.` finishes (or raises).
+
+    The method is replaced by an instance attribute for the duration of the
+    block and the original lookup is restored afterwards, even on error.
+
+    Args:
+        obj : object whose method is counted; should be private to the caller
+        name (str) : method name
+        handle (ProgressHandle) : bar to advance
+        weight (float or callable) : units per call; a callable receives the
+            call's arguments, e.g. to count the structures passed in
+    """
+    original = getattr(obj, name)
+    had_own = name in vars(obj)
+    own = vars(obj).get(name)
+
+    def counted(*args: Any, **kwargs: Any) -> Any:
+        try:
+            return original(*args, **kwargs)
+        finally:  # also when it raises, e.g. for an entry pymatgen drops
+            _advance(handle, weight(*args, **kwargs) if callable(weight) else weight)
+
+    setattr(obj, name, counted)
+    try:
+        yield
+    finally:
+        if had_own:
+            setattr(obj, name, own)
+        else:
+            delattr(obj, name)
+
+
+def _private_copy(obj: Any) -> Any:
+    """A shallow copy of `obj` that bypasses monty's `@cached_class` cache.
+
+    Attributes are shared with `obj` (pymatgen only reads them while
+    processing); attributes set on the copy, including counted methods and
+    state pymatgen records during `process_entries`, stay on the copy.
+    """
+    clone = object.__new__(type(obj))
+    clone.__dict__.update(vars(obj))
+    return clone
+
+
+def get_conventional_cell_entry(
+    entry: ComputedStructureEntry,
+) -> ComputedStructureEntry:
+    """Rebuild `entry` on the standard conventional unit cell, scaling the energy
+    and energy adjustments accordingly.
+    """
+    conventional_structure = SpacegroupAnalyzer(
+        entry.structure
+    ).get_conventional_standard_structure()
+    site_ratio = len(conventional_structure) / len(entry.structure)
+
+    energy_adjustments = deepcopy(entry.energy_adjustments)
+    for adjustment in energy_adjustments:  # adjustment values are extensive
+        adjustment.normalize(1 / site_ratio)
+
+    return ComputedStructureEntry(
+        conventional_structure,
+        entry.uncorrected_energy * site_ratio,
+        energy_adjustments=energy_adjustments,
+        parameters=entry.parameters,
+        data=entry.data,
+        entry_id=entry.entry_id,
+    )
+
+
+def rescale_to_conventional(
+    entries: Sequence[ComputedStructureEntry], *, enabled: bool = True
+) -> list[ComputedStructureEntry]:
+    """Rebuild each entry on its conventional unit cell, with a progress bar.
+
+    Args:
+        entries : entries to rescale
+        enabled (bool) : whether to show progress (e.g. `not mute_progress_bars`)
+    """
+    n = len(entries)
+    rescaled = []
+    with progress_bar(
+        "Rescaling entries to conventional cells",
+        total=n,
+        enabled=enabled,
+        unit="entries",
+        summary=f"Rescaled {n:,} {_plural(n)} to conventional unit cells",
+        log=f"Rescaling {n:,} {_plural(n)} to conventional unit cells",
+    ) as handle:
+        for entry in entries:
+            rescaled.append(get_conventional_cell_entry(entry))
+            handle.update()
+    return rescaled
+
+
+def apply_corrections(
+    corrector: Compatibility,
+    entries: Sequence[Any],
+    *,
+    description: str,
+    log: str,
+    summary: str,
+    enabled: bool = True,
+) -> list[Any]:
+    """Run `corrector.process_entries(entries)` behind a progress bar.
+
+    The bar counts entries. For a mixing scheme, which goes over the entries
+    in several passes, each pass is weighted so the bar still ends at the
+    number of entries.
+
+    Args:
+        corrector (Compatibility) : a correction scheme or mixing scheme. It
+            isn't modified: a private copy does the work.
+        entries : entries to process
+        description (str) : short label next to the bar
+        log (str) : logged at INFO when no live bar can be drawn
+        summary (str) : completion line; "{kept}" is replaced by the number of
+            entries returned, e.g. "Corrected 10 entries ({kept:,} kept)"
+        enabled (bool) : whether to show progress
+    """
+    from pymatgen.entries.compatibility import MaterialsProjectAqueousCompatibility
+    from pymatgen.entries.mixing_scheme import MaterialsProjectDFTMixingScheme
+
+    n = len(entries)
+    corrector = _private_copy(corrector)
+    with (
+        progress_bar(
+            description, total=n, enabled=enabled, unit="entries", log=log
+        ) as handle,
+        ExitStack() as stack,
+    ):
+        if isinstance(corrector, MaterialsProjectDFTMixingScheme):
+            # passes: compat_1 corrections (run type 1 entries), structure
+            # matching (all entries), then the mixing adjustments (all entries)
+            n_1 = sum(
+                (getattr(e, "parameters", None) or {}).get("run_type")
+                in corrector.valid_rtypes_1
+                for e in entries
+            )
+            unit = n / (2 * n + (n_1 if corrector.compat_1 else 0)) if n else 0.0
+            corrector.structure_matcher = _private_copy(corrector.structure_matcher)
+            if corrector.compat_1:
+                corrector.compat_1 = _private_copy(corrector.compat_1)
+                stack.enter_context(
+                    _track_calls(corrector.compat_1, "get_adjustments", handle, unit)
+                )
+            stack.enter_context(
+                _track_calls(
+                    corrector.structure_matcher,
+                    "group_structures",
+                    handle,
+                    lambda s_list, *a, **k: unit * len(s_list),
+                )
+            )
+            stack.enter_context(
+                _track_calls(corrector, "get_adjustments", handle, unit)
+            )
+        elif (
+            isinstance(corrector, MaterialsProjectAqueousCompatibility)
+            and corrector.solid_compat
+        ):  # solid corrections first, then aqueous ones
+            corrector.solid_compat = _private_copy(corrector.solid_compat)
+            stack.enter_context(
+                _track_calls(corrector.solid_compat, "get_adjustments", handle, 0.5)
+            )
+            stack.enter_context(
+                _track_calls(corrector, "get_adjustments", handle, 0.5)
+            )
+        else:
+            stack.enter_context(_track_calls(corrector, "get_adjustments", handle))
+
+        processed: list[Any] = corrector.process_entries(entries)  # type: ignore[arg-type]
+        _advance(handle, n)  # anything pymatgen did without a counted call
+        # `progress_bar` formats the summary again, so escape literal braces
+        handle.summary = (
+            summary.format(kept=len(processed)).replace("{", "{{").replace("}", "}}")
+        )
+    return processed
+
+
+def _plural(n: int, word: str = "entry", plural: str = "entries") -> str:
+    return word if n == 1 else plural
diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py
index 68903627..8639abaa 100644
--- a/mp_api/client/mprester.py
+++ b/mp_api/client/mprester.py
@@ -8,7 +8,6 @@
 import warnings
 from collections import defaultdict
 from concurrent.futures import ThreadPoolExecutor
-from copy import deepcopy
 from functools import cache, lru_cache
 from typing import TYPE_CHECKING
 from urllib.parse import urlencode
@@ -38,6 +37,11 @@
     status,
 )
 from mp_api.client.core._display import quiet as set_quiet
+from mp_api.client.core._entries import (
+    apply_corrections,
+    get_conventional_cell_entry,
+    rescale_to_conventional,
+)
 from mp_api.client.core._oxygen_evolution import OxygenEvolution
 from mp_api.client.core.client import (
     LATEST_DB_VERSION,
@@ -329,11 +333,23 @@ def contribs(self):
             - use_document_model
             - mute_progress_bars
 
+        Raises:
+            ImportError: if the optional MPContribs dependencies aren't installed.
+            MPRestError: if the client can't be created, e.g. the server is
+                unreachable. Nothing is cached, so a later access retries.
         """
         if self._contribs is None:
             try:
                 from mp_api.client.contribs.client import ContribsClient
+            except ImportError as error:
+                raise ImportError(
+                    "The MPContribs client needs optional dependencies: run "
+                    "`pip install 'mp-api[contribs]'`. It provides user-contributed "
+                    "data, also used by e.g. `get_pourbaix_entries` and "
+                    "`get_cohesive_energy`."
+                ) from error
 
+            try:
                 self._contribs = ContribsClient(
                     api_key=self.api_key,
                     headers=self.headers,
@@ -342,16 +358,10 @@ def contribs(self):
                     mute_progress_bars=self.mute_progress_bars,
                     **self._contribs_kwargs,
                 )
-
-            except ImportError:
-                self._contribs = None
-                mp_warning(
-                    "Run `pip install 'mp-api[contribs]'` to make use of the MPContribs client.",
-                    stacklevel=2,
-                )
             except Exception as error:
-                self._contribs = None
-                logger.warning(f"Problem loading MPContribs client: {error}")
+                raise MPRestError(
+                    f"Problem loading the MPContribs client: {error}"
+                ) from error
         return self._contribs
 
     def __getattr__(self, attr):
@@ -797,14 +807,15 @@ def get_entries(
                 ):  # merge property_data, retaining entry data (e.g. `oxidation_states`)
                     entry_dict["data"] |= {prop: doc[prop] for prop in property_data}
 
-                entry = TypeAdapter(ComputedStructureEntryType).validate_python(
-                    entry_dict
+                # Need to store object to permit de-duplication
+                entries.add(
+                    TypeAdapter(ComputedStructureEntryType).validate_python(entry_dict)
                 )
-                if conventional_unit_cell:
-                    entry = self._get_conventional_cell_entry(entry)
-
-                entries.add(entry)  # object permits de-duplication
 
+        if conventional_unit_cell:
+            return rescale_to_conventional(
+                list(entries), enabled=not self.mute_progress_bars
+            )
         return list(entries)
 
     def get_pourbaix_entries(
@@ -935,20 +946,23 @@ def get_pourbaix_entries(
             )
             compat = MaterialsProjectAqueousCompatibility(solid_compat=solid_compat)
         # suppress the warning about missing oxidation states
+        n = len(ion_ref_entries)
         with warnings.catch_warnings():
             warnings.filterwarnings(
                 "ignore", message="Failed to guess oxidation states.*"
             )
-            ion_ref_entries = compat.process_entries(ion_ref_entries)  # type: ignore
+            ion_ref_entries = apply_corrections(
+                compat,
+                ion_ref_entries,
+                description="Applying aqueous corrections",
+                log=f"Applying aqueous corrections to {n:,} ion reference entries",
+                summary=f"Applied aqueous corrections to {n:,} entries ({{kept:,}} kept)",
+                enabled=not self.mute_progress_bars,
+            )
         # TODO - if the commented line above would work, this conditional block
         # could be removed
         if use_gibbs:
-            # replace the entries with GibbsComputedStructureEntry
-            from pymatgen.entries.computed_entries import GibbsComputedStructureEntry
-
-            ion_ref_entries = GibbsComputedStructureEntry.from_entries(
-                ion_ref_entries, temp=use_gibbs
-            )
+            ion_ref_entries = self._to_gibbs_entries(ion_ref_entries, use_gibbs)  # type: ignore[arg-type]
         ion_ref_pd = PhaseDiagram(ion_ref_entries)  # type: ignore
 
         ion_entries = self.get_ion_entries(ion_ref_pd, ion_ref_data=ion_data)
@@ -1195,30 +1209,21 @@ def _get_unmixed_entries(
             )
         ]
 
-    @staticmethod
-    def _get_conventional_cell_entry(
-        entry: ComputedStructureEntry,
-    ) -> ComputedStructureEntry:
-        """Rebuild ``entry`` on the standard conventional unit cell, scaling the energy
-        and energy adjustments accordingly.
-        """
-        conventional_structure = SpacegroupAnalyzer(
-            entry.structure
-        ).get_conventional_standard_structure()
-        site_ratio = len(conventional_structure) / len(entry.structure)
-
-        energy_adjustments = deepcopy(entry.energy_adjustments)
-        for adjustment in energy_adjustments:  # adjustment values are extensive
-            adjustment.normalize(1 / site_ratio)
-
-        return ComputedStructureEntry(
-            conventional_structure,
-            entry.uncorrected_energy * site_ratio,
-            energy_adjustments=energy_adjustments,
-            parameters=entry.parameters,
-            data=entry.data,
-            entry_id=entry.entry_id,
-        )
+    _get_conventional_cell_entry = staticmethod(get_conventional_cell_entry)
+
+    def _to_gibbs_entries(
+        self, entries: Sequence[ComputedStructureEntry], temp: float
+    ) -> list[GibbsComputedStructureEntry]:
+        """Replace `entries` with GibbsComputedStructureEntry estimates at `temp` K."""
+        from pymatgen.entries.computed_entries import GibbsComputedStructureEntry
+
+        # pymatgen builds a phase diagram and converts each entry internally,
+        # with no hook to count progress, so this is a status line:
+        with status(
+            f"Estimating Gibbs free energies at {temp:g} K for {len(entries):,} entries...",
+            enabled=not self.mute_progress_bars,
+        ):
+            return GibbsComputedStructureEntry.from_entries(list(entries), temp=temp)
 
     def get_entries_in_chemsys(
         self,
@@ -1249,7 +1254,8 @@ def get_entries_in_chemsys(
         scheme is re-applied locally when MP has no pre-built diagram, are then dropped).
         Passing ``compatible_only = False`` cannot be served that way and returns
         entries that are *not* immediately suitable for constructing a phase diagram,
-        with a warning.
+        with a warning. When MP has no pre-built diagram for the system, the mixing
+        scheme is re-applied locally (logged as a warning, with a progress bar).
 
         Args:
             elements (str or [str]): Parent chemical system string comprising element
@@ -1294,16 +1300,6 @@ def get_entries_in_chemsys(
 
         all_chemsyses = _all_subchemsyses(elements_set)
 
-        if additional_criteria is None:
-            mp_warning(
-                "The default thermo type when retrieving entries has been changed from "
-                "the mixed/corrected PBE GGA and GGA+U hull (`thermo_type = GGA_GGA+U`) "
-                "to the joint PBE GGA / GGA+U / r2SCAN hull (`thermo_type = GGA_GGA+U_R2SCAN`). "
-                "To use the older behavior, call `get_entries_in_chemsys` with "
-                '`additional_criteria = {"thermo_types": ["GGA_GGA+U"]}`',
-                stacklevel=2,
-            )
-
         additional_criteria = {
             **DEFAULT_THERMOTYPE_CRITERIA,
             **(additional_criteria or {}),
@@ -1346,25 +1342,32 @@ def get_entries_in_chemsys(
             if entries is None:
                 # MP has no pre-built diagram for this system, so redo the mixing here as MP does when
                 # building PDs. Mixing scheme is chemical-system dependent, so this can anchor on a
-                # different hull than MP did/would, and it drops entries it cannot place:
+                # different hull than MP did/would, and it drops entries it cannot place.
+                # A data event the caller can't change, so a log record (not a warning):
                 from pymatgen.entries.mixing_scheme import (
                     MaterialsProjectDFTMixingScheme,
                 )
 
-                mp_warning(
-                    "Reconstructing a common energy scale for these entries with the "
-                    "GGA(+U)/r2SCAN mixing scheme, as the Materials Project has no pre-built "
-                    "phase diagram to serve for this query. Energies and hull distances may "
-                    "differ slightly from https://materialsproject.org, and entries the mixing "
-                    "scheme cannot place are dropped.",
-                    stacklevel=2,
+                chemsys = "-".join(sorted(elements_set))
+                logger.warning(
+                    f"The Materials Project has no pre-built phase diagram for {chemsys}, "
+                    "so a common energy scale is being reconstructed locally with the "
+                    "GGA(+U)/r2SCAN mixing scheme. Energies and hull distances may differ "
+                    "slightly from https://materialsproject.org, and entries the mixing "
+                    "scheme cannot place are dropped."
                 )
-                entries = MaterialsProjectDFTMixingScheme().process_entries(
-                    self._get_unmixed_entries(
-                        all_chemsyses,
-                        property_data=property_data,
-                        **kwargs,
-                    )
+                unmixed = self._get_unmixed_entries(
+                    all_chemsyses, property_data=property_data, **kwargs
+                )
+                n = len(unmixed)
+                entries = apply_corrections(
+                    MaterialsProjectDFTMixingScheme(),
+                    unmixed,
+                    description="Re-mixing GGA(+U)/r2SCAN entries",
+                    log=f"Re-mixing {n:,} GGA(+U)/r2SCAN entries onto a common energy scale",
+                    summary=f"Re-mixed {n:,} entries onto a common energy scale "
+                    "({kept:,} kept)",
+                    enabled=not self.mute_progress_bars,
                 )
 
             if extra_criteria:
@@ -1408,13 +1411,12 @@ def get_entries_in_chemsys(
         if conventional_unit_cell:
             # reshaped here rather than in the queries above, so that structure matching in the mixing
             # scheme sees the original cells, and so that every energy adjustment is scaled appropriately:
-            entries = [self._get_conventional_cell_entry(entry) for entry in entries]
+            entries = rescale_to_conventional(
+                entries, enabled=not self.mute_progress_bars
+            )
 
         if use_gibbs:
-            # replace the entries with GibbsComputedStructureEntry
-            from pymatgen.entries.computed_entries import GibbsComputedStructureEntry
-
-            return GibbsComputedStructureEntry.from_entries(entries, temp=use_gibbs)
+            return self._to_gibbs_entries(entries, use_gibbs)
 
         return entries
 
@@ -1945,11 +1947,31 @@ def get_stability(
         # side by side), and silently drops every entry it cannot pair up -- so we fetch
         # separately in the mixed case
 
-        new_pd = PhaseDiagram(
-            corrector.process_entries(joint_entries)  # type: ignore[arg-type]
-            if corrector
-            else joint_entries  # type: ignore[list-item]
-        )
+        if corrector is None:
+            processed = list(joint_entries)
+        else:
+            n_mp = len(joint_entries) - len(entries)
+            mixing = thermo_type_valid_str == ThermoType.GGA_GGA_U_R2SCAN.value
+            action = "Re-mixing" if mixing else "Correcting"
+            processed = apply_corrections(
+                corrector,
+                joint_entries,
+                description=f"{action} your entries with MP's",
+                log=(
+                    f"{action} {len(entries):,} of your entries with {n_mp:,} MP entries "
+                    + (
+                        "onto a common GGA(+U)/r2SCAN energy scale"
+                        if mixing
+                        else f"using {type(corrector).__name__}"
+                    )
+                ),
+                summary=(
+                    f"{'Re-mixed' if mixing else 'Corrected'} {len(joint_entries):,} "
+                    "entries ({kept:,} kept)"
+                ),
+                enabled=not self.mute_progress_bars,
+            )
+        new_pd = PhaseDiagram(processed)  # type: ignore[arg-type]
 
         return [
             {
diff --git a/mp_api/client/routes/materials/thermo.py b/mp_api/client/routes/materials/thermo.py
index e7fd846e..cc2101d7 100644
--- a/mp_api/client/routes/materials/thermo.py
+++ b/mp_api/client/routes/materials/thermo.py
@@ -12,6 +12,7 @@
 from pymatgen.core import Element
 
 from mp_api.client.core import BaseRester
+from mp_api.client.core._display import status
 from mp_api.client.core.client import LATEST_DB_VERSION, _normalize_db_version
 from mp_api.client.core.exceptions import MPRestError
 from mp_api.client.core.settings import DEFAULT_THERMOTYPE
@@ -213,24 +214,32 @@ def get_phase_diagram_from_chemsys(
 
         sorted_chemsys = "-".join(sorted(chemsys.split("-")))
 
-        pd_lbl, _ = self._get_delta_table(
-            "materialsproject-build", "objects/phase-diagrams", label="phase_diagrams"
-        )
-        requested = _normalize_db_version(db_version) or self.db_version
-        if requested == LATEST_DB_VERSION:
-            counts = self.delta_catalog.partition_row_counts(pd_lbl) or {}
-            version = self._resolve_db_version(requested, counts, "phase-diagrams")
-        else:
-            version = to_partition_version(requested)
-
-        query = f"""
-            SELECT phase_diagram
-            FROM   {pd_lbl}
-            WHERE  chemsys='{sorted_chemsys}'
-              AND  version='{version}'
-              AND  thermo_type='{validated_thermo_type}'
-        """
-        table = self._query_delta_single(query, label=pd_lbl)
+        # Loading the (large) table and scanning it for one system takes seconds,
+        # so show what's happening rather than blocking silently:
+        with status(
+            f"Fetching the {validated_thermo_type} phase diagram for {sorted_chemsys}...",
+            enabled=not self.mute_progress_bars,
+        ):
+            pd_lbl, _ = self._get_delta_table(
+                "materialsproject-build",
+                "objects/phase-diagrams",
+                label="phase_diagrams",
+            )
+            requested = _normalize_db_version(db_version) or self.db_version
+            if requested == LATEST_DB_VERSION:
+                counts = self.delta_catalog.partition_row_counts(pd_lbl) or {}
+                version = self._resolve_db_version(requested, counts, "phase-diagrams")
+            else:
+                version = to_partition_version(requested)
+
+            query = f"""
+                SELECT phase_diagram
+                FROM   {pd_lbl}
+                WHERE  chemsys='{sorted_chemsys}'
+                  AND  version='{version}'
+                  AND  thermo_type='{validated_thermo_type}'
+            """
+            table = self._query_delta_single(query, label=pd_lbl)
         as_py = table["phase_diagram"].to_pylist(maps_as_pydicts="strict")
 
         pd: PhaseDiagram | None = None
diff --git a/tests/client/contribs/test_side_effects.py b/tests/client/contribs/test_side_effects.py
index 209e1222..800e1116 100644
--- a/tests/client/contribs/test_side_effects.py
+++ b/tests/client/contribs/test_side_effects.py
@@ -9,7 +9,7 @@
 import pytest
 from swagger_spec_validator.common import SwaggerValidationError
 
-from mp_api.client.core.exceptions import MPContribsClientError
+from mp_api.client.core.exceptions import MPContribsClientError, MPRestError
 
 
 def test_import_has_no_global_side_effects():
@@ -122,3 +122,42 @@ def test_logging_does_not_go_to_stdout(capsys):
     MPCC_LOGGER.info("Nothing to submit.")
     out, _ = capsys.readouterr()
     assert out == ""
+
+
+def _offline_mprester(monkeypatch):
+    from mp_api.client import MPRester
+
+    monkeypatch.setattr(
+        MPRester, "_get_heartbeat_info", staticmethod(lambda endpoint: ("v", []))
+    )
+    monkeypatch.setattr(MPRester, "get_emmet_version", staticmethod(lambda ep: None))
+    return MPRester(api_key="a" * 32)
+
+
+def test_missing_contribs_extra_raises(monkeypatch):
+    """Without the extra, `contribs` (and everything using it) raises ImportError."""
+    mpr = _offline_mprester(monkeypatch)
+    monkeypatch.setitem(sys.modules, "mp_api.client.contribs.client", None)
+    with pytest.raises(ImportError, match=r"pip install 'mp-api\[contribs\]'"):
+        mpr.contribs
+    with pytest.raises(ImportError, match="MPContribs client needs"):
+        mpr.get_pourbaix_entries("Li-Mn-O")
+
+
+def test_contribs_load_failure_raises_and_is_not_cached(monkeypatch):
+    from mp_api.client.contribs import client as contribs_client
+
+    mpr = _offline_mprester(monkeypatch)
+    calls = []
+
+    class Flaky:
+        def __init__(self, **kwargs):
+            calls.append(1)
+            if len(calls) == 1:
+                raise ConnectionError("server unreachable")
+
+    monkeypatch.setattr(contribs_client, "ContribsClient", Flaky)
+    with pytest.raises(MPRestError, match="server unreachable"):
+        mpr.contribs
+    assert isinstance(mpr.contribs, Flaky)  # retried, not cached as None
+    assert mpr.contribs is mpr.contribs and len(calls) == 2
diff --git a/tests/client/materials/test_eos.py b/tests/client/materials/test_eos.py
index dbce1bc2..32b91158 100644
--- a/tests/client/materials/test_eos.py
+++ b/tests/client/materials/test_eos.py
@@ -3,7 +3,7 @@
 import pytest
 
 from mp_api._test_utils import client_search_testing, requires_api_key
-from mp_api.client.core.exceptions import MPRestError, MPRestWarning
+from mp_api.client.core.exceptions import MPRestError
 from mp_api.client.routes.materials.eos import EOSRester
 
 
@@ -48,7 +48,7 @@ def test_client(rester):
 def test_warnings_errors(rester):
 
     with pytest.warns(
-        MPRestWarning, match="`material_id` has been replaced by `task_id`"
+        FutureWarning, match="`material_id` has been replaced by `task_id`"
     ):
         rester.search(material_ids=["mp-149"], num_chunks=1, chunk_size=1)
 
diff --git a/tests/client/test_entry_progress.py b/tests/client/test_entry_progress.py
new file mode 100644
index 00000000..caff1d59
--- /dev/null
+++ b/tests/client/test_entry_progress.py
@@ -0,0 +1,427 @@
+"""Progress and messaging for client-side entry processing. No network access.
+
+Covers `core/_entries.py` (rescaling to conventional cells, corrections and the
+GGA(+U)/r2SCAN mixing scheme behind a progress bar) and how MPRester's entry
+methods use it, with the API mocked out.
+"""
+
+import io
+import logging
+import re
+import warnings
+
+import pytest
+from pymatgen.core import Lattice, Structure
+from pymatgen.entries.compatibility import MaterialsProject2020Compatibility
+from pymatgen.entries.computed_entries import (
+    ComputedStructureEntry,
+    GibbsComputedStructureEntry,
+)
+from pymatgen.entries.mixing_scheme import MaterialsProjectDFTMixingScheme
+from rich.console import Console
+
+import mp_api.client.core._display as display
+from mp_api.client import quiet
+from mp_api.client.core._display import MP_THEME, progress_bar
+from mp_api.client.core._entries import (
+    _track_calls,
+    apply_corrections,
+    _private_copy,
+    get_conventional_cell_entry,
+    rescale_to_conventional,
+)
+from mp_api.client.core.exceptions import MPRestWarning
+
+CLIENT = "mp_api.client"
+
+
+def messages(caplog) -> list[str]:
+    """Distinct messages; caplog can see a record twice (root handler and ours)."""
+    return list({id(r): r.getMessage() for r in caplog.records}.values())
+
+
+@pytest.fixture
+def term():
+    buf = io.StringIO()
+    display.set_console(
+        Console(
+            file=buf, force_terminal=True, force_jupyter=False, width=140, theme=MP_THEME
+        )
+    )
+    yield buf
+    display.set_console(None)
+
+
+@pytest.fixture
+def pipe():
+    buf = io.StringIO()
+    display.set_console(Console(file=buf, force_terminal=False, force_jupyter=False))
+    yield buf
+    display.set_console(None)
+
+
+def plain(text: str) -> str:
+    return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text)
+
+
+def _entry(entry_id, run_type, species, coords, a, energy_per_atom, cubic="P"):
+    lattice = Lattice.cubic(a)
+    if cubic == "F":  # primitive fcc cell: 4x fewer sites than conventional
+        lattice = Lattice.from_parameters(a, a, a, 60, 60, 60)
+    structure = Structure(lattice, species, coords)
+    return ComputedStructureEntry(
+        structure,
+        energy_per_atom * len(structure),
+        parameters={
+            "run_type": run_type,
+            "hubbards": {},
+            "is_hubbard": False,
+            "potcar_symbols": [],
+        },
+        entry_id=entry_id,
+    )
+
+
+def li_o_entries():
+    """Small, complete GGA and r2SCAN Li-O systems, enough for the mixing scheme."""
+    entries = []
+    for run_type, shift in (("GGA", 0.0), ("r2SCAN", -1.0)):
+        entries += [
+            _entry(f"{run_type}-1", run_type, ["Li"], [[0, 0, 0]], 3.4, -1.9 + shift),
+            _entry(
+                f"{run_type}-2",
+                run_type,
+                ["O", "O"],
+                [[0, 0, 0], [0.5, 0.5, 0.5]],
+                4.0,
+                -4.9 + shift,
+            ),
+            _entry(
+                f"{run_type}-3",
+                run_type,
+                ["Li", "Li", "O"],
+                [[0, 0, 0], [0.5, 0.5, 0.5], [0.25, 0.25, 0.25]],
+                4.6,
+                -4.8 + shift,
+            ),
+            _entry(
+                f"{run_type}-4",
+                run_type,
+                ["Li", "O"],
+                [[0, 0, 0], [0.5, 0.5, 0.5]],
+                3.0,
+                -4.0 + shift,
+            ),
+        ]
+    return entries
+
+
+def mixing_scheme(**kwargs):
+    # synthetic entries carry no POTCAR info
+    return MaterialsProjectDFTMixingScheme(
+        compat_1=MaterialsProject2020Compatibility(check_potcar=False),
+        check_potcar=False,
+        **kwargs,
+    )
+
+
+def fcc_entry(material_id="mp-1"):
+    return _entry(
+        f"{material_id}-GGA", "GGA", ["Cu"], [[0, 0, 0]], 3.6, -3.7, cubic="F"
+    )
+
+
+# --- _track_calls ------------------------------------------------------------------
+
+
+def test_track_calls_counts_and_restores():
+    class Obj:
+        def work(self, items):
+            return len(items)
+
+    obj = Obj()
+    handle = display.ProgressHandle(total=10)
+    with _track_calls(obj, "work", handle, lambda items: len(items)):
+        assert obj.work([1, 2, 3]) == 3
+        assert obj.work([1]) == 1
+    assert handle.completed == 4
+    assert "work" not in vars(obj)  # instance attribute removed again
+    assert Obj.work.__qualname__.endswith("Obj.work")  # class untouched
+
+
+def test_track_calls_never_passes_total():
+    class Obj:
+        def work(self):
+            return None
+
+    obj = Obj()
+    handle = display.ProgressHandle(total=2)
+    with _track_calls(obj, "work", handle):
+        for _ in range(5):
+            obj.work()
+    assert handle.completed == 2
+
+
+def test_track_calls_counts_calls_that_raise():
+    class Obj:
+        def work(self):
+            raise ValueError
+
+    obj = Obj()
+    handle = display.ProgressHandle(total=2)
+    with _track_calls(obj, "work", handle):
+        with pytest.raises(ValueError):
+            obj.work()
+    assert handle.completed == 1
+
+
+def test_track_calls_restores_on_error_and_keeps_own_attribute():
+    class Obj:
+        pass
+
+    obj = Obj()
+    obj.work = lambda: 1 / 0  # an instance attribute already
+    original = obj.work
+    with pytest.raises(ZeroDivisionError):
+        with _track_calls(obj, "work", display.ProgressHandle(total=1)):
+            obj.work()
+    assert obj.work is original
+
+
+def test_private_copy_bypasses_pymatgen_instance_cache():
+    shared = MaterialsProject2020Compatibility()
+    assert MaterialsProject2020Compatibility() is shared  # monty @cached_class
+    clone = _private_copy(shared)
+    assert clone is not shared and type(clone) is type(shared)
+    clone.get_adjustments = None
+    assert "get_adjustments" not in vars(shared)
+    assert MaterialsProject2020Compatibility() is shared  # cache untouched
+
+
+# --- results are unchanged ---------------------------------------------------------
+
+
+def test_mixing_results_identical_with_progress(term):
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        expected = mixing_scheme().process_entries(li_o_entries())
+        scheme = mixing_scheme()
+        processed = apply_corrections(
+            scheme,
+            li_o_entries(),
+            description="Re-mixing",
+            log="Re-mixing",
+            summary="Re-mixed 8 entries ({kept:,} kept)",
+        )
+    assert [(e.entry_id, e.energy) for e in processed] == pytest.approx(
+        [(e.entry_id, e.energy) for e in expected]
+    )
+    # the caller's scheme and its parts were never modified
+    for obj in (scheme, scheme.compat_1, scheme.structure_matcher):
+        assert not {"get_adjustments", "group_structures"} & set(vars(obj))
+    assert MaterialsProject2020Compatibility(check_potcar=False) is scheme.compat_1
+    out = plain(term.getvalue())
+    assert f"Re-mixed 8 entries ({len(expected)} kept)" in out
+
+
+def test_mixing_bar_counts_passes(monkeypatch):
+    """The counted passes add up to the number of entries without the final top-up."""
+    seen = []
+    real_advance = display.ProgressHandle.update
+
+    def record(self, advance=1):
+        seen.append(advance)
+        real_advance(self, advance)
+
+    monkeypatch.setattr(display.ProgressHandle, "update", record)
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        apply_corrections(
+            mixing_scheme(), li_o_entries(), description="x", log="x", summary="x"
+        )
+    # the counted calls cover most of the work (O2's structures are grouped by
+    # pymatgen without the structure matcher, so they're filled in at the end)
+    counted = sum(seen[:-1])
+    assert 0.8 * 8 <= counted < 8
+    assert sum(seen) == pytest.approx(8)
+
+
+def test_compatibility_corrections_counted(term, monkeypatch):
+    entries = [e for e in li_o_entries() if e.parameters["run_type"] == "GGA"]
+    compat = MaterialsProject2020Compatibility(check_potcar=False)
+    calls = []
+    real = MaterialsProject2020Compatibility.get_adjustments
+
+    def spy(self, entry):
+        calls.append(entry.entry_id)
+        return real(self, entry)
+
+    monkeypatch.setattr(MaterialsProject2020Compatibility, "get_adjustments", spy)
+    processed = apply_corrections(
+        compat,
+        entries,
+        description="Correcting",
+        log="Correcting",
+        summary="Corrected 4 entries ({kept:,} kept)",
+    )
+    assert len(calls) == 4 and len(processed) == 4
+    assert "get_adjustments" not in vars(compat)
+    assert "Corrected 4 entries (4 kept)" in plain(term.getvalue())
+
+
+def test_rescale_matches_per_entry_function(term):
+    entry = fcc_entry()
+    [rescaled] = rescale_to_conventional([entry])
+    direct = get_conventional_cell_entry(entry)
+    assert len(rescaled.structure) == len(direct.structure) == 4
+    assert rescaled.energy_per_atom == pytest.approx(entry.energy_per_atom)
+    out = plain(term.getvalue())
+    assert "Rescaled 1 entry to conventional unit cells" in out
+
+
+# --- progress_bar additions --------------------------------------------------------
+
+
+def test_progress_bar_log_when_not_drawable(pipe, caplog):
+    with caplog.at_level(logging.INFO, logger=CLIENT):
+        with progress_bar("x", total=1, log="Doing the thing") as handle:
+            handle.update()
+    assert messages(caplog) == ["Doing the thing"]
+    assert pipe.getvalue() == ""
+
+
+@pytest.mark.parametrize("muted", [True, False])
+def test_progress_bar_log_silent_when_muted_or_quiet(pipe, caplog, muted):
+    with caplog.at_level(logging.DEBUG, logger=CLIENT):
+        if muted:
+            with progress_bar("x", total=1, log="msg", enabled=False):
+                pass
+        else:
+            with quiet(), progress_bar("x", total=1, log="msg"):
+                pass
+    assert not caplog.records
+
+
+def test_progress_bar_summary_can_be_replaced(term):
+    with progress_bar("x", total=2, summary="placeholder") as handle:
+        handle.update(2)
+        handle.summary = "Done {completed} of {total}, {{literal}}"
+    assert "Done 2 of 2, {literal}" in plain(term.getvalue())
+
+
+# --- MPRester entry methods --------------------------------------------------------
+
+
+@pytest.fixture
+def mpr(monkeypatch):
+    from mp_api.client import MPRester
+    from mp_api.client.core.client import _Rester
+
+    monkeypatch.setattr(
+        _Rester, "_get_heartbeat_info", staticmethod(lambda ep: ("2026.04.13", []))
+    )
+    monkeypatch.setattr(MPRester, "get_emmet_version", staticmethod(lambda ep: None))
+    return MPRester(api_key="a" * 32)
+
+
+@pytest.fixture
+def thermo(monkeypatch):
+    """Patch `ThermoRester` methods (MPRester's resters are lazy wrappers)."""
+    from mp_api.client.routes.materials.thermo import ThermoRester
+
+    def patch(name, func):
+        monkeypatch.setattr(ThermoRester, name, func)
+
+    return patch
+
+
+def _thermo_docs(entries):
+    return [
+        {
+            "entries": {"GGA": e.as_dict()},
+            "thermo_type": "GGA_GGA+U",
+            "material_id": e.entry_id,
+        }
+        for e in entries
+    ]
+
+
+def test_get_entries_conventional_shows_progress(mpr, thermo, term):
+    entries = [fcc_entry("mp-1"), fcc_entry("mp-2")]
+    thermo("search", lambda self, **kw: _thermo_docs(entries))
+    result = mpr.get_entries(["mp-1", "mp-2"], conventional_unit_cell=True)
+    assert sorted(len(e.structure) for e in result) == [4, 4]
+    assert "Rescaled 2 entries to conventional unit cells" in plain(term.getvalue())
+
+
+def test_get_entries_conventional_logs_in_scripts(mpr, thermo, pipe, caplog):
+    thermo("search", lambda self, **kw: _thermo_docs([fcc_entry()]))
+    with caplog.at_level(logging.INFO, logger=CLIENT):
+        mpr.get_entries("mp-1", conventional_unit_cell=True)
+    assert "Rescaling 1 entry to conventional unit cells" in messages(caplog)
+
+
+def test_get_entries_conventional_silent_when_muted(mpr, thermo, pipe, caplog):
+    mpr.mute_progress_bars = True
+    thermo("search", lambda self, **kw: _thermo_docs([fcc_entry()]))
+    with caplog.at_level(logging.DEBUG, logger=CLIENT):
+        mpr.get_entries("mp-1", conventional_unit_cell=True)
+    assert not caplog.records and pipe.getvalue() == ""
+
+
+def test_remix_without_prebuilt_pd(mpr, thermo, monkeypatch, term, caplog):
+    """No pre-built PD: a log warning (not MPRestWarning) and a re-mixing bar."""
+    thermo("get_phase_diagram_from_chemsys", lambda self, *a, **k: None)
+    monkeypatch.setattr(
+        type(mpr), "_get_unmixed_entries", lambda self, *a, **k: li_o_entries()
+    )
+    real_init = MaterialsProjectDFTMixingScheme.__init__
+
+    def init(self, **kw):  # synthetic entries carry no POTCAR info
+        kw.setdefault("compat_1", MaterialsProject2020Compatibility(check_potcar=False))
+        real_init(self, check_potcar=False, **kw)
+
+    monkeypatch.setattr(MaterialsProjectDFTMixingScheme, "__init__", init)
+    with warnings.catch_warnings(record=True) as record, caplog.at_level(
+        logging.INFO, logger=CLIENT
+    ):
+        warnings.simplefilter("always")
+        entries = mpr.get_entries_in_chemsys(
+            "Li-O", additional_criteria={"thermo_types": ["GGA_GGA+U_R2SCAN"]}
+        )
+    assert entries
+    assert not [w for w in record if issubclass(w.category, MPRestWarning)]
+    assert any(
+        "no pre-built phase diagram for Li-O" in r.getMessage()
+        for r in caplog.records
+        if r.levelno == logging.WARNING
+    )
+    out = plain(term.getvalue())
+    assert f"Re-mixed 8 entries onto a common energy scale ({len(entries)} kept)" in out
+
+
+def test_gibbs_conversion_shows_status(mpr, pipe, caplog):
+    entries = [e for e in li_o_entries() if e.parameters["run_type"] == "GGA"]
+    with caplog.at_level(logging.INFO, logger=CLIENT):
+        gibbs = mpr._to_gibbs_entries(entries, 300)
+    assert gibbs and all(isinstance(e, GibbsComputedStructureEntry) for e in gibbs)
+    assert "Estimating Gibbs free energies at 300 K for 4 entries..." in messages(
+        caplog
+    )
+
+
+def test_get_stability_shows_progress(mpr, thermo, monkeypatch, term):
+    from pymatgen.analysis.phase_diagram import PhaseDiagram
+
+    gga = [e for e in li_o_entries() if e.parameters["run_type"] == "GGA"]
+    thermo(
+        "get_phase_diagram_from_chemsys", lambda self, *a, **k: PhaseDiagram(gga[:3])
+    )
+    monkeypatch.setattr(
+        "pymatgen.entries.compatibility.MaterialsProject2020Compatibility",
+        lambda: MaterialsProject2020Compatibility(check_potcar=False),
+    )
+    mine = [gga[3]]
+    result = mpr.get_stability(mine, thermo_type="GGA_GGA+U")
+    assert result and result[0]["entry_id"] == "GGA-4"
+    assert "Corrected 4 entries (4 kept)" in plain(term.getvalue())
diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py
index 810be8f6..7720f55f 100644
--- a/tests/client/test_mprester.py
+++ b/tests/client/test_mprester.py
@@ -1,5 +1,6 @@
 import importlib
 import itertools
+import logging
 import os
 import random
 import warnings
@@ -81,7 +82,7 @@ def test_get_database_version(self, mpr):
         assert db_version is not None
 
         with pytest.warns(
-            MPRestWarning, match="`get_database_version` has been deprecated"
+            FutureWarning, match="`get_database_version` has been deprecated"
         ):
             assert db_version == mpr.get_database_version()
 
@@ -287,10 +288,10 @@ def test_get_entries(self, mpr):
     def test_get_entries_in_chemsys(self, mpr):
         syms = ["Li", "Fe", "O"]
         syms2 = "Li-Fe-O"
-        with pytest.warns(
-            MPRestWarning, match="The default thermo type when retrieving entries"
-        ):
-            entries = mpr.get_entries_in_chemsys(syms)
+        with warnings.catch_warnings(record=True) as record:
+            warnings.simplefilter("always")
+            entries = mpr.get_entries_in_chemsys(syms)  # default thermo type
+        assert not [w for w in record if issubclass(w.category, MPRestWarning)]
         entries2 = mpr.get_entries_in_chemsys(
             syms2, additional_criteria={"thermo_types": [ThermoType.GGA_GGA_U_R2SCAN]}
         )
@@ -694,7 +695,7 @@ def test_get_cohesive_energy(self):
     @pytest.mark.parametrize(
         "thermo_type", ["GGA_GGA+U", ThermoType.GGA_GGA_U_R2SCAN, "r2SCAN"]
     )
-    def test_get_stability(self, thermo_type):
+    def test_get_stability(self, thermo_type, caplog):
         """
         This test is adapted from the pymatgen one - the scope is broadened
         to include more diverse chemical environments and thermo types which
@@ -742,10 +743,15 @@ def test_get_stability(self, thermo_type):
                     ):
                         # For a multi-element chemsys with no multinaries, only elementals,
                         # there should be no phase diagram data available.
-                        with pytest.warns(
-                            MPRestWarning, match="No phase diagram data available"
-                        ):
-                            mpr.get_stability(modified_entries, thermo_type=thermo_type)
+                        # a data event, so a log record rather than a warning
+                        with caplog.at_level(logging.WARNING, logger="mp_api.client"):
+                            assert (
+                                mpr.get_stability(
+                                    modified_entries, thermo_type=thermo_type
+                                )
+                                is None
+                            )
+                        assert "No phase diagram data available" in caplog.text
                         return
 
                     else:
@@ -877,9 +883,9 @@ def test_db_warning(self, monkeypatch: pytest.MonkeyPatch):
             assert parsed_db_ver == db_version
             assert isinstance(parsed_db_ver, str)
 
-    def test_warnings_exceptions(self):
-        # Generic warnings/exceptions tests, nothji
-        with pytest.warns(MPRestWarning, match="Ignoring `monty_decode`"):
+    def test_warnings_exceptions(self, caplog):
+        # Generic warnings/exceptions tests
+        with pytest.warns(FutureWarning, match="Ignoring `monty_decode`"):
             MPRester(monty_decode=False)
 
         with MPRester() as mpr:
@@ -889,10 +895,10 @@ def test_warnings_exceptions(self):
             ):
                 mpr.query(some_field=1.0)
 
-            with pytest.warns(
-                MPRestWarning, match="No material found containing task mp-0"
-            ):
+            # a data event, so a log record rather than a warning
+            with caplog.at_level(logging.WARNING, logger="mp_api.client"):
                 assert mpr.get_material_id_from_task_id("mp-0") is None
+            assert "No material found containing task mp-0" in caplog.text
 
             for attr in mpr._deprecated_attributes:
                 with pytest.warns(
@@ -900,7 +906,7 @@ def test_warnings_exceptions(self):
                 ):
                     getattr(mpr, attr, None)
 
-    def test_min_emmet_warning(self, monkeypatch: pytest.MonkeyPatch):
+    def test_min_emmet_warning(self, monkeypatch: pytest.MonkeyPatch, caplog):
         from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS
 
         with MPRester() as mpr:
@@ -908,7 +914,6 @@ def test_min_emmet_warning(self, monkeypatch: pytest.MonkeyPatch):
             monkeypatch.setattr(
                 MAPI_CLIENT_SETTINGS, "MIN_EMMET_VERSION", f"{emmet_ver.major + 1}.0.0"
             )
-            with pytest.warns(
-                MPRestWarning, match="The installed version of the mp-api"
-            ):
+            with caplog.at_level(logging.WARNING, logger="mp_api.client"):
                 MPRester()
+            assert "The installed version of the mp-api" in caplog.text
diff --git a/tests/mcp/test_utils.py b/tests/mcp/test_utils.py
index fbd2fd63..e4930100 100644
--- a/tests/mcp/test_utils.py
+++ b/tests/mcp/test_utils.py
@@ -1,6 +1,6 @@
 import pytest
 
-from mp_api.client.core.exceptions import MPRestError, MPRestWarning
+from mp_api.client.core.exceptions import MPRestError
 from mp_api.mcp.utils import _NeedsMPClient
 
 
@@ -14,7 +14,7 @@ def test_mix_in():
     with pytest.raises(MPRestError, match="Valid API keys are 32"):
         test_class.update_user_api_key(30 * "a")
 
-    with pytest.warns(MPRestWarning, match="Ignoring `monty_decode`"):
+    with pytest.warns(FutureWarning, match="Ignoring `monty_decode`"):
         # Test that `use_document_model` is always enforced to be False, and user agent is included
         test_class = _NeedsMPClient(
             client_kwargs={

From 72e24868e61c512d4e57f2a35f2b48145a199727 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Thu, 8 Oct 2026 15:57:23 -0700
Subject: [PATCH 15/30] linting

---
 mp_api/client/core/_entries.py      |  4 +---
 tests/client/test_entry_progress.py | 11 ++++++++---
 2 files changed, 9 insertions(+), 6 deletions(-)

diff --git a/mp_api/client/core/_entries.py b/mp_api/client/core/_entries.py
index e35fa2b8..29f1be93 100644
--- a/mp_api/client/core/_entries.py
+++ b/mp_api/client/core/_entries.py
@@ -217,9 +217,7 @@ def apply_corrections(
             stack.enter_context(
                 _track_calls(corrector.solid_compat, "get_adjustments", handle, 0.5)
             )
-            stack.enter_context(
-                _track_calls(corrector, "get_adjustments", handle, 0.5)
-            )
+            stack.enter_context(_track_calls(corrector, "get_adjustments", handle, 0.5))
         else:
             stack.enter_context(_track_calls(corrector, "get_adjustments", handle))
 
diff --git a/tests/client/test_entry_progress.py b/tests/client/test_entry_progress.py
index caff1d59..2ad6bbba 100644
--- a/tests/client/test_entry_progress.py
+++ b/tests/client/test_entry_progress.py
@@ -45,7 +45,11 @@ def term():
     buf = io.StringIO()
     display.set_console(
         Console(
-            file=buf, force_terminal=True, force_jupyter=False, width=140, theme=MP_THEME
+            file=buf,
+            force_terminal=True,
+            force_jupyter=False,
+            width=140,
+            theme=MP_THEME,
         )
     )
     yield buf
@@ -382,8 +386,9 @@ def init(self, **kw):  # synthetic entries carry no POTCAR info
         real_init(self, check_potcar=False, **kw)
 
     monkeypatch.setattr(MaterialsProjectDFTMixingScheme, "__init__", init)
-    with warnings.catch_warnings(record=True) as record, caplog.at_level(
-        logging.INFO, logger=CLIENT
+    with (
+        warnings.catch_warnings(record=True) as record,
+        caplog.at_level(logging.INFO, logger=CLIENT),
     ):
         warnings.simplefilter("always")
         entries = mpr.get_entries_in_chemsys(

From 2031ee7ea7266d6a0798f56521637f67a2f0a916 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Thu, 8 Oct 2026 15:59:27 -0700
Subject: [PATCH 16/30] bump emmet

---
 pyproject.toml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index a663eabc..ea5c117e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@ dependencies = [
   "pymatgen>2024.2.20",
   "requests>=2.23.0",
   "monty>=2024.12.10",
-  "emmet-core>=0.87.3rc2,<0.87.4",
+  "emmet-core>=0.88.0rc,<0.88.1",
   "boto3",
   "orjson >= 3.10,<4",
   "pyarrow >= 20.0.0",
@@ -70,7 +70,7 @@ contribs = [
 ]
 all = [
   "custodian",
-  "emmet-core[all]>=0.87.3rc2,<0.87.4",
+  "emmet-core[all]>=0.88.0rc,<0.88.1",
   "fastmcp",
   "flask",
   "ipywidgets",

From 538bcfc4d5eb1ba23a28982fa2ffaf5fc5e6c45b Mon Sep 17 00:00:00 2001
From: github-actions 
Date: Thu, 8 Oct 2026 23:02:35 +0000
Subject: [PATCH 17/30] auto dependency upgrades

---
 .../requirements-ubuntu-latest_py3.12.txt     |  26 ++--
 ...quirements-ubuntu-latest_py3.12_extras.txt | 118 +++++++++---------
 .../requirements-ubuntu-latest_py3.13.txt     |  26 ++--
 ...quirements-ubuntu-latest_py3.13_extras.txt | 118 +++++++++---------
 .../requirements-ubuntu-latest_py3.14.txt     |  26 ++--
 ...quirements-ubuntu-latest_py3.14_extras.txt | 118 +++++++++---------
 6 files changed, 234 insertions(+), 198 deletions(-)

diff --git a/requirements/requirements-ubuntu-latest_py3.12.txt b/requirements/requirements-ubuntu-latest_py3.12.txt
index e353464f..8edf03c6 100644
--- a/requirements/requirements-ubuntu-latest_py3.12.txt
+++ b/requirements/requirements-ubuntu-latest_py3.12.txt
@@ -2,7 +2,7 @@
 # This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
-#    pip-compile --no-index --output-file=requirements/requirements-ubuntu-latest_py3.12.txt pyproject.toml
+#    pip-compile --output-file=requirements/requirements-ubuntu-latest_py3.12.txt pyproject.toml
 #
 annotated-types==0.8.0
     # via pydantic
@@ -10,11 +10,11 @@ arro3-core==0.9.0
     # via deltalake
 bibtexparser==1.4.4
     # via pymatgen-core
-blake3==1.0.10
+blake3==1.0.11
     # via emmet-core
-boto3==1.43.108
+boto3==1.43.110
     # via mp-api (pyproject.toml)
-botocore==1.43.108
+botocore==1.43.110
     # via
     #   boto3
     #   s3transfer
@@ -32,7 +32,7 @@ deltalake==1.5.1
     # via mp-api (pyproject.toml)
 deprecated==3.0.0
     # via deltalake
-emmet-core==0.87.3rc2
+emmet-core==0.88.0rc0
     # via mp-api (pyproject.toml)
 fonttools==4.66.1
     # via matplotlib
@@ -52,8 +52,12 @@ latexcodec==3.0.1
     # via pybtex
 lxml==6.1.3
     # via pymatgen-core
+markdown-it-py==4.2.0
+    # via rich
 matplotlib==3.11.2
     # via pymatgen-core
+mdurl==0.1.2
+    # via markdown-it-py
 monty==2026.7.16
     # via
     #   emmet-core
@@ -77,7 +81,7 @@ numpy==2.5.3
     #   pymatgen-io-validation
     #   scipy
     #   spglib
-orjson==3.12.0
+orjson==3.13.0
     # via
     #   mp-api (pyproject.toml)
     #   pymatgen-core
@@ -99,17 +103,19 @@ pyarrow==25.0.1
     # via mp-api (pyproject.toml)
 pybtex==0.26.1
     # via emmet-core
-pydantic==2.13.5
+pydantic==2.14.0
     # via
     #   emmet-core
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.46.5
+pydantic-core==2.50.0
     # via pydantic
 pydantic-settings==2.15.0
     # via
     #   emmet-core
     #   pymatgen-io-validation
+pygments==2.21.0
+    # via rich
 pymatgen==2026.9.24
     # via
     #   emmet-core
@@ -139,6 +145,8 @@ requests==2.34.2
     #   mp-api (pyproject.toml)
     #   pymatgen-core
     #   pymatgen-io-validation
+rich==15.0.0
+    # via mp-api (pyproject.toml)
 ruamel-yaml==0.19.1
     # via monty
 s3transfer==0.19.2
@@ -159,8 +167,6 @@ typeguard==4.6.0
     # via inflect
 typing-extensions==4.16.0
     # via
-    #   emmet-core
-    #   mp-api (pyproject.toml)
     #   pydantic
     #   pydantic-core
     #   spglib
diff --git a/requirements/requirements-ubuntu-latest_py3.12_extras.txt b/requirements/requirements-ubuntu-latest_py3.12_extras.txt
index 711b9139..70056458 100644
--- a/requirements/requirements-ubuntu-latest_py3.12_extras.txt
+++ b/requirements/requirements-ubuntu-latest_py3.12_extras.txt
@@ -2,7 +2,7 @@
 # This file is autogenerated by pip-compile with Python 3.12
 # by the following command:
 #
-#    pip-compile --all-extras --no-index --output-file=requirements/requirements-ubuntu-latest_py3.12_extras.txt pyproject.toml
+#    pip-compile --all-extras --output-file=requirements/requirements-ubuntu-latest_py3.12_extras.txt pyproject.toml
 #
 aiofile==3.12.3
     # via py-key-value-aio
@@ -24,7 +24,7 @@ arrow==1.4.0
     # via isoduration
 ase==3.29.0
     # via pymatgen-analysis-diffusion
-ast-serialize==0.12.0
+ast-serialize==0.12.1
     # via mypy
 asttokens==3.0.2
     # via stack-data
@@ -42,15 +42,15 @@ beartype==0.22.9
     # via py-key-value-aio
 bibtexparser==1.4.4
     # via pymatgen-core
-blake3==1.0.10
+blake3==1.0.11
     # via emmet-core
 blinker==1.9.0
     # via flask
 boltons==26.2.0
     # via mp-api (pyproject.toml)
-boto3==1.43.108
+boto3==1.43.110
     # via mp-api (pyproject.toml)
-botocore==1.43.108
+botocore==1.43.110
     # via
     #   boto3
     #   s3transfer
@@ -58,7 +58,7 @@ bravado==12.0.1
     # via mp-api (pyproject.toml)
 bravado-core==6.4.1
     # via bravado
-cachetools==7.2.0
+cachetools==7.2.1
     # via
     #   mp-api (pyproject.toml)
     #   py-key-value-aio
@@ -78,6 +78,8 @@ click==8.5.0
     #   uvicorn
 cloudpickle==3.1.2
     # via joblib
+comm==0.2.3
+    # via ipywidgets
 contourpy==1.4.0
     # via matplotlib
 coverage[toml]==7.16.2
@@ -89,10 +91,12 @@ cryptography==50.0.2
     #   pyjwt
     #   secretstorage
 custodian==2025.12.14
-    # via mp-api (pyproject.toml)
+    # via
+    #   emmet-core
+    #   mp-api (pyproject.toml)
 cycler==0.12.1
     # via matplotlib
-cyclopts==5.1.1
+cyclopts==5.2.0
     # via fastmcp-slim
 deltalake==1.5.1
     # via mp-api (pyproject.toml)
@@ -111,7 +115,7 @@ docutils==0.22.4
     # via sphinx
 email-validator==2.3.0
     # via pydantic
-emmet-core[all]==0.87.3rc2
+emmet-core[all]==0.88.0rc0
     # via mp-api (pyproject.toml)
 exceptiongroup==1.3.1
     # via fastmcp-slim
@@ -119,18 +123,16 @@ execnet==2.1.2
     # via pytest-xdist
 executing==2.2.1
     # via stack-data
-fastmcp==4.0.10
+fastmcp==4.1.0
     # via mp-api (pyproject.toml)
-fastmcp-slim[client,server]==4.0.10
+fastmcp-slim[client,server]==4.1.0
     # via fastmcp
-filelock==4.0.9
+filelock==4.0.12
     # via
     #   python-discovery
     #   virtualenv
 filetype==1.2.0
     # via mp-api (pyproject.toml)
-flake8==7.4.1
-    # via mp-api (pyproject.toml)
 flask==3.1.3
     # via mp-api (pyproject.toml)
 flexcache==0.3
@@ -141,7 +143,7 @@ fonttools==4.66.1
     # via matplotlib
 fqdn==1.6.0
     # via jsonschema
-griffelib==2.3.0
+griffelib==2.3.2
     # via fastmcp-slim
 h11==0.16.0
     # via
@@ -164,7 +166,7 @@ idna==3.20
     #   httpx2
     #   jsonschema
     #   requests
-imageio==2.38.0
+imageio==2.38.1
     # via scikit-image
 imagesize==2.0.1
     # via sphinx
@@ -172,12 +174,16 @@ importlib-resources==7.1.0
     # via swagger-spec-validator
 inflect==7.5.0
     # via emmet-core
-iniconfig==2.3.0
+iniconfig==2.3.1
     # via pytest
 ipython==9.17.1
-    # via mp-api (pyproject.toml)
+    # via
+    #   ipywidgets
+    #   mp-api (pyproject.toml)
 ipython-pygments-lexers==1.1.1
     # via ipython
+ipywidgets==8.1.9
+    # via mp-api (pyproject.toml)
 isoduration==20.11.0
     # via jsonschema
 itsdangerous==2.2.0
@@ -212,7 +218,7 @@ joserfc==1.7.5
     #   fastmcp-slim
 json2html==1.3.0
     # via mp-api (pyproject.toml)
-jsonpointer==3.1.1
+jsonpointer==3.2.0
     # via jsonschema
 jsonref==1.1.0
     # via
@@ -227,12 +233,16 @@ jsonschema-path==0.5.0
     # via fastmcp-slim
 jsonschema-specifications==2025.9.1
     # via jsonschema
+jupyterlab-widgets==3.0.17
+    # via ipywidgets
 keyring==25.7.0
     # via py-key-value-aio
 kiwisolver==1.5.1
     # via matplotlib
 lark==1.3.1
-    # via rfc3987-syntax
+    # via
+    #   optimade
+    #   rfc3987-syntax
 latexcodec==3.0.1
     # via pybtex
 lazy-loader==0.6
@@ -245,7 +255,7 @@ lxml==6.1.3
     # via pymatgen-core
 markdown-it-py==4.2.0
     # via rich
-markupsafe==3.0.3
+markupsafe==3.0.4
     # via
     #   flask
     #   jinja2
@@ -258,11 +268,9 @@ matplotlib==3.11.2
     #   seaborn
 matplotlib-inline==0.2.2
     # via ipython
-mccabe==0.7.0
-    # via flake8
-mcp==2.2.0
+mcp==2.3.0
     # via fastmcp-slim
-mcp-types==2.2.0
+mcp-types==2.3.0
     # via
     #   fastmcp-slim
     #   mcp
@@ -292,9 +300,7 @@ msgpack==1.2.3
 mypy==2.4.0
     # via mp-api (pyproject.toml)
 mypy-extensions==1.1.0
-    # via
-    #   mp-api (pyproject.toml)
-    #   mypy
+    # via mypy
 narwhals==2.26.0
     # via plotly
 networkx==3.7
@@ -328,11 +334,13 @@ numpy==2.5.3
     #   tifffile
 openapi-pydantic==0.6.0
     # via fastmcp-slim
-opentelemetry-api==1.45.0
+opentelemetry-api==1.45.1
     # via
     #   fastmcp-slim
     #   mcp
-orjson==3.12.0
+optimade==1.5.0
+    # via emmet-core
+orjson==3.13.0
     # via
     #   mp-api (pyproject.toml)
     #   pymatgen-core
@@ -361,7 +369,7 @@ pathspec==1.1.1
     # via mypy
 pexpect==4.9.0
     # via ipython
-phonopy==4.8.0
+phonopy==4.8.1
     # via emmet-core
 phonors==0.6.0
     # via phonopy
@@ -372,7 +380,7 @@ pillow==12.3.0
     #   scikit-image
 pint==0.26.1
     # via mp-api (pyproject.toml)
-platformdirs==4.12.2
+platformdirs==4.12.4
     # via
     #   fastmcp-slim
     #   pint
@@ -407,30 +415,26 @@ pybtex==0.26.1
     # via emmet-core
 pycodcif==3.0.1
     # via emmet-core
-pycodestyle==2.15.0
-    # via
-    #   flake8
-    #   mp-api (pyproject.toml)
-pycparser==3.0
+pycparser==3.1
     # via cffi
-pydantic[email]==2.13.5
+pydantic[email]==2.14.0
     # via
     #   emmet-core
     #   fastmcp-slim
     #   mcp
     #   mcp-types
     #   openapi-pydantic
+    #   optimade
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.46.5
+pydantic-core==2.50.0
     # via pydantic
 pydantic-settings==2.15.0
     # via
     #   emmet-core
     #   fastmcp-slim
+    #   optimade
     #   pymatgen-io-validation
-pyflakes==4.0.1
-    # via flake8
 pygments==2.21.0
     # via
     #   ipython
@@ -465,7 +469,7 @@ pymatgen-core==2026.10.2
     #   pymatgen-io-validation
 pymatgen-io-validation==0.1.4
     # via emmet-core
-pymongo==4.18.2
+pymongo==4.18.3
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -508,7 +512,7 @@ python-multipart==0.0.32
     # via
     #   fastmcp-slim
     #   mcp
-pytz==2026.4
+pytz==2026.5
     # via bravado-core
 pyyaml==6.0.3
     # via
@@ -516,6 +520,7 @@ pyyaml==6.0.3
     #   bravado-core
     #   fastmcp-slim
     #   jsonschema-path
+    #   optimade
     #   phonopy
     #   pre-commit
     #   pybtex
@@ -530,6 +535,7 @@ requests==2.34.2
     #   bravado
     #   bravado-core
     #   mp-api (pyproject.toml)
+    #   optimade
     #   pymatgen-core
     #   pymatgen-io-validation
     #   requests-futures
@@ -546,12 +552,13 @@ rich==15.0.0
     # via
     #   cyclopts
     #   fastmcp-slim
+    #   mp-api (pyproject.toml)
     #   rich-rst
 rich-rst==2.2.0
     # via cyclopts
 roman-numerals==4.1.0
     # via sphinx
-rpds-py==2026.6.3
+rpds-py==2026.9.1
     # via
     #   jsonschema
     #   referencing
@@ -575,9 +582,9 @@ secretstorage==3.5.0
     # via keyring
 seekpath==2.2.2
     # via emmet-core
-shapely==2.1.2
+shapely==2.2.0
     # via pymatgen-analysis-alloys
-simplejson==4.1.2
+simplejson==4.2.0
     # via
     #   bravado
     #   bravado-core
@@ -635,6 +642,7 @@ tqdm==4.70.1
 traitlets==5.16.1
     # via
     #   ipython
+    #   ipywidgets
     #   matplotlib-inline
 truststore==0.10.4
     # via
@@ -642,18 +650,15 @@ truststore==0.10.4
     #   httpx2
 typeguard==4.6.0
     # via inflect
-types-requests==2.33.0.20260906
-    # via mp-api (pyproject.toml)
-types-setuptools==84.0.0.20260812
+types-requests==2.33.0.20261006
     # via mp-api (pyproject.toml)
-types-tqdm==4.70.0.20260906
+types-setuptools==84.0.0.20261006
     # via mp-api (pyproject.toml)
 typing-extensions==4.16.0
     # via
     #   anyio
     #   ase
     #   bravado
-    #   emmet-core
     #   exceptiongroup
     #   fastmcp-slim
     #   flexcache
@@ -661,7 +666,6 @@ typing-extensions==4.16.0
     #   httpx2
     #   mcp
     #   mcp-types
-    #   mp-api (pyproject.toml)
     #   mypy
     #   opentelemetry-api
     #   pint
@@ -680,7 +684,7 @@ typing-inspection==0.4.4
     #   mcp
     #   pydantic
     #   pydantic-settings
-tzdata==2026.4
+tzdata==2026.5
     # via arrow
 uncalled-for==0.4.0
     # via fastmcp-slim
@@ -697,17 +701,19 @@ uvicorn==0.54.0
     # via
     #   fastmcp-slim
     #   mcp
-virtualenv==21.14.5
+virtualenv==21.14.6
     # via pre-commit
 watchfiles==1.3.0
     # via fastmcp-slim
-wcwidth==0.9.1
+wcwidth==0.9.2
     # via prompt-toolkit
 webcolors==25.10.0
     # via jsonschema
-websockets==17.1
+websockets==17.2
     # via fastmcp-slim
 werkzeug==3.1.9
     # via flask
+widgetsnbextension==4.0.16
+    # via ipywidgets
 wrapt==2.5.0
     # via deprecated
diff --git a/requirements/requirements-ubuntu-latest_py3.13.txt b/requirements/requirements-ubuntu-latest_py3.13.txt
index 742dd107..89917b68 100644
--- a/requirements/requirements-ubuntu-latest_py3.13.txt
+++ b/requirements/requirements-ubuntu-latest_py3.13.txt
@@ -2,7 +2,7 @@
 # This file is autogenerated by pip-compile with Python 3.13
 # by the following command:
 #
-#    pip-compile --no-index --output-file=requirements/requirements-ubuntu-latest_py3.13.txt pyproject.toml
+#    pip-compile --output-file=requirements/requirements-ubuntu-latest_py3.13.txt pyproject.toml
 #
 annotated-types==0.8.0
     # via pydantic
@@ -10,11 +10,11 @@ arro3-core==0.9.0
     # via deltalake
 bibtexparser==1.4.4
     # via pymatgen-core
-blake3==1.0.10
+blake3==1.0.11
     # via emmet-core
-boto3==1.43.108
+boto3==1.43.110
     # via mp-api (pyproject.toml)
-botocore==1.43.108
+botocore==1.43.110
     # via
     #   boto3
     #   s3transfer
@@ -32,7 +32,7 @@ deltalake==1.5.1
     # via mp-api (pyproject.toml)
 deprecated==3.0.0
     # via deltalake
-emmet-core==0.87.3rc2
+emmet-core==0.88.0rc0
     # via mp-api (pyproject.toml)
 fonttools==4.66.1
     # via matplotlib
@@ -52,8 +52,12 @@ latexcodec==3.0.1
     # via pybtex
 lxml==6.1.3
     # via pymatgen-core
+markdown-it-py==4.2.0
+    # via rich
 matplotlib==3.11.2
     # via pymatgen-core
+mdurl==0.1.2
+    # via markdown-it-py
 monty==2026.7.16
     # via
     #   emmet-core
@@ -77,7 +81,7 @@ numpy==2.5.3
     #   pymatgen-io-validation
     #   scipy
     #   spglib
-orjson==3.12.0
+orjson==3.13.0
     # via
     #   mp-api (pyproject.toml)
     #   pymatgen-core
@@ -99,17 +103,19 @@ pyarrow==25.0.1
     # via mp-api (pyproject.toml)
 pybtex==0.26.1
     # via emmet-core
-pydantic==2.13.5
+pydantic==2.14.0
     # via
     #   emmet-core
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.46.5
+pydantic-core==2.50.0
     # via pydantic
 pydantic-settings==2.15.0
     # via
     #   emmet-core
     #   pymatgen-io-validation
+pygments==2.21.0
+    # via rich
 pymatgen==2026.9.24
     # via
     #   emmet-core
@@ -139,6 +145,8 @@ requests==2.34.2
     #   mp-api (pyproject.toml)
     #   pymatgen-core
     #   pymatgen-io-validation
+rich==15.0.0
+    # via mp-api (pyproject.toml)
 ruamel-yaml==0.19.1
     # via monty
 s3transfer==0.19.2
@@ -159,8 +167,6 @@ typeguard==4.6.0
     # via inflect
 typing-extensions==4.16.0
     # via
-    #   emmet-core
-    #   mp-api (pyproject.toml)
     #   pydantic
     #   pydantic-core
     #   typeguard
diff --git a/requirements/requirements-ubuntu-latest_py3.13_extras.txt b/requirements/requirements-ubuntu-latest_py3.13_extras.txt
index a1c03312..b5445d18 100644
--- a/requirements/requirements-ubuntu-latest_py3.13_extras.txt
+++ b/requirements/requirements-ubuntu-latest_py3.13_extras.txt
@@ -2,7 +2,7 @@
 # This file is autogenerated by pip-compile with Python 3.13
 # by the following command:
 #
-#    pip-compile --all-extras --no-index --output-file=requirements/requirements-ubuntu-latest_py3.13_extras.txt pyproject.toml
+#    pip-compile --all-extras --output-file=requirements/requirements-ubuntu-latest_py3.13_extras.txt pyproject.toml
 #
 aiofile==3.12.3
     # via py-key-value-aio
@@ -24,7 +24,7 @@ arrow==1.4.0
     # via isoduration
 ase==3.29.0
     # via pymatgen-analysis-diffusion
-ast-serialize==0.12.0
+ast-serialize==0.12.1
     # via mypy
 asttokens==3.0.2
     # via stack-data
@@ -42,15 +42,15 @@ beartype==0.22.9
     # via py-key-value-aio
 bibtexparser==1.4.4
     # via pymatgen-core
-blake3==1.0.10
+blake3==1.0.11
     # via emmet-core
 blinker==1.9.0
     # via flask
 boltons==26.2.0
     # via mp-api (pyproject.toml)
-boto3==1.43.108
+boto3==1.43.110
     # via mp-api (pyproject.toml)
-botocore==1.43.108
+botocore==1.43.110
     # via
     #   boto3
     #   s3transfer
@@ -58,7 +58,7 @@ bravado==12.0.1
     # via mp-api (pyproject.toml)
 bravado-core==6.4.1
     # via bravado
-cachetools==7.2.0
+cachetools==7.2.1
     # via
     #   mp-api (pyproject.toml)
     #   py-key-value-aio
@@ -78,6 +78,8 @@ click==8.5.0
     #   uvicorn
 cloudpickle==3.1.2
     # via joblib
+comm==0.2.3
+    # via ipywidgets
 contourpy==1.4.0
     # via matplotlib
 coverage[toml]==7.16.2
@@ -89,10 +91,12 @@ cryptography==50.0.2
     #   pyjwt
     #   secretstorage
 custodian==2025.12.14
-    # via mp-api (pyproject.toml)
+    # via
+    #   emmet-core
+    #   mp-api (pyproject.toml)
 cycler==0.12.1
     # via matplotlib
-cyclopts==5.1.1
+cyclopts==5.2.0
     # via fastmcp-slim
 deltalake==1.5.1
     # via mp-api (pyproject.toml)
@@ -111,7 +115,7 @@ docutils==0.22.4
     # via sphinx
 email-validator==2.3.0
     # via pydantic
-emmet-core[all]==0.87.3rc2
+emmet-core[all]==0.88.0rc0
     # via mp-api (pyproject.toml)
 exceptiongroup==1.3.1
     # via fastmcp-slim
@@ -119,18 +123,16 @@ execnet==2.1.2
     # via pytest-xdist
 executing==2.2.1
     # via stack-data
-fastmcp==4.0.10
+fastmcp==4.1.0
     # via mp-api (pyproject.toml)
-fastmcp-slim[client,server]==4.0.10
+fastmcp-slim[client,server]==4.1.0
     # via fastmcp
-filelock==4.0.9
+filelock==4.0.12
     # via
     #   python-discovery
     #   virtualenv
 filetype==1.2.0
     # via mp-api (pyproject.toml)
-flake8==7.4.1
-    # via mp-api (pyproject.toml)
 flask==3.1.3
     # via mp-api (pyproject.toml)
 flexcache==0.3
@@ -141,7 +143,7 @@ fonttools==4.66.1
     # via matplotlib
 fqdn==1.6.0
     # via jsonschema
-griffelib==2.3.0
+griffelib==2.3.2
     # via fastmcp-slim
 h11==0.16.0
     # via
@@ -164,7 +166,7 @@ idna==3.20
     #   httpx2
     #   jsonschema
     #   requests
-imageio==2.38.0
+imageio==2.38.1
     # via scikit-image
 imagesize==2.0.1
     # via sphinx
@@ -172,12 +174,16 @@ importlib-resources==7.1.0
     # via swagger-spec-validator
 inflect==7.5.0
     # via emmet-core
-iniconfig==2.3.0
+iniconfig==2.3.1
     # via pytest
 ipython==9.17.1
-    # via mp-api (pyproject.toml)
+    # via
+    #   ipywidgets
+    #   mp-api (pyproject.toml)
 ipython-pygments-lexers==1.1.1
     # via ipython
+ipywidgets==8.1.9
+    # via mp-api (pyproject.toml)
 isoduration==20.11.0
     # via jsonschema
 itsdangerous==2.2.0
@@ -212,7 +218,7 @@ joserfc==1.7.5
     #   fastmcp-slim
 json2html==1.3.0
     # via mp-api (pyproject.toml)
-jsonpointer==3.1.1
+jsonpointer==3.2.0
     # via jsonschema
 jsonref==1.1.0
     # via
@@ -227,12 +233,16 @@ jsonschema-path==0.5.0
     # via fastmcp-slim
 jsonschema-specifications==2025.9.1
     # via jsonschema
+jupyterlab-widgets==3.0.17
+    # via ipywidgets
 keyring==25.7.0
     # via py-key-value-aio
 kiwisolver==1.5.1
     # via matplotlib
 lark==1.3.1
-    # via rfc3987-syntax
+    # via
+    #   optimade
+    #   rfc3987-syntax
 latexcodec==3.0.1
     # via pybtex
 lazy-loader==0.6
@@ -245,7 +255,7 @@ lxml==6.1.3
     # via pymatgen-core
 markdown-it-py==4.2.0
     # via rich
-markupsafe==3.0.3
+markupsafe==3.0.4
     # via
     #   flask
     #   jinja2
@@ -258,11 +268,9 @@ matplotlib==3.11.2
     #   seaborn
 matplotlib-inline==0.2.2
     # via ipython
-mccabe==0.7.0
-    # via flake8
-mcp==2.2.0
+mcp==2.3.0
     # via fastmcp-slim
-mcp-types==2.2.0
+mcp-types==2.3.0
     # via
     #   fastmcp-slim
     #   mcp
@@ -292,9 +300,7 @@ msgpack==1.2.3
 mypy==2.4.0
     # via mp-api (pyproject.toml)
 mypy-extensions==1.1.0
-    # via
-    #   mp-api (pyproject.toml)
-    #   mypy
+    # via mypy
 narwhals==2.26.0
     # via plotly
 networkx==3.7
@@ -328,11 +334,13 @@ numpy==2.5.3
     #   tifffile
 openapi-pydantic==0.6.0
     # via fastmcp-slim
-opentelemetry-api==1.45.0
+opentelemetry-api==1.45.1
     # via
     #   fastmcp-slim
     #   mcp
-orjson==3.12.0
+optimade==1.5.0
+    # via emmet-core
+orjson==3.13.0
     # via
     #   mp-api (pyproject.toml)
     #   pymatgen-core
@@ -361,7 +369,7 @@ pathspec==1.1.1
     # via mypy
 pexpect==4.9.0
     # via ipython
-phonopy==4.8.0
+phonopy==4.8.1
     # via emmet-core
 phonors==0.6.0
     # via phonopy
@@ -372,7 +380,7 @@ pillow==12.3.0
     #   scikit-image
 pint==0.26.1
     # via mp-api (pyproject.toml)
-platformdirs==4.12.2
+platformdirs==4.12.4
     # via
     #   fastmcp-slim
     #   pint
@@ -407,30 +415,26 @@ pybtex==0.26.1
     # via emmet-core
 pycodcif==3.0.1
     # via emmet-core
-pycodestyle==2.15.0
-    # via
-    #   flake8
-    #   mp-api (pyproject.toml)
-pycparser==3.0
+pycparser==3.1
     # via cffi
-pydantic[email]==2.13.5
+pydantic[email]==2.14.0
     # via
     #   emmet-core
     #   fastmcp-slim
     #   mcp
     #   mcp-types
     #   openapi-pydantic
+    #   optimade
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.46.5
+pydantic-core==2.50.0
     # via pydantic
 pydantic-settings==2.15.0
     # via
     #   emmet-core
     #   fastmcp-slim
+    #   optimade
     #   pymatgen-io-validation
-pyflakes==4.0.1
-    # via flake8
 pygments==2.21.0
     # via
     #   ipython
@@ -465,7 +469,7 @@ pymatgen-core==2026.10.2
     #   pymatgen-io-validation
 pymatgen-io-validation==0.1.4
     # via emmet-core
-pymongo==4.18.2
+pymongo==4.18.3
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -508,7 +512,7 @@ python-multipart==0.0.32
     # via
     #   fastmcp-slim
     #   mcp
-pytz==2026.4
+pytz==2026.5
     # via bravado-core
 pyyaml==6.0.3
     # via
@@ -516,6 +520,7 @@ pyyaml==6.0.3
     #   bravado-core
     #   fastmcp-slim
     #   jsonschema-path
+    #   optimade
     #   phonopy
     #   pre-commit
     #   pybtex
@@ -530,6 +535,7 @@ requests==2.34.2
     #   bravado
     #   bravado-core
     #   mp-api (pyproject.toml)
+    #   optimade
     #   pymatgen-core
     #   pymatgen-io-validation
     #   requests-futures
@@ -546,12 +552,13 @@ rich==15.0.0
     # via
     #   cyclopts
     #   fastmcp-slim
+    #   mp-api (pyproject.toml)
     #   rich-rst
 rich-rst==2.2.0
     # via cyclopts
 roman-numerals==4.1.0
     # via sphinx
-rpds-py==2026.6.3
+rpds-py==2026.9.1
     # via
     #   jsonschema
     #   referencing
@@ -575,9 +582,9 @@ secretstorage==3.5.0
     # via keyring
 seekpath==2.2.2
     # via emmet-core
-shapely==2.1.2
+shapely==2.2.0
     # via pymatgen-analysis-alloys
-simplejson==4.1.2
+simplejson==4.2.0
     # via
     #   bravado
     #   bravado-core
@@ -635,6 +642,7 @@ tqdm==4.70.1
 traitlets==5.16.1
     # via
     #   ipython
+    #   ipywidgets
     #   matplotlib-inline
 truststore==0.10.4
     # via
@@ -642,24 +650,20 @@ truststore==0.10.4
     #   httpx2
 typeguard==4.6.0
     # via inflect
-types-requests==2.33.0.20260906
-    # via mp-api (pyproject.toml)
-types-setuptools==84.0.0.20260812
+types-requests==2.33.0.20261006
     # via mp-api (pyproject.toml)
-types-tqdm==4.70.0.20260906
+types-setuptools==84.0.0.20261006
     # via mp-api (pyproject.toml)
 typing-extensions==4.16.0
     # via
     #   anyio
     #   ase
     #   bravado
-    #   emmet-core
     #   fastmcp-slim
     #   flexcache
     #   flexparser
     #   mcp
     #   mcp-types
-    #   mp-api (pyproject.toml)
     #   mypy
     #   opentelemetry-api
     #   pint
@@ -674,7 +678,7 @@ typing-inspection==0.4.4
     #   mcp
     #   pydantic
     #   pydantic-settings
-tzdata==2026.4
+tzdata==2026.5
     # via arrow
 uncalled-for==0.4.0
     # via fastmcp-slim
@@ -691,17 +695,19 @@ uvicorn==0.54.0
     # via
     #   fastmcp-slim
     #   mcp
-virtualenv==21.14.5
+virtualenv==21.14.6
     # via pre-commit
 watchfiles==1.3.0
     # via fastmcp-slim
-wcwidth==0.9.1
+wcwidth==0.9.2
     # via prompt-toolkit
 webcolors==25.10.0
     # via jsonschema
-websockets==17.1
+websockets==17.2
     # via fastmcp-slim
 werkzeug==3.1.9
     # via flask
+widgetsnbextension==4.0.16
+    # via ipywidgets
 wrapt==2.5.0
     # via deprecated
diff --git a/requirements/requirements-ubuntu-latest_py3.14.txt b/requirements/requirements-ubuntu-latest_py3.14.txt
index c1e1d61f..67976bb5 100644
--- a/requirements/requirements-ubuntu-latest_py3.14.txt
+++ b/requirements/requirements-ubuntu-latest_py3.14.txt
@@ -2,7 +2,7 @@
 # This file is autogenerated by pip-compile with Python 3.14
 # by the following command:
 #
-#    pip-compile --no-index --output-file=requirements/requirements-ubuntu-latest_py3.14.txt pyproject.toml
+#    pip-compile --output-file=requirements/requirements-ubuntu-latest_py3.14.txt pyproject.toml
 #
 annotated-types==0.8.0
     # via pydantic
@@ -10,11 +10,11 @@ arro3-core==0.9.0
     # via deltalake
 bibtexparser==1.4.4
     # via pymatgen-core
-blake3==1.0.10
+blake3==1.0.11
     # via emmet-core
-boto3==1.43.108
+boto3==1.43.110
     # via mp-api (pyproject.toml)
-botocore==1.43.108
+botocore==1.43.110
     # via
     #   boto3
     #   s3transfer
@@ -32,7 +32,7 @@ deltalake==1.5.1
     # via mp-api (pyproject.toml)
 deprecated==3.0.0
     # via deltalake
-emmet-core==0.87.3rc2
+emmet-core==0.88.0rc0
     # via mp-api (pyproject.toml)
 fonttools==4.66.1
     # via matplotlib
@@ -52,8 +52,12 @@ latexcodec==3.0.1
     # via pybtex
 lxml==6.1.3
     # via pymatgen-core
+markdown-it-py==4.2.0
+    # via rich
 matplotlib==3.11.2
     # via pymatgen-core
+mdurl==0.1.2
+    # via markdown-it-py
 monty==2026.7.16
     # via
     #   emmet-core
@@ -77,7 +81,7 @@ numpy==2.5.3
     #   pymatgen-io-validation
     #   scipy
     #   spglib
-orjson==3.12.0
+orjson==3.13.0
     # via
     #   mp-api (pyproject.toml)
     #   pymatgen-core
@@ -99,17 +103,19 @@ pyarrow==25.0.1
     # via mp-api (pyproject.toml)
 pybtex==0.26.1
     # via emmet-core
-pydantic==2.13.5
+pydantic==2.14.0
     # via
     #   emmet-core
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.46.5
+pydantic-core==2.50.0
     # via pydantic
 pydantic-settings==2.15.0
     # via
     #   emmet-core
     #   pymatgen-io-validation
+pygments==2.21.0
+    # via rich
 pymatgen==2026.9.24
     # via
     #   emmet-core
@@ -139,6 +145,8 @@ requests==2.34.2
     #   mp-api (pyproject.toml)
     #   pymatgen-core
     #   pymatgen-io-validation
+rich==15.0.0
+    # via mp-api (pyproject.toml)
 ruamel-yaml==0.19.1
     # via monty
 s3transfer==0.19.2
@@ -159,8 +167,6 @@ typeguard==4.6.0
     # via inflect
 typing-extensions==4.16.0
     # via
-    #   emmet-core
-    #   mp-api (pyproject.toml)
     #   pydantic
     #   pydantic-core
     #   typeguard
diff --git a/requirements/requirements-ubuntu-latest_py3.14_extras.txt b/requirements/requirements-ubuntu-latest_py3.14_extras.txt
index 5db9a741..a349212f 100644
--- a/requirements/requirements-ubuntu-latest_py3.14_extras.txt
+++ b/requirements/requirements-ubuntu-latest_py3.14_extras.txt
@@ -2,7 +2,7 @@
 # This file is autogenerated by pip-compile with Python 3.14
 # by the following command:
 #
-#    pip-compile --all-extras --no-index --output-file=requirements/requirements-ubuntu-latest_py3.14_extras.txt pyproject.toml
+#    pip-compile --all-extras --output-file=requirements/requirements-ubuntu-latest_py3.14_extras.txt pyproject.toml
 #
 aiofile==3.12.3
     # via py-key-value-aio
@@ -24,7 +24,7 @@ arrow==1.4.0
     # via isoduration
 ase==3.29.0
     # via pymatgen-analysis-diffusion
-ast-serialize==0.12.0
+ast-serialize==0.12.1
     # via mypy
 asttokens==3.0.2
     # via stack-data
@@ -42,15 +42,15 @@ beartype==0.22.9
     # via py-key-value-aio
 bibtexparser==1.4.4
     # via pymatgen-core
-blake3==1.0.10
+blake3==1.0.11
     # via emmet-core
 blinker==1.9.0
     # via flask
 boltons==26.2.0
     # via mp-api (pyproject.toml)
-boto3==1.43.108
+boto3==1.43.110
     # via mp-api (pyproject.toml)
-botocore==1.43.108
+botocore==1.43.110
     # via
     #   boto3
     #   s3transfer
@@ -58,7 +58,7 @@ bravado==12.0.1
     # via mp-api (pyproject.toml)
 bravado-core==6.4.1
     # via bravado
-cachetools==7.2.0
+cachetools==7.2.1
     # via
     #   mp-api (pyproject.toml)
     #   py-key-value-aio
@@ -78,6 +78,8 @@ click==8.5.0
     #   uvicorn
 cloudpickle==3.1.2
     # via joblib
+comm==0.2.3
+    # via ipywidgets
 contourpy==1.4.0
     # via matplotlib
 coverage[toml]==7.16.2
@@ -89,10 +91,12 @@ cryptography==50.0.2
     #   pyjwt
     #   secretstorage
 custodian==2025.12.14
-    # via mp-api (pyproject.toml)
+    # via
+    #   emmet-core
+    #   mp-api (pyproject.toml)
 cycler==0.12.1
     # via matplotlib
-cyclopts==5.1.1
+cyclopts==5.2.0
     # via fastmcp-slim
 deltalake==1.5.1
     # via mp-api (pyproject.toml)
@@ -111,7 +115,7 @@ docutils==0.22.4
     # via sphinx
 email-validator==2.3.0
     # via pydantic
-emmet-core[all]==0.87.3rc2
+emmet-core[all]==0.88.0rc0
     # via mp-api (pyproject.toml)
 exceptiongroup==1.3.1
     # via fastmcp-slim
@@ -119,18 +123,16 @@ execnet==2.1.2
     # via pytest-xdist
 executing==2.2.1
     # via stack-data
-fastmcp==4.0.10
+fastmcp==4.1.0
     # via mp-api (pyproject.toml)
-fastmcp-slim[client,server]==4.0.10
+fastmcp-slim[client,server]==4.1.0
     # via fastmcp
-filelock==4.0.9
+filelock==4.0.12
     # via
     #   python-discovery
     #   virtualenv
 filetype==1.2.0
     # via mp-api (pyproject.toml)
-flake8==7.4.1
-    # via mp-api (pyproject.toml)
 flask==3.1.3
     # via mp-api (pyproject.toml)
 flexcache==0.3
@@ -141,7 +143,7 @@ fonttools==4.66.1
     # via matplotlib
 fqdn==1.6.0
     # via jsonschema
-griffelib==2.3.0
+griffelib==2.3.2
     # via fastmcp-slim
 h11==0.16.0
     # via
@@ -164,7 +166,7 @@ idna==3.20
     #   httpx2
     #   jsonschema
     #   requests
-imageio==2.38.0
+imageio==2.38.1
     # via scikit-image
 imagesize==2.0.1
     # via sphinx
@@ -172,12 +174,16 @@ importlib-resources==7.1.0
     # via swagger-spec-validator
 inflect==7.5.0
     # via emmet-core
-iniconfig==2.3.0
+iniconfig==2.3.1
     # via pytest
 ipython==9.17.1
-    # via mp-api (pyproject.toml)
+    # via
+    #   ipywidgets
+    #   mp-api (pyproject.toml)
 ipython-pygments-lexers==1.1.1
     # via ipython
+ipywidgets==8.1.9
+    # via mp-api (pyproject.toml)
 isoduration==20.11.0
     # via jsonschema
 itsdangerous==2.2.0
@@ -212,7 +218,7 @@ joserfc==1.7.5
     #   fastmcp-slim
 json2html==1.3.0
     # via mp-api (pyproject.toml)
-jsonpointer==3.1.1
+jsonpointer==3.2.0
     # via jsonschema
 jsonref==1.1.0
     # via
@@ -227,12 +233,16 @@ jsonschema-path==0.5.0
     # via fastmcp-slim
 jsonschema-specifications==2025.9.1
     # via jsonschema
+jupyterlab-widgets==3.0.17
+    # via ipywidgets
 keyring==25.7.0
     # via py-key-value-aio
 kiwisolver==1.5.1
     # via matplotlib
 lark==1.3.1
-    # via rfc3987-syntax
+    # via
+    #   optimade
+    #   rfc3987-syntax
 latexcodec==3.0.1
     # via pybtex
 lazy-loader==0.6
@@ -245,7 +255,7 @@ lxml==6.1.3
     # via pymatgen-core
 markdown-it-py==4.2.0
     # via rich
-markupsafe==3.0.3
+markupsafe==3.0.4
     # via
     #   flask
     #   jinja2
@@ -258,11 +268,9 @@ matplotlib==3.11.2
     #   seaborn
 matplotlib-inline==0.2.2
     # via ipython
-mccabe==0.7.0
-    # via flake8
-mcp==2.2.0
+mcp==2.3.0
     # via fastmcp-slim
-mcp-types==2.2.0
+mcp-types==2.3.0
     # via
     #   fastmcp-slim
     #   mcp
@@ -292,9 +300,7 @@ msgpack==1.2.3
 mypy==2.4.0
     # via mp-api (pyproject.toml)
 mypy-extensions==1.1.0
-    # via
-    #   mp-api (pyproject.toml)
-    #   mypy
+    # via mypy
 narwhals==2.26.0
     # via plotly
 networkx==3.7
@@ -328,11 +334,13 @@ numpy==2.5.3
     #   tifffile
 openapi-pydantic==0.6.0
     # via fastmcp-slim
-opentelemetry-api==1.45.0
+opentelemetry-api==1.45.1
     # via
     #   fastmcp-slim
     #   mcp
-orjson==3.12.0
+optimade==1.5.0
+    # via emmet-core
+orjson==3.13.0
     # via
     #   mp-api (pyproject.toml)
     #   pymatgen-core
@@ -361,7 +369,7 @@ pathspec==1.1.1
     # via mypy
 pexpect==4.9.0
     # via ipython
-phonopy==4.8.0
+phonopy==4.8.1
     # via emmet-core
 phonors==0.6.0
     # via phonopy
@@ -372,7 +380,7 @@ pillow==12.3.0
     #   scikit-image
 pint==0.26.1
     # via mp-api (pyproject.toml)
-platformdirs==4.12.2
+platformdirs==4.12.4
     # via
     #   fastmcp-slim
     #   pint
@@ -407,30 +415,26 @@ pybtex==0.26.1
     # via emmet-core
 pycodcif==3.0.1
     # via emmet-core
-pycodestyle==2.15.0
-    # via
-    #   flake8
-    #   mp-api (pyproject.toml)
-pycparser==3.0
+pycparser==3.1
     # via cffi
-pydantic[email]==2.13.5
+pydantic[email]==2.14.0
     # via
     #   emmet-core
     #   fastmcp-slim
     #   mcp
     #   mcp-types
     #   openapi-pydantic
+    #   optimade
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.46.5
+pydantic-core==2.50.0
     # via pydantic
 pydantic-settings==2.15.0
     # via
     #   emmet-core
     #   fastmcp-slim
+    #   optimade
     #   pymatgen-io-validation
-pyflakes==4.0.1
-    # via flake8
 pygments==2.21.0
     # via
     #   ipython
@@ -465,7 +469,7 @@ pymatgen-core==2026.10.2
     #   pymatgen-io-validation
 pymatgen-io-validation==0.1.4
     # via emmet-core
-pymongo==4.18.2
+pymongo==4.18.3
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -508,7 +512,7 @@ python-multipart==0.0.32
     # via
     #   fastmcp-slim
     #   mcp
-pytz==2026.4
+pytz==2026.5
     # via bravado-core
 pyyaml==6.0.3
     # via
@@ -516,6 +520,7 @@ pyyaml==6.0.3
     #   bravado-core
     #   fastmcp-slim
     #   jsonschema-path
+    #   optimade
     #   phonopy
     #   pre-commit
     #   pybtex
@@ -530,6 +535,7 @@ requests==2.34.2
     #   bravado
     #   bravado-core
     #   mp-api (pyproject.toml)
+    #   optimade
     #   pymatgen-core
     #   pymatgen-io-validation
     #   requests-futures
@@ -546,12 +552,13 @@ rich==15.0.0
     # via
     #   cyclopts
     #   fastmcp-slim
+    #   mp-api (pyproject.toml)
     #   rich-rst
 rich-rst==2.2.0
     # via cyclopts
 roman-numerals==4.1.0
     # via sphinx
-rpds-py==2026.6.3
+rpds-py==2026.9.1
     # via
     #   jsonschema
     #   referencing
@@ -575,9 +582,9 @@ secretstorage==3.5.0
     # via keyring
 seekpath==2.2.2
     # via emmet-core
-shapely==2.1.2
+shapely==2.2.0
     # via pymatgen-analysis-alloys
-simplejson==4.1.2
+simplejson==4.2.0
     # via
     #   bravado
     #   bravado-core
@@ -635,6 +642,7 @@ tqdm==4.70.1
 traitlets==5.16.1
     # via
     #   ipython
+    #   ipywidgets
     #   matplotlib-inline
 truststore==0.10.4
     # via
@@ -642,24 +650,20 @@ truststore==0.10.4
     #   httpx2
 typeguard==4.6.0
     # via inflect
-types-requests==2.33.0.20260906
-    # via mp-api (pyproject.toml)
-types-setuptools==84.0.0.20260812
+types-requests==2.33.0.20261006
     # via mp-api (pyproject.toml)
-types-tqdm==4.70.0.20260906
+types-setuptools==84.0.0.20261006
     # via mp-api (pyproject.toml)
 typing-extensions==4.16.0
     # via
     #   anyio
     #   ase
     #   bravado
-    #   emmet-core
     #   fastmcp-slim
     #   flexcache
     #   flexparser
     #   mcp
     #   mcp-types
-    #   mp-api (pyproject.toml)
     #   mypy
     #   opentelemetry-api
     #   pint
@@ -674,7 +678,7 @@ typing-inspection==0.4.4
     #   mcp
     #   pydantic
     #   pydantic-settings
-tzdata==2026.4
+tzdata==2026.5
     # via arrow
 uncalled-for==0.4.0
     # via fastmcp-slim
@@ -691,17 +695,19 @@ uvicorn==0.54.0
     # via
     #   fastmcp-slim
     #   mcp
-virtualenv==21.14.5
+virtualenv==21.14.6
     # via pre-commit
 watchfiles==1.3.0
     # via fastmcp-slim
-wcwidth==0.9.1
+wcwidth==0.9.2
     # via prompt-toolkit
 webcolors==25.10.0
     # via jsonschema
-websockets==17.1
+websockets==17.2
     # via fastmcp-slim
 werkzeug==3.1.9
     # via flask
+widgetsnbextension==4.0.16
+    # via ipywidgets
 wrapt==2.5.0
     # via deprecated

From be6eb94bf2e9a849ae7056982f3787b5050134c3 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:21:22 -0700
Subject: [PATCH 18/30] keep dev x-api-key out of shared session headers

---
 mp_api/client/core/client.py              | 13 +++--
 tests/client/core/test_session_headers.py | 65 +++++++++++++++++++++++
 2 files changed, 73 insertions(+), 5 deletions(-)
 create mode 100644 tests/client/core/test_session_headers.py

diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py
index d7d9eb8a..2000a647 100644
--- a/mp_api/client/core/client.py
+++ b/mp_api/client/core/client.py
@@ -227,16 +227,17 @@ def __init__(
         self.include_user_agent = include_user_agent
         self.use_document_model = use_document_model
 
-        self.headers = headers or get_consumer()
+        # Copy, so neither the caller's dict nor (below) a shared session is mutated
+        self.headers = dict(headers or get_consumer())
+        if is_dev_env() and self.api_key:
+            self.headers["x-api-key"] = self.api_key
+
         self._session = session or _Rester._create_session(
             api_key=self.api_key,
             include_user_agent=self.include_user_agent,
             headers=self.headers,
         )
 
-        if is_dev_env():
-            self._session.headers["x-api-key"] = self.api_key or ""
-
         self.use_document_model = use_document_model
         self.mute_progress_bars = mute_progress_bars
         self.db_version: str = _normalize_db_version(db_version)
@@ -938,7 +939,9 @@ def _query_delta_backed(
         version: str | None = None
         if versioned:
             version = self._resolve_db_version(
-                requested, version_counts, collection  # type: ignore[arg-type]
+                requested,
+                version_counts,
+                collection,  # type: ignore[arg-type]
             )
         elif override:
             logger.warning(
diff --git a/tests/client/core/test_session_headers.py b/tests/client/core/test_session_headers.py
new file mode 100644
index 00000000..aca6fc01
--- /dev/null
+++ b/tests/client/core/test_session_headers.py
@@ -0,0 +1,65 @@
+"""Shared-session header handling, without network access.
+
+Applications (e.g. a web server) pass one `requests.Session` to every rester
+in a process. Per-client credentials and consumer headers must then travel
+with each request, never be written onto that shared session.
+"""
+
+import pytest
+import requests
+
+import mp_api.client.core.client as client_module
+from mp_api.client.core.client import BaseRester
+
+KEY_A = "a" * 32
+KEY_B = "b" * 32
+
+
+@pytest.fixture(autouse=True)
+def _no_heartbeat(monkeypatch):
+    monkeypatch.setattr(
+        BaseRester, "_get_heartbeat_info", staticmethod(lambda endpoint: ("v", []))
+    )
+
+
+@pytest.fixture
+def dev_env(monkeypatch):
+    monkeypatch.setattr(client_module, "is_dev_env", lambda: True)
+
+
+@pytest.fixture
+def prod_env(monkeypatch):
+    monkeypatch.setattr(client_module, "is_dev_env", lambda: False)
+
+
+def test_dev_key_not_written_to_shared_session(dev_env):
+    session = requests.Session()
+    before = dict(session.headers)
+
+    a = BaseRester(api_key=KEY_A, session=session)
+    b = BaseRester(api_key=KEY_B, session=session)
+
+    assert dict(session.headers) == before
+    assert "x-api-key" not in session.headers
+    assert a.headers["x-api-key"] == KEY_A
+    assert b.headers["x-api-key"] == KEY_B
+
+
+def test_dev_key_on_own_session(dev_env):
+    # Without a caller session, the rester's own session still carries the key
+    rester = BaseRester(api_key=KEY_A)
+    assert rester.session.headers["x-api-key"] == KEY_A
+
+
+def test_prod_headers_not_given_dev_key(prod_env):
+    consumer = {"X-Consumer-Id": "abc"}
+    rester = BaseRester(api_key=KEY_A, session=requests.Session(), headers=consumer)
+    assert rester.headers == consumer
+    # the caller's dict isn't mutated
+    assert consumer == {"X-Consumer-Id": "abc"}
+
+
+def test_caller_headers_not_mutated(dev_env):
+    headers = {"X-Consumer-Id": "abc"}
+    BaseRester(api_key=KEY_A, session=requests.Session(), headers=headers)
+    assert headers == {"X-Consumer-Id": "abc"}

From 550cadda637b433073ac9112aae64608a948e5e4 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:21:25 -0700
Subject: [PATCH 19/30] forward per-request headers on POST/PATCH

---
 mp_api/client/core/client.py              |  8 ++++++--
 tests/client/core/test_session_headers.py | 16 ++++++++++++++++
 2 files changed, 22 insertions(+), 2 deletions(-)

diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py
index 2000a647..d8f9cd4c 100644
--- a/mp_api/client/core/client.py
+++ b/mp_api/client/core/client.py
@@ -512,7 +512,9 @@ def _post_resource(
 
         try:
             url = validate_endpoint(self.endpoint, suffix=suburl)
-            response = self.session.post(url, json=payload, verify=True, params=params)
+            response = self.session.post(
+                url, json=payload, verify=True, params=params, headers=self.headers
+            )
 
             if response.status_code == 200:
                 data = load_json(response.text)
@@ -576,7 +578,9 @@ def _patch_resource(
 
         try:
             url = validate_endpoint(self.endpoint, suffix=suburl)
-            response = self.session.patch(url, json=payload, verify=True, params=params)
+            response = self.session.patch(
+                url, json=payload, verify=True, params=params, headers=self.headers
+            )
 
             if response.status_code == 200:
                 data = load_json(response.text)
diff --git a/tests/client/core/test_session_headers.py b/tests/client/core/test_session_headers.py
index aca6fc01..4b995356 100644
--- a/tests/client/core/test_session_headers.py
+++ b/tests/client/core/test_session_headers.py
@@ -5,6 +5,8 @@
 with each request, never be written onto that shared session.
 """
 
+from unittest.mock import MagicMock
+
 import pytest
 import requests
 
@@ -63,3 +65,17 @@ def test_caller_headers_not_mutated(dev_env):
     headers = {"X-Consumer-Id": "abc"}
     BaseRester(api_key=KEY_A, session=requests.Session(), headers=headers)
     assert headers == {"X-Consumer-Id": "abc"}
+
+
+@pytest.mark.parametrize("method", ["post", "patch"])
+def test_write_requests_forward_headers(prod_env, method):
+    session = MagicMock(spec=requests.Session)
+    response = MagicMock(status_code=200, text='{"data": []}')
+    getattr(session, method).return_value = response
+
+    consumer = {"X-Consumer-Id": "abc", "X-Consumer-Custom-Id": KEY_A}
+    rester = BaseRester(api_key=KEY_A, session=session, headers=consumer)
+    getattr(rester, f"_{method}_resource")(body={"x": 1}, use_document_model=False)
+
+    kwargs = getattr(session, method).call_args.kwargs
+    assert kwargs["headers"] == consumer

From d95b552598a6c6a38e27778cffa10b7bb39ec6e2 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:23:05 -0700
Subject: [PATCH 20/30] don't reconfigure adapters on a caller-supplied session

---
 mp_api/client/contribs/client.py           | 14 +++++++++--
 tests/client/contribs/test_side_effects.py | 29 ++++++++++++++++++++++
 2 files changed, 41 insertions(+), 2 deletions(-)

diff --git a/mp_api/client/contribs/client.py b/mp_api/client/contribs/client.py
index 55369f82..27246d57 100644
--- a/mp_api/client/contribs/client.py
+++ b/mp_api/client/contribs/client.py
@@ -169,10 +169,20 @@ def get_session(session: requests.Session | None = None) -> FuturesSession:
 
     Args:
         session (requests.Session or None) : Optional Session to use
-            in starting a FuturesSession
+            in starting a FuturesSession. Its adapters (retry policy,
+            pool size) are left as the caller configured them.
+
     Returns:
         FuturesSession
     """
+    if session is not None:
+        # FuturesSession applies `adapter_kwargs` to the adapters already
+        # mounted on a supplied session, in place. A caller's session may be
+        # shared with other clients (e.g. MPRester's, or one per web-server
+        # worker), so don't impose this client's retry policy (which retries
+        # POSTs) on it.
+        return FuturesSession(session=session, max_workers=MPCC_SETTINGS.MAX_WORKERS)
+
     adapter_kwargs = dict(
         max_retries=Retry(
             total=MPCC_SETTINGS.RETRIES,
@@ -185,7 +195,7 @@ def get_session(session: requests.Session | None = None) -> FuturesSession:
         )
     )
     return FuturesSession(
-        session=session if session else requests.Session(),
+        session=requests.Session(),
         max_workers=MPCC_SETTINGS.MAX_WORKERS,
         adapter_kwargs=adapter_kwargs,
     )
diff --git a/tests/client/contribs/test_side_effects.py b/tests/client/contribs/test_side_effects.py
index 800e1116..4b2cca65 100644
--- a/tests/client/contribs/test_side_effects.py
+++ b/tests/client/contribs/test_side_effects.py
@@ -161,3 +161,32 @@ def __init__(self, **kwargs):
         mpr.contribs
     assert isinstance(mpr.contribs, Flaky)  # retried, not cached as None
     assert mpr.contribs is mpr.contribs and len(calls) == 2
+
+
+def test_supplied_session_adapters_untouched():
+    """A caller's (possibly shared) session keeps its own retry policy."""
+    import requests
+    from requests.adapters import HTTPAdapter
+    from urllib3.util.retry import Retry
+
+    from mp_api.client.contribs.client import get_session
+
+    session = requests.Session()
+    retry = Retry(total=1, status_forcelist=[503], allowed_methods={"GET"})
+    session.mount("https://", HTTPAdapter(max_retries=retry))
+    before = session.get_adapter("https://").max_retries
+
+    futures_session = get_session(session=session)
+
+    assert futures_session.session is session
+    after = session.get_adapter("https://").max_retries
+    assert after is before
+    assert "POST" not in (after.allowed_methods or ())
+
+
+def test_own_session_gets_contribs_retries():
+    from mp_api.client.contribs.client import get_session
+
+    futures_session = get_session()
+    retry = futures_session.session.get_adapter("https://").max_retries
+    assert "POST" in retry.allowed_methods

From 33cc5324fbb412634e9ad40628174758b2589ebb Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Fri, 9 Oct 2026 13:35:14 -0700
Subject: [PATCH 21/30] mypy

---
 mp_api/client/core/client.py | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py
index d8f9cd4c..e1e14d99 100644
--- a/mp_api/client/core/client.py
+++ b/mp_api/client/core/client.py
@@ -941,12 +941,8 @@ def _query_delta_backed(
         versioned = version_counts is not None
 
         version: str | None = None
-        if versioned:
-            version = self._resolve_db_version(
-                requested,
-                version_counts,
-                collection,  # type: ignore[arg-type]
-            )
+        if version_counts is not None:
+            version = self._resolve_db_version(requested, version_counts, collection)
         elif override:
             logger.warning(
                 f"The {collection} dataset has a single version, ignoring "

From b7766329a30391a109c2208a1a78c90bb1b03e0f Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Fri, 9 Oct 2026 13:36:19 -0700
Subject: [PATCH 22/30] missed updates to nomad test after updates to client
 communication model

---
 tests/client/test_mprester.py | 19 +++++++++++--------
 1 file changed, 11 insertions(+), 8 deletions(-)

diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py
index 7720f55f..255797e5 100644
--- a/tests/client/test_mprester.py
+++ b/tests/client/test_mprester.py
@@ -819,16 +819,12 @@ def test_oxygen_evolution_bad_input(self, mpr):
         reason="upstream known to timeout",
         strict=False,
     )
-    def test_nomad_integration(self, mpr):
+    def test_nomad_integration(self, mpr, caplog):
         # No particular reason for this MPID other than that it exists in NOMAD.
         target_mpid = "mp-10018"
-        with (
-            pytest.warns(
-                MPRestWarning, match="Full downloads of raw data are being transitioned"
-            ),
-            pytest.warns(
-                MPRestWarning, match="the following ids are not found on NOMAD"
-            ),
+        caplog.set_level(logging.WARNING, logger="mp_api.client")
+        with pytest.warns(
+            MPRestWarning, match="Full downloads of raw data are being transitioned"
         ):
             calc_type_map, nomad_urls = mpr.get_download_info(
                 target_mpid,
@@ -864,6 +860,13 @@ def test_nomad_integration(self, mpr):
                 for url in nomad_urls
             )
 
+        # tasks missing from NOMAD are a data event: logged, not warned
+        assert any(
+            "the following ids are not found on NOMAD" in r.getMessage()
+            and r.levelno == logging.WARNING
+            for r in caplog.records
+        )
+
     def test_db_warning(self, monkeypatch: pytest.MonkeyPatch):
         from pathlib import Path
 

From 27b191ae9a986eb37bb91beaef044f6247df0e87 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:06:59 -0700
Subject: [PATCH 23/30] wrap comms from external libraries to fit client
 communication model

makes `quiet` actually capable of being quiet
---
 mp_api/client/contribs/_logger.py             |   5 +-
 mp_api/client/contribs/client.py              |  10 +-
 mp_api/client/core/_display.py                | 206 +++++-
 mp_api/client/core/_entries.py                |  21 +-
 mp_api/client/core/_external.py               | 347 +++++++++
 mp_api/client/core/client.py                  |  36 +-
 mp_api/client/core/settings.py                |  11 +-
 mp_api/client/core/utils.py                   |  37 +-
 mp_api/client/mprester.py                     |  87 ++-
 .../routes/materials/electronic_structure.py  |  11 +-
 mp_api/client/routes/materials/eos.py         |   5 +-
 mp_api/client/routes/materials/materials.py   |   2 +-
 mp_api/client/routes/materials/xas.py         |   5 +-
 mp_api/mcp/_schemas.py                        |   2 +-
 tests/client/core/test_external.py            | 673 ++++++++++++++++++
 tests/client/test_entry_progress.py           |   3 +-
 16 files changed, 1351 insertions(+), 110 deletions(-)
 create mode 100644 mp_api/client/core/_external.py
 create mode 100644 tests/client/core/test_external.py

diff --git a/mp_api/client/contribs/_logger.py b/mp_api/client/contribs/_logger.py
index a71c9876..16c537d8 100644
--- a/mp_api/client/contribs/_logger.py
+++ b/mp_api/client/contribs/_logger.py
@@ -9,9 +9,9 @@
 
 import logging
 import os
-import warnings
 
 from mp_api.client.contribs.settings import MPCC_SETTINGS
+from mp_api.client.core._display import mp_deprecation
 
 
 class CustomLoggerAdapter(logging.LoggerAdapter):
@@ -39,10 +39,9 @@ def get_logger(name: str = "mp_api.client.contribs") -> CustomLoggerAdapter:
 
 
 if MPCC_SETTINGS.CLIENT_LOG_LEVEL is not None:
-    warnings.warn(
+    mp_deprecation(
         "MPCONTRIBS_CLIENT_LOG_LEVEL is deprecated and ignored, use MPRESTER_LOG_LEVEL "
         "(which sets the level for the whole client) instead.",
-        FutureWarning,
         stacklevel=2,
     )
 
diff --git a/mp_api/client/contribs/client.py b/mp_api/client/contribs/client.py
index 27246d57..21fba1cc 100644
--- a/mp_api/client/contribs/client.py
+++ b/mp_api/client/contribs/client.py
@@ -8,7 +8,6 @@
 import itertools
 import sys
 import time
-import warnings
 from base64 import urlsafe_b64encode
 from collections import defaultdict
 from concurrent.futures import as_completed
@@ -67,7 +66,8 @@
     get_md5,
     unflatten_dict,
 )
-from mp_api.client.core._display import progress_bar
+from mp_api.client.core._display import mp_deprecation, progress_bar
+from mp_api.client.core._external import track_calls
 from mp_api.client.core.exceptions import MPContribsClientError
 from mp_api.client.core.schemas import _convert_to_model
 from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS
@@ -444,6 +444,7 @@ def _version(url):
             time.sleep(30)
 
 
+@track_calls
 class ContribsClient(SwaggerClient):
     """client to connect to MPContribs API.
 
@@ -492,7 +493,7 @@ def __init__(
                 )
             else:
                 api_key = kwargs.pop("apikey")
-            warnings.warn(api_key_warn, FutureWarning, stacklevel=2)
+            mp_deprecation(api_key_warn, stacklevel=2)
 
         if api_key and len(api_key) != 32:
             raise MPContribsClientError(f"Invalid API key: {api_key}")
@@ -542,10 +543,9 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
     @property
     def apikey(self) -> str | None:
         """Handle deprecated `apikey` attr."""
-        warnings.warn(
+        mp_deprecation(
             "`apikey` has been deprecated in favor of `api_key` for "
             "consistency with the Materials Project API client.",
-            FutureWarning,
             stacklevel=2,
         )
         return self.api_key
diff --git a/mp_api/client/core/_display.py b/mp_api/client/core/_display.py
index b5d6fc50..af23f321 100644
--- a/mp_api/client/core/_display.py
+++ b/mp_api/client/core/_display.py
@@ -29,20 +29,62 @@
 including contribs) never set their own level, so one setting controls all of
 them. Use `MPRESTER_LOG_LEVEL` or `enable_logging(level)`.
 
+Messages are shown in the console's default style; only the level is
+coloured.
+
+The client's warnings
+---------------------
+The client's own warnings (`MPRestWarning`, its `FutureWarning` deprecations,
+the `MPDataset*Warning`s) are ordinary Python warnings, so filters
+(`warnings.filterwarnings`, `pytest.warns`, `logging.captureWarnings`) work
+as usual. In a terminal / notebook they are shown in the same layout as log
+records, with the warning class as the label and the caller's code below:
+
+```
+MPRestWarning  
+               my_script.py:12
+```
+
+Elsewhere, or if the application installed its own warnings hook (e.g.
+`logging.captureWarnings(True)`), Python's usual format / the application's
+hook is used.
+
+Other libraries
+---------------
+Warnings from other libraries (pymatgen, emmet, ...) raised while a client
+method runs, and their log records that nothing else would handle, are shown
+as WARNING records on `mp_api.client.external.`, naming the library
+code they came from. In a terminal / notebook:
+
+```
+WARNING  mp_api.client.external.pymatgen  
+         pymatgen/analysis/compatibility/mixing_scheme.py:320
+```
+
+and on one line otherwise. Library deprecation warnings are left alone.
+Silence one library with e.g.
+`logging.getLogger("mp_api.client.external.pymatgen").setLevel(logging.ERROR)`.
+See `mp_api.client.core._external`.
+
 Quiet mode
 ----------
 `quiet()` (or `MPRESTER_QUIET=true`, or `MPRester(quiet=True)`) silences the
-client for the whole process: no log records, progress bars, status lines,
-notices or `MPRestWarning`s. Deprecation warnings (`FutureWarning`) and
-exceptions are still raised. It can be used as a context manager.
+client for the whole process: no log records (including those from other
+libraries, above), progress bars, status lines, notices, `MPRestWarning`s or
+`MPDataset*Warning`s.
+Exceptions are still raised, and the client's deprecation warnings
+(`FutureWarning`) are still shown unless `quiet(deprecations=True)` or
+`MPRESTER_QUIET_DEPRECATIONS=true`. It can be used as a context manager; see
+`quiet()` for exactly what is and isn't silenced.
 
 Which channel to use
 --------------------
 - The operation failed: raise an exception.
-- A deprecated API was used: `warnings.warn(..., FutureWarning)`, which is
-  shown by default, also when called from other libraries.
+- A deprecated API was used: `mp_deprecation(...)` (a `FutureWarning`), which
+  is shown by default, also when called from other libraries.
 - Something the caller can change in their call (an ignored argument, misuse,
-  a caveat about what the data means): `mp_warning(...)` (an `MPRestWarning`).
+  a caveat about what the data means): `mp_warning(...)` (an `MPRestWarning`),
+  or `mp_advice(..., category)` for a more specific warning class.
 - A runtime or data event the caller can't change in code (server
   unreachable, data missing, a local dataset reused): `logger.warning/info`.
 - Diagnostics: `logger.debug`.
@@ -81,11 +123,11 @@
 import logging
 import threading
 import time
-import warnings
 from contextlib import contextmanager
 from typing import TYPE_CHECKING, Self
 
 from rich.console import Console
+from rich.highlighter import NullHighlighter
 from rich.logging import RichHandler
 from rich.progress import (
     BarColumn,
@@ -137,6 +179,8 @@
         "mp.error": "#F14668",  # Bulma danger
         "mp.muted": "#7A7A7A",  # Bulma .has-text-grey
         "mp.title": "bold underline",
+        "mp.logger.external": "#939FC8",  # mp.secondary: logger of a library record
+        "mp.warning.label": "#D69E00",  # class name of a client warning
         "mp.field": "bold",
         "mp.table.header": "bold #008F83",  # brand dark
         # database versions, relative to the one the API serves
@@ -200,6 +244,35 @@ def _can_draw_live(console: Console) -> bool:
     return console.is_terminal and not console.is_dumb_terminal
 
 
+def print_warning(label: str, message: str, location: str) -> bool:
+    """Show a client warning on the console, if it's interactive.
+
+    Rendered like the log records: the label (the warning's class name) in a
+    column of its own, the message in the default style, and where the
+    warning points to (the caller's code) on a muted line below it.
+
+    Args:
+        label (str) : e.g. "MPRestWarning"
+        message (str) : warning text
+        location (str) : "file:line"
+
+    Returns:
+        bool : False if nothing was shown (not an interactive console)
+    """
+    console = get_console()
+    if not _is_interactive(console):
+        return False
+    grid = Table.grid(padding=(0, 1), expand=True)
+    grid.add_column(style="mp.warning.label", no_wrap=True)
+    grid.add_column(ratio=1, overflow="fold")
+    grid.add_row(
+        Text(label),
+        Text.assemble(message.strip(), "\n", (location, "mp.muted")),
+    )
+    console.print(grid)
+    return True
+
+
 def print_notice(message: str, style: str | None = None) -> None:
     """Print a one-line notice to the console (stderr).
 
@@ -675,6 +748,25 @@ def _app_level(logger: logging.Logger) -> int:
     return logging.WARNING  # pragma: no cover - root always has a level
 
 
+class _MPRichHandler(RichHandler):
+    """`RichHandler` showing messages in the console's default style.
+
+    Only the level (and, for records re-emitted from other libraries, the
+    logger name and origin) is styled.
+    """
+
+    def render_message(self, record: logging.LogRecord, message: str) -> Text:
+        if (origin := getattr(record, "mp_origin", None)) is None:
+            return Text(message)
+        return Text.assemble(
+            (record.name, "mp.logger.external"),
+            "  ",
+            str(getattr(record, "mp_message", message)),
+            "\n",
+            (origin, "mp.muted"),
+        )
+
+
 class _MPDefaultHandler(logging.Handler):
     """Default handler for `mp_api.client`, see module docstring."""
 
@@ -686,12 +778,14 @@ def __init__(self, logger: logging.Logger) -> None:
 
     def _rich_handler(self, console: Console) -> RichHandler:
         if self._rich is None or self._rich.console is not console:
-            self._rich = RichHandler(
+            self._rich = _MPRichHandler(
                 console=console,
                 show_time=False,
                 show_path=False,
                 markup=False,
                 rich_tracebacks=False,
+                highlighter=NullHighlighter(),
+                keywords=[],
             )
         return self._rich
 
@@ -768,10 +862,11 @@ def enable_logging(level: int | str | None = None) -> None:
 
 
 class _QuietState:
-    """Process-wide quiet flag. `saved_level` is the log level to restore."""
+    """Process-wide quiet flags. `saved_level` is the log level to restore."""
 
     def __init__(self) -> None:
         self.enabled = False
+        self.deprecations = False
         self.saved_level: int = logging.NOTSET
 
 
@@ -781,36 +876,58 @@ def __init__(self) -> None:
 class _QuietContext:
     """Returned by `quiet()`; restores the previous state when used with `with`."""
 
-    def __init__(self, previous: bool) -> None:
+    def __init__(self, previous: tuple[bool, bool]) -> None:
         self._previous = previous
 
     def __enter__(self) -> Self:
         return self
 
     def __exit__(self, *exc_info: object) -> None:
-        _set_quiet(self._previous)
+        _set_quiet(*self._previous)
 
 
-def _set_quiet(enabled: bool) -> bool:
-    """Turn quiet mode on or off, returning the previous state."""
+def _set_quiet(enabled: bool, deprecations: bool = False) -> tuple[bool, bool]:
+    """Set quiet mode, returning the previous `(enabled, deprecations)`."""
     logger = logging.getLogger(LOGGER_NAME)
     with _handler_lock:
-        previous = _QUIET.enabled
-        if enabled and not previous:
+        previous = (_QUIET.enabled, _QUIET.deprecations)
+        if enabled and not _QUIET.enabled:
             _QUIET.saved_level = logger.level
             logger.setLevel(_SILENT)
-        elif previous and not enabled:
+        elif _QUIET.enabled and not enabled:
             logger.setLevel(_QUIET.saved_level)
         _QUIET.enabled = enabled
+        _QUIET.deprecations = enabled and deprecations
     return previous
 
 
-def quiet(enabled: bool = True) -> _QuietContext:
-    """Silence all output from the client in this process.
+def quiet(enabled: bool = True, *, deprecations: bool = False) -> _QuietContext:
+    """Silence output from the client in this process.
+
+    Silenced while quiet:
+
+    - the client's log records (they don't reach the application's handlers
+      either), progress bars, status lines and notices
+    - `MPRestWarning`s and the `MPDataset*Warning`s (slow indexing, slicing
+      or iteration of downloaded datasets)
+    - warnings and fallback log output from other libraries (e.g. pymatgen)
+      raised while a client method runs; these are otherwise shown as
+      WARNING records on `mp_api.client.external.`
+    - with `deprecations=True`, also the client's deprecation warnings
+      (`FutureWarning`)
 
-    While quiet, the client emits no log records (they don't reach the
-    application's handlers either), progress bars, status lines, notices or
-    `MPRestWarning`s. Deprecation warnings and exceptions are unaffected.
+    Not silenced:
+
+    - exceptions
+    - deprecation warnings from other libraries
+    - output libraries print directly (`print()`, their own progress bars)
+      or write from compiled code
+    - log records sent to handlers that a library or the application set up
+    - library warnings raised outside client methods, e.g. on import, or when
+      using returned objects later
+    - warnings, if the application replaced `warnings.showwarning` itself
+      (e.g. `logging.captureWarnings(True)` after importing the client, or
+      `warnings.catch_warnings(record=True)`): they go where it sent them
 
     Takes effect immediately; as a context manager, the previous state is
     restored on exit:
@@ -821,15 +938,19 @@ def quiet(enabled: bool = True) -> _QuietContext:
     quiet()  # for the rest of the process
     with quiet():  # only within the block
         ...
+    with quiet(deprecations=True):  # also hide the client's deprecation warnings
+        ...
     ```
 
     Args:
         enabled (bool) : True to silence the client, False to restore output.
+        deprecations (bool) : also silence the client's deprecation warnings.
+            Only applies when `enabled` is True.
 
     Returns:
         a context manager that restores the previous state on exit
     """
-    return _QuietContext(_set_quiet(enabled))
+    return _QuietContext(_set_quiet(enabled, deprecations))
 
 
 def is_quiet() -> bool:
@@ -850,13 +971,50 @@ def mp_warning(message: str, stacklevel: int = 1) -> None:
         return
     from mp_api.client.core.exceptions import MPRestWarning
 
-    warnings.warn(message, MPRestWarning, stacklevel=stacklevel + 1)
+    _warn(message, MPRestWarning, stacklevel + 1)
+
+
+def mp_advice(message: str, category: type[Warning], stacklevel: int = 1) -> None:
+    """Like `mp_warning`, with another warning class, e.g. `MPDatasetIndexingWarning`.
+
+    Args:
+        message (str) : warning text
+        category (type[Warning]) : warning class
+        stacklevel (int) : as for `warnings.warn`, relative to the caller
+    """
+    if _QUIET.enabled:
+        return
+    _warn(message, category, stacklevel + 1)
+
+
+def mp_deprecation(message: str, stacklevel: int = 1) -> None:
+    """Emit a deprecation warning (`FutureWarning`) for the client's own API.
+
+    Shown by default; silenced by `quiet(deprecations=True)` or
+    `MPRESTER_QUIET_DEPRECATIONS`.
+
+    Args:
+        message (str) : warning text
+        stacklevel (int) : as for `warnings.warn`, relative to the caller
+    """
+    if _QUIET.deprecations:
+        return
+    _warn(message, FutureWarning, stacklevel + 1)
+
+
+def _warn(message: str, category: type[Warning], stacklevel: int) -> None:
+    """Warn as if from `_warn`'s caller, not counting public method wrappers."""
+    from mp_api.client.core._external import warn
+
+    warn(message, category, stacklevel + 1)
 
 
 def _configure_from_settings() -> None:
     """Configure client output from settings, on import of `mp_api.client`."""
+    from mp_api.client.core._external import install
     from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS
 
+    install()
     enable_logging()
     if MAPI_CLIENT_SETTINGS.QUIET:
-        quiet(True)
+        quiet(True, deprecations=MAPI_CLIENT_SETTINGS.QUIET_DEPRECATIONS)
diff --git a/mp_api/client/core/_entries.py b/mp_api/client/core/_entries.py
index 29f1be93..0e36e4d0 100644
--- a/mp_api/client/core/_entries.py
+++ b/mp_api/client/core/_entries.py
@@ -33,11 +33,27 @@
     from collections.abc import Callable, Iterator, Sequence
     from typing import Any
 
+    from pymatgen.analysis.compatibility.mixing_scheme import (
+        MaterialsProjectDFTMixingScheme,
+    )
     from pymatgen.entries.compatibility import Compatibility
 
     from mp_api.client.core._display import ProgressHandle
 
 
+def mixing_scheme_class() -> type[MaterialsProjectDFTMixingScheme]:
+    """The `MaterialsProjectDFTMixingScheme` class, wherever this pymatgen keeps it.
+
+    Newer pymatgen moved it to `pymatgen.analysis.compatibility.mixing_scheme`
+    and deprecated the old module.
+    """
+    try:
+        from pymatgen.analysis.compatibility import mixing_scheme
+    except ImportError:  # pragma: no cover - older pymatgen
+        from pymatgen.entries import mixing_scheme  # type: ignore[no-redef]
+    return mixing_scheme.MaterialsProjectDFTMixingScheme
+
+
 def _advance(handle: ProgressHandle, amount: float) -> None:
     """Advance `handle` by `amount`, without going past its total."""
     if handle.total is not None:
@@ -173,7 +189,8 @@ def apply_corrections(
         enabled (bool) : whether to show progress
     """
     from pymatgen.entries.compatibility import MaterialsProjectAqueousCompatibility
-    from pymatgen.entries.mixing_scheme import MaterialsProjectDFTMixingScheme
+
+    mixing_scheme = mixing_scheme_class()
 
     n = len(entries)
     corrector = _private_copy(corrector)
@@ -183,7 +200,7 @@ def apply_corrections(
         ) as handle,
         ExitStack() as stack,
     ):
-        if isinstance(corrector, MaterialsProjectDFTMixingScheme):
+        if isinstance(corrector, mixing_scheme):
             # passes: compat_1 corrections (run type 1 entries), structure
             # matching (all entries), then the mixing adjustments (all entries)
             n_1 = sum(
diff --git a/mp_api/client/core/_external.py b/mp_api/client/core/_external.py
new file mode 100644
index 00000000..f06db015
--- /dev/null
+++ b/mp_api/client/core/_external.py
@@ -0,0 +1,347 @@
+"""Route output from other libraries through the client's logging, during client calls.
+
+Libraries the client uses (pymatgen, emmet, pydantic, numpy, ...) report
+problems with `warnings.warn`, or with `logging` on their own loggers. Left
+alone, these bypass the client's output model: `quiet()` can't silence them,
+and a library warning raised with `stacklevel=2` is reported at the client's
+line that called the library, not at the library code that warned.
+
+While a client call is running (`capturing()`, entered automatically by every
+public method of the resters, `MPDataset` and `ContribsClient`):
+
+- Library warnings become WARNING log records on
+  `mp_api.client.external.` (e.g. `mp_api.client.external.pymatgen`),
+  one per warning, naming the library code that warned. Being children of
+  `mp_api.client`, they follow `quiet()`, `MPRESTER_LOG_LEVEL` and the
+  application's logging config, and can be tuned per library, e.g.
+  `logging.getLogger("mp_api.client.external.pymatgen").setLevel(logging.ERROR)`.
+- Library log records that would otherwise reach Python's fallback handler
+  (`logging.lastResort`, used only when nothing has configured logging) are
+  re-logged on the same loggers.
+
+Not routed (passed through unchanged): deprecation warnings from libraries
+(`DeprecationWarning`, `PendingDeprecationWarning`, `FutureWarning`), which
+are about the client's use of a library.
+
+The client's own warnings (anything raised from `mp_api` code, at any time)
+stay Python warnings. When they would be printed by Python's default printer
+to an interactive console, the same hook renders them in the client's style
+instead (`_display.print_warning`).
+
+Python's warning filters still apply first, so `"ignore"`, `"error"` and the
+default once-per-location rule keep working. An application that replaces
+`warnings.showwarning` itself (e.g. `logging.captureWarnings(True)` after
+importing the client, or `warnings.catch_warnings(record=True)` as used by
+`pytest.warns`) gets every warning, and the client doesn't take the hook back.
+"""
+
+from __future__ import annotations
+
+import contextvars
+import functools
+import inspect
+import logging
+import sys
+import threading
+import warnings
+from contextlib import contextmanager
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from collections.abc import Callable, Iterator
+    from types import FrameType
+    from typing import Any, TextIO, TypeVar
+
+    T = TypeVar("T")
+
+EXTERNAL_LOGGER = "mp_api.client.external"
+
+_THIS_FILE = __file__
+
+# Methods wrapped besides public ones (no leading underscore)
+_WRAPPED_DUNDERS = frozenset({"__init__", "__getitem__"})
+
+_PASS_THROUGH_CATEGORIES: tuple[type[Warning], ...] = (
+    DeprecationWarning,
+    PendingDeprecationWarning,
+    FutureWarning,
+)
+
+_WARNINGS_MODULES = frozenset({"warnings", "_py_warnings", __name__})
+
+_depth: contextvars.ContextVar[int] = contextvars.ContextVar(
+    "mp_api_client_call_depth", default=0
+)
+
+
+# --------------------------------------------------------------------------
+# Marking client calls
+# --------------------------------------------------------------------------
+
+
+@contextmanager
+def capturing() -> Iterator[None]:
+    """Mark a client call: library output in this context is routed. Re-entrant."""
+    token = _depth.set(_depth.get() + 1)
+    try:
+        yield
+    finally:
+        _depth.reset(token)
+
+
+def is_capturing() -> bool:
+    """Whether the current thread / context is inside a client call."""
+    return _depth.get() > 0
+
+
+def in_current_context(fn: Callable[..., T]) -> Callable[..., T]:
+    """Wrap `fn` to run in a copy of the caller's context, e.g. in worker threads.
+
+    Each call gets its own copy, so the wrapper can run in several threads at once.
+    """
+    context = contextvars.copy_context()
+
+    @functools.wraps(fn)
+    def run(*args: Any, **kwargs: Any) -> T:
+        return context.copy().run(fn, *args, **kwargs)
+
+    return run
+
+
+def warn(message: str, category: type[Warning], stacklevel: int = 1) -> None:
+    """`warnings.warn` for client code, not counting the `capturing()` wrappers.
+
+    `stacklevel` is as for `warnings.warn` called by the caller of `warn`: 2
+    reports the warning at that caller's caller (the user's code), even when
+    a public method wrapper sits in between. (`warnings.warn`'s own
+    `skip_file_prefixes` doesn't skip these frames reliably across Python
+    versions, hence the explicit walk.)
+    """
+    stack: list[FrameType] = []  # stack[0] is this function's frame
+    frame: FrameType | None = sys._getframe(0)
+    while frame is not None:
+        stack.append(frame)
+        frame = frame.f_back
+
+    index = 1  # the caller of `warn`
+    for _ in range(stacklevel - 1):
+        index += 1
+        while index < len(stack) and stack[index].f_code.co_filename == _THIS_FILE:
+            index += 1
+    # `warnings.warn(stacklevel=n)` reports at stack[n - 1], counted from here
+    warnings.warn(message, category, stacklevel=min(index, len(stack) - 1) + 1)
+
+
+def user_stacklevel() -> int:
+    """`stacklevel` reaching the first caller outside `mp_api`, for `mp_warning` etc.
+
+    Relative to the function calling `user_stacklevel` (1 is that function),
+    not counting the public method wrappers, as `warn` counts.
+    """
+    level = 1
+    frame = sys._getframe(1)
+    while frame is not None:
+        if frame.f_code.co_filename != _THIS_FILE:
+            if frame.f_globals.get("__name__", "").split(".")[0] != "mp_api":
+                return level
+            level += 1
+        frame = frame.f_back  # type: ignore[assignment]
+    return 1
+
+
+def _tracked(fn: Callable[..., Any]) -> Callable[..., Any]:
+    if getattr(fn, "__mp_tracked__", False):
+        return fn
+
+    @functools.wraps(fn)
+    def wrapper(*args: Any, **kwargs: Any) -> Any:
+        with capturing():
+            return fn(*args, **kwargs)
+
+    wrapper.__mp_tracked__ = True  # type: ignore[attr-defined]
+    return wrapper
+
+
+def track_calls(cls: type[T]) -> type[T]:
+    """Make the public methods `cls` defines run inside `capturing()`.
+
+    Covers plain functions, static and class methods (not properties or other
+    callable attributes), plus `__init__` and `__getitem__`. Inherited methods are wrapped where they're
+    defined. Safe to apply more than once. Usable as a class decorator.
+    """
+    for name, attr in list(vars(cls).items()):
+        if name.startswith("_") and name not in _WRAPPED_DUNDERS:
+            continue
+        if isinstance(attr, staticmethod | classmethod):
+            if inspect.isfunction(fn := attr.__func__):
+                wrapped = _tracked(fn)
+                if wrapped is not fn:
+                    setattr(cls, name, type(attr)(wrapped))
+        elif inspect.isfunction(attr):
+            wrapped = _tracked(attr)
+            if wrapped is not attr:
+                setattr(cls, name, wrapped)
+    return cls
+
+
+# --------------------------------------------------------------------------
+# Re-emitting
+# --------------------------------------------------------------------------
+
+
+def _package_path(filename: str, package: str) -> str:
+    """`filename` from the package directory on, e.g. "pymatgen/core/structure.py"."""
+    parts = Path(filename).parts
+    for index in range(len(parts) - 1, -1, -1):
+        if parts[index] == package:
+            return "/".join(parts[index:])
+    return Path(filename).name
+
+
+def _emit(
+    package: str,
+    level: int,
+    message: str,
+    pathname: str,
+    lineno: int,
+    func: str | None,
+) -> None:
+    logger = logging.getLogger(f"{EXTERNAL_LOGGER}.{package}")
+    if not logger.isEnabledFor(level):
+        return
+    origin = f"{_package_path(pathname, package)}:{lineno}"
+    record = logger.makeRecord(
+        logger.name,
+        level,
+        pathname,
+        lineno,
+        f"{message} ({origin})",
+        (),
+        None,
+        func=func,
+        extra={"mp_message": message, "mp_origin": origin, "mp_package": package},
+    )
+    logger.handle(record)
+
+
+def _location(filename: str, lineno: int) -> str:
+    """`filename:lineno`, relative to the working directory if it's below it."""
+    path = Path(filename)
+    if path.is_absolute():
+        try:
+            filename = str(path.relative_to(Path.cwd()))
+        except (ValueError, OSError):
+            pass
+    return f"{filename}:{lineno}"
+
+
+def _render_own(category: type[Warning], message: str, filename: str, lineno: int) -> bool:
+    """Show one of the client's warnings on the console, if it's interactive."""
+    from mp_api.client.core._display import print_warning
+
+    return print_warning(category.__name__, message, _location(filename, lineno))
+
+
+def _warning_origin() -> FrameType | None:
+    """The frame that called `warnings.warn` (or C code that warned).
+
+    Ignores `stacklevel`: libraries often pass `stacklevel=2` to report a
+    warning at their caller, which for a client call is the client's own code.
+    """
+    frame: FrameType | None = sys._getframe(1)
+    while frame is not None and frame.f_globals.get("__name__") in _WARNINGS_MODULES:
+        frame = frame.f_back
+    return frame
+
+
+class _WarningHook:
+    """Chained `warnings.showwarning`, see module docstring."""
+
+    def __init__(self, previous: Callable[..., None]) -> None:
+        self.previous = previous
+
+    def _prints_by_default(self) -> bool:
+        """Whether the hook below would print with Python's default printer.
+
+        False if an application put its own hook below ours (e.g.
+        `logging.captureWarnings(True)` before importing the client), or if
+        warnings are being recorded (`catch_warnings(record=True)`).
+        """
+        # `catch_warnings(record=True)` swaps the printer for `list.append`
+        return self.previous is getattr(
+            warnings, "_showwarning_orig", None
+        ) and inspect.isfunction(getattr(warnings, "_showwarnmsg_impl", None))
+
+    def __call__(
+        self,
+        message: Warning | str,
+        category: type[Warning],
+        filename: str,
+        lineno: int,
+        file: TextIO | None = None,
+        line: str | None = None,
+    ) -> None:
+        origin = _warning_origin()
+        module = origin.f_globals.get("__name__", "") if origin else ""
+        if module.split(".")[0] == "mp_api":
+            if file is None and self._prints_by_default() and _render_own(
+                category, str(message), filename, lineno
+            ):
+                return
+        elif is_capturing() and not issubclass(category, _PASS_THROUGH_CATEGORIES):
+            if origin is not None:
+                text = str(message)
+                if category is not UserWarning:
+                    text = f"{category.__name__}: {text}"
+                _emit(
+                    module.split(".")[0] or "unknown",
+                    logging.WARNING,
+                    text,
+                    origin.f_code.co_filename,
+                    origin.f_lineno,
+                    origin.f_code.co_name,
+                )
+                return
+        self.previous(message, category, filename, lineno, file, line)
+
+
+class _LastResortHandler(logging.Handler):
+    """Replacement for `logging.lastResort`, see module docstring."""
+
+    def __init__(self, previous: logging.Handler) -> None:
+        super().__init__(previous.level)
+        self.previous = previous
+
+    def handle(self, record: logging.LogRecord) -> bool:  # type: ignore[override]
+        package = record.name.split(".")[0]
+        if is_capturing() and package != "mp_api":
+            _emit(
+                package or "root",
+                record.levelno,
+                record.getMessage(),
+                record.pathname,
+                record.lineno,
+                record.funcName,
+            )
+            return True
+        return bool(self.previous.handle(record))
+
+    def emit(self, record: logging.LogRecord) -> None:  # pragma: no cover - unused
+        self.handle(record)
+
+
+_install_lock = threading.Lock()
+_installed = False
+
+
+def install() -> None:
+    """Install the warnings hook and the logging fallback, once per process."""
+    global _installed
+    with _install_lock:
+        if _installed:
+            return
+        warnings.showwarning = _WarningHook(warnings.showwarning)
+        if logging.lastResort is not None:
+            logging.lastResort = _LastResortHandler(logging.lastResort)
+        _installed = True
diff --git a/mp_api/client/core/client.py b/mp_api/client/core/client.py
index e1e14d99..3fbf9644 100644
--- a/mp_api/client/core/client.py
+++ b/mp_api/client/core/client.py
@@ -16,7 +16,6 @@
 import sys
 import time
 import uuid
-import warnings
 from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
 from contextlib import ExitStack
 from copy import copy
@@ -47,7 +46,14 @@
 from urllib3.util.retry import Retry
 
 from mp_api.client._server_utils import get_consumer, get_user_api_key, is_dev_env
-from mp_api.client.core._display import ProgressHandle, mp_warning, progress_bar, status
+from mp_api.client.core._display import (
+    ProgressHandle,
+    mp_deprecation,
+    mp_warning,
+    progress_bar,
+    status,
+)
+from mp_api.client.core._external import in_current_context, track_calls
 from mp_api.client.core.delta import DeltaCatalog
 from mp_api.client.core.exceptions import (
     MPRestError,
@@ -134,11 +140,10 @@ def __init__(self, catalog: DeltaCatalog | None = None, _warn: bool = True) -> N
             _warn (bool) : internal, whether to emit a DeprecationWarning
         """
         if _warn:
-            warnings.warn(
+            mp_deprecation(
                 "QueryBuilderWithCache is deprecated and will be removed in a future "
                 "release. Pass `delta_catalog=DeltaCatalog()` "
                 "(from mp_api.client.core.delta) to MPRester instead.",
-                FutureWarning,
                 stacklevel=2,
             )
         self.catalog: DeltaCatalog = catalog if catalog is not None else DeltaCatalog()
@@ -162,6 +167,11 @@ def execute(self, sql: str) -> RecordBatchReader:
 class _Rester:
     """Define base attributes of a REST client."""
 
+    def __init_subclass__(cls, **kwargs: Any) -> None:
+        """Run public methods inside `capturing()`, see `core._external`."""
+        super().__init_subclass__(**kwargs)
+        track_calls(cls)
+
     def __init__(
         self,
         api_key: str | None = None,
@@ -253,10 +263,9 @@ def __init__(
         if "monty_decode" in kwargs:
             # Pop to not repeatedly trigger warning to the user
             kwargs.pop("monty_decode", None)
-            warnings.warn(
+            mp_deprecation(
                 "Ignoring `monty_decode`, as it is no longer a supported option in `mp_api`."
                 "The client by default returns results consistent with `monty_decode=True`.",
-                FutureWarning,
                 stacklevel=2,
             )
 
@@ -284,9 +293,8 @@ def delta_catalog(self) -> DeltaCatalog:
     @property
     def query_builder(self) -> QueryBuilderWithCache:
         """Deprecated: use `delta_catalog`."""
-        warnings.warn(
+        mp_deprecation(
             "`query_builder` is deprecated, use `delta_catalog` instead.",
-            FutureWarning,
             stacklevel=2,
         )
         if self._query_builder is None:
@@ -373,6 +381,9 @@ def _get_heartbeat_info(endpoint) -> tuple[str, list[str]]:
         return response["db_version"], response["access_controlled_batch_ids"]
 
 
+track_calls(_Rester)
+
+
 class BaseRester(_Rester):
     """Base client class with core stubs."""
 
@@ -1806,6 +1817,8 @@ def _multi_thread(
         )  # Iter necessary for islice to keep track of what has been accessed
 
         params_ind = 0
+        # Workers run inside the caller's client call, see `core._external`
+        run_in_context = in_current_context(func)
 
         with ThreadPoolExecutor(
             max_workers=MAPI_CLIENT_SETTINGS.NUM_PARALLEL_REQUESTS  # type: ignore
@@ -1818,7 +1831,7 @@ def _multi_thread(
                 MAPI_CLIENT_SETTINGS.NUM_PARALLEL_REQUESTS,  # type: ignore
             ):
                 future = executor.submit(
-                    func,
+                    run_in_context,
                     **params,
                 )
 
@@ -1847,7 +1860,7 @@ def _multi_thread(
                 # Populate more futures to replace finished
                 for params in itertools.islice(params_gen, len(finished)):
                     new_future = executor.submit(
-                        func,
+                        run_in_context,
                         **params,
                     )
 
@@ -2015,9 +2028,8 @@ def get_data_by_id(
         document_id: str,
         fields: list[str] | None = None,
     ) -> BaseModel | dict[str, Any] | None:
-        warnings.warn(
+        mp_deprecation(
             "get_data_by_id is deprecated and will be removed soon. Please use the search method instead.",
-            FutureWarning,
             stacklevel=2,
         )
 
diff --git a/mp_api/client/core/settings.py b/mp_api/client/core/settings.py
index 8dfbb692..f53907f7 100644
--- a/mp_api/client/core/settings.py
+++ b/mp_api/client/core/settings.py
@@ -62,8 +62,15 @@ class MAPIClientSettings(BaseSettings):
 
     QUIET: bool = Field(
         False,
-        description="Silence all client output: log messages, progress bars, "
-        "notices and MPRestWarnings. Deprecation warnings and errors are still raised.",
+        description="Silence client output: log messages, progress bars, notices, "
+        "MPRestWarnings, and warnings from other libraries raised during client "
+        "calls. Deprecation warnings and errors are still raised.",
+    )
+
+    QUIET_DEPRECATIONS: bool = Field(
+        False,
+        description="With MPRESTER_QUIET, also silence the client's deprecation "
+        "warnings (FutureWarning).",
     )
 
     MIN_EMMET_VERSION: str = Field(
diff --git a/mp_api/client/core/utils.py b/mp_api/client/core/utils.py
index f4d205d2..7f6f08f6 100644
--- a/mp_api/client/core/utils.py
+++ b/mp_api/client/core/utils.py
@@ -19,7 +19,8 @@
 from monty.json import MontyDecoder
 from packaging.version import parse as parse_version
 
-from mp_api.client.core._display import mp_warning
+from mp_api.client.core._display import mp_advice, mp_warning
+from mp_api.client.core._external import track_calls
 from mp_api.client.core.exceptions import (
     MPDatasetIndexingWarning,
     MPDatasetIterationWarning,
@@ -282,6 +283,7 @@ def __dir__(self) -> Iterable[str]:
         return self._obj.__dir__() if hasattr(self._obj, "__dir__") else []
 
 
+@track_calls
 class MPDataset:
     """Convenience wrapper for pyarrow datasets stored on disk."""
 
@@ -394,12 +396,11 @@ def num_chunks(self) -> int:
 
     def __getitem__(self, idx):
         if isinstance(idx, slice):
-            warnings.warn(
-                """
-                Pythonic slicing of arrow-based MPDatasets is sub-optimal, consider using
-                idiomatic arrow patterns. See MP's docs on MPDatasets for relevant examples:
-                docs.materialsproject.org/materials-project-data-lakehouse/arrow-datasets
-                """,
+            mp_advice(
+                "Pythonic slicing of arrow-based MPDatasets is sub-optimal, consider "
+                "using idiomatic arrow patterns. See MP's docs on MPDatasets for "
+                "relevant examples: "
+                "docs.materialsproject.org/materials-project-data-lakehouse/arrow-datasets",
                 MPDatasetSlicingWarning,
                 stacklevel=2,
             )
@@ -412,12 +413,11 @@ def __getitem__(self, idx):
                 else ds_slice
             )
 
-        warnings.warn(
-            """
-            Pythonic indexing into arrow-based MPDatasets is sub-optimal, consider using
-            idiomatic arrow patterns. See MP's docs on MPDatasets for relevant examples:
-            docs.materialsproject.org/materials-project-data-lakehouse/arrow-datasets
-            """,
+        mp_advice(
+            "Pythonic indexing into arrow-based MPDatasets is sub-optimal, consider "
+            "using idiomatic arrow patterns. See MP's docs on MPDatasets for "
+            "relevant examples: "
+            "docs.materialsproject.org/materials-project-data-lakehouse/arrow-datasets",
             MPDatasetIndexingWarning,
             stacklevel=2,
         )
@@ -431,12 +431,11 @@ def __iter__(self):
         with warnings.catch_warnings(
             action="ignore", category=MPDatasetIndexingWarning
         ):
-            warnings.warn(
-                """
-                Iterating through arrow-based MPDatasets is sub-optimal, consider using
-                idiomatic arrow patterns. See MP's docs on MPDatasets for relevant examples:
-                docs.materialsproject.org/materials-project-data-lakehouse/arrow-datasets
-                """,
+            mp_advice(
+                "Iterating through arrow-based MPDatasets is sub-optimal, consider "
+                "using idiomatic arrow patterns. See MP's docs on MPDatasets for "
+                "relevant examples: "
+                "docs.materialsproject.org/materials-project-data-lakehouse/arrow-datasets",
                 MPDatasetIterationWarning,
                 stacklevel=2,
             )
diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py
index 8639abaa..baff3577 100644
--- a/mp_api/client/mprester.py
+++ b/mp_api/client/mprester.py
@@ -32,6 +32,7 @@
 
 from mp_api.client.core._display import (
     DatabaseVersions,
+    mp_deprecation,
     mp_warning,
     print_notice,
     status,
@@ -40,8 +41,10 @@
 from mp_api.client.core._entries import (
     apply_corrections,
     get_conventional_cell_entry,
+    mixing_scheme_class,
     rescale_to_conventional,
 )
+from mp_api.client.core._external import in_current_context
 from mp_api.client.core._oxygen_evolution import OxygenEvolution
 from mp_api.client.core.client import (
     LATEST_DB_VERSION,
@@ -127,6 +130,41 @@ def _all_subchemsyses(elements: Iterable[str]) -> list[str]:
     ]
 
 
+def _classify_chemsys_formula_ids(values: list[str]) -> dict[str, list[str]]:
+    """Sort `get_entries` inputs into chemical systems, formulas and IDs.
+
+    - chemical system: element symbols or "*" joined by "-" ("Li-Fe-O", "Si-*")
+    - formula: no "-", and a valid composition once "*" are removed ("Fe2O3", "Si*")
+    - anything else is taken to be an ID, validated later
+
+    Returns:
+        dict : {"chemsys": [...], "formula": [...], "material_ids": [...]},
+            leaving out kinds with no values, in that order
+    """
+    by_kind: dict[str, list[str]] = {"chemsys": [], "formula": [], "material_ids": []}
+    for value in values:
+        text = value.strip() if isinstance(value, str) else value
+        if not isinstance(text, str) or not text:
+            by_kind["material_ids"].append(value)
+            continue
+        parts = text.split("-")
+        if len(parts) > 1 and all(
+            part == "*" or Element.is_valid_symbol(part) for part in parts
+        ):
+            by_kind["chemsys"].append(text)
+            continue
+        if len(parts) == 1 and text.replace("*", ""):
+            try:
+                Composition(text.replace("*", ""))
+            except Exception:
+                pass
+            else:
+                by_kind["formula"].append(text)
+                continue
+        by_kind["material_ids"].append(value)
+    return {kind: found for kind, found in by_kind.items() if found}
+
+
 class MPRester(_Rester):
     """Access the new Materials Project API."""
 
@@ -366,10 +404,9 @@ def contribs(self):
 
     def __getattr__(self, attr):
         if attr in self._deprecated_attributes:
-            warnings.warn(
+            mp_deprecation(
                 f"Accessing {attr} data through MPRester.{attr} is deprecated. "
                 f"Please use MPRester.materials.{attr} instead.",
-                FutureWarning,
                 stacklevel=2,
             )
             return getattr(super().__getattribute__("materials"), attr)
@@ -463,7 +500,7 @@ def _list(route: str) -> tuple[str, list[str] | None]:
             ),
             ThreadPoolExecutor(max_workers=min(8, len(resters) or 1)) as pool,
         ):
-            found = list(pool.map(_list, sorted(resters)))
+            found = list(pool.map(in_current_context(_list), sorted(resters)))
         return DatabaseVersions(
             {route: versions for route, versions in found if versions},
             current=self.current_db_version,
@@ -544,10 +581,9 @@ def get_database_version(self) -> str | None:
 
         Returns: database version as a string if accessible, None otherwise
         """
-        warnings.warn(
+        mp_deprecation(
             "`get_database_version` has been deprecated in favor of "
             "MPRester().db_version.",
-            FutureWarning,
             stacklevel=2,
         )
         return self.db_version
@@ -735,7 +771,9 @@ def get_entries(
             chemsys_formula_mpids (str | list[str]): A chemical system, list of chemical systems
                 (e.g., Li-Fe-O, Si-*, [Si-O, Li-Fe-P]), formula, list of formulas
                 (e.g., Fe2O3, Si*, [SiO2, BiFeO3]), Materials Project ID, or list of Materials
-                Project IDs (e.g., mp-22526, [mp-22526, mp-149]).
+                Project IDs (e.g., mp-22526, [mp-22526, mp-149]). A list may mix these
+                kinds (e.g., [Li-O, Fe2O3, mp-149]): each kind is queried separately and
+                the entries are combined. Invalid IDs are skipped with a warning.
             compatible_only (bool): Whether to return only "compatible"
                 entries. Compatible entries are entries that have been
                 processed using the MaterialsProject2020Compatibility class,
@@ -757,26 +795,18 @@ def get_entries(
             List ComputedStructureEntry objects.
         """
         if kwargs.pop("inc_structure", None) is not None:
-            warnings.warn(
+            mp_deprecation(
                 "The `inc_structure` argument is deprecated as final structures "
                 "are always included in all returned ComputedStructureEntry objects.",
-                FutureWarning,
                 stacklevel=2,
             )
 
         if isinstance(chemsys_formula_mpids, str):
             chemsys_formula_mpids = [chemsys_formula_mpids]
 
-        try:
-            input_params = {"material_ids": validate_ids(chemsys_formula_mpids)}
-        except ValueError:
-            if any("-" in entry for entry in chemsys_formula_mpids):
-                input_params = {"chemsys": chemsys_formula_mpids}
-            else:
-                input_params = {"formula": chemsys_formula_mpids}
-
-        if additional_criteria:
-            input_params = {**input_params, **additional_criteria}
+        by_kind = _classify_chemsys_formula_ids(chemsys_formula_mpids)
+        if by_kind.get("material_ids"):  # report invalid IDs here, before any query
+            by_kind["material_ids"] = validate_ids(by_kind["material_ids"])
 
         entries: set[ComputedStructureEntry] = set()
 
@@ -786,11 +816,16 @@ def get_entries(
             else ["entries", "thermo_type"] + property_data
         )
 
-        docs = self.materials.thermo.search(
-            **input_params,  # type: ignore
-            all_fields=False,
-            fields=fields,
-        )
+        # One query per kind of input (a query matches all of its filters)
+        docs = [
+            doc
+            for kind, values in by_kind.items()
+            for doc in self.materials.thermo.search(
+                **{kind: values, **(additional_criteria or {})},  # type: ignore[arg-type]
+                all_fields=False,
+                fields=fields,
+            )
+        ]
 
         for doc in docs:
             entry_list = (doc.model_dump() if isinstance(doc, BaseModel) else doc)[
@@ -1344,9 +1379,7 @@ def get_entries_in_chemsys(
                 # building PDs. Mixing scheme is chemical-system dependent, so this can anchor on a
                 # different hull than MP did/would, and it drops entries it cannot place.
                 # A data event the caller can't change, so a log record (not a warning):
-                from pymatgen.entries.mixing_scheme import (
-                    MaterialsProjectDFTMixingScheme,
-                )
+                MaterialsProjectDFTMixingScheme = mixing_scheme_class()
 
                 chemsys = "-".join(sorted(elements_set))
                 logger.warning(
@@ -1918,7 +1951,7 @@ def get_stability(
             corrector = MaterialsProject2020Compatibility()
 
         elif thermo_type_valid_str == ThermoType.GGA_GGA_U_R2SCAN.value:
-            from pymatgen.entries.mixing_scheme import MaterialsProjectDFTMixingScheme
+            MaterialsProjectDFTMixingScheme = mixing_scheme_class()
 
             corrector = MaterialsProjectDFTMixingScheme(run_type_2="r2SCAN")
 
diff --git a/mp_api/client/routes/materials/electronic_structure.py b/mp_api/client/routes/materials/electronic_structure.py
index 4b528f99..dc12eb96 100644
--- a/mp_api/client/routes/materials/electronic_structure.py
+++ b/mp_api/client/routes/materials/electronic_structure.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import warnings
 from collections import defaultdict
 from typing import TYPE_CHECKING
 
@@ -17,6 +16,7 @@
 from pymatgen.electronic_structure.core import OrbitalType, Spin
 
 from mp_api.client.core import BaseRester, MPRestError
+from mp_api.client.core._display import mp_deprecation
 from mp_api.client.core.utils import validate_ids
 
 if TYPE_CHECKING:
@@ -30,10 +30,9 @@ class ElectronicStructureRester(BaseRester):
 
     def search_electronic_structure_docs(self, *args, **kwargs):  # pragma: no cover
         """Deprecated."""
-        warnings.warn(
+        mp_deprecation(
             "MPRester.electronic_structure.search_electronic_structure_docs is deprecated. "
             "Please use MPRester.electronic_structure.search instead.",
-            FutureWarning,
             stacklevel=2,
         )
 
@@ -180,10 +179,9 @@ class BandStructureRester(BaseESPropertyRester):
 
     def search_bandstructure_summary(self, *args, **kwargs):  # pragma: no cover
         """Deprecated."""
-        warnings.warn(
+        mp_deprecation(
             "MPRester.electronic_structure_bandstructure.search_bandstructure_summary is deprecated. "
             "Please use MPRester.electronic_structure_bandstructure.search instead.",
-            FutureWarning,
             stacklevel=2,
         )
 
@@ -415,10 +413,9 @@ class DosRester(BaseESPropertyRester):
 
     def search_dos_summary(self, *args, **kwargs):  # pragma: no cover
         """Deprecated."""
-        warnings.warn(
+        mp_deprecation(
             "MPRester.electronic_structure_dos.search_dos_summary is deprecated. "
             "Please use MPRester.electronic_structure_dos.search instead.",
-            FutureWarning,
             stacklevel=2,
         )
 
diff --git a/mp_api/client/routes/materials/eos.py b/mp_api/client/routes/materials/eos.py
index 45897e61..29ff0099 100644
--- a/mp_api/client/routes/materials/eos.py
+++ b/mp_api/client/routes/materials/eos.py
@@ -1,11 +1,11 @@
 from __future__ import annotations
 
-import warnings
 from collections import defaultdict
 
 from emmet.core.eos import EOSDoc
 
 from mp_api.client.core import BaseRester, MPRestError
+from mp_api.client.core._display import mp_deprecation
 from mp_api.client.core.utils import validate_ids
 
 
@@ -50,10 +50,9 @@ def search(
                     "Please specify only `task_ids`."
                 )
             task_ids = kwargs.pop("material_ids")
-            warnings.warn(
+            mp_deprecation(
                 "`material_id` has been replaced by `task_id` in the EOS endpoint. "
                 "Please migrate to using the newer field name.",
-                FutureWarning,
                 stacklevel=2,
             )
 
diff --git a/mp_api/client/routes/materials/materials.py b/mp_api/client/routes/materials/materials.py
index 0d2bffe5..e16325f8 100644
--- a/mp_api/client/routes/materials/materials.py
+++ b/mp_api/client/routes/materials/materials.py
@@ -262,7 +262,7 @@ def find_structure(
 
         if not matches:
             return []
-        material_ids = validate_ids(matches)
+        material_ids = validate_ids(matches, user_input=False)
         return material_ids if allow_multiple_results else material_ids[0]
 
     def get_blessed_entries(
diff --git a/mp_api/client/routes/materials/xas.py b/mp_api/client/routes/materials/xas.py
index 65d1e5dd..35e9c7d3 100644
--- a/mp_api/client/routes/materials/xas.py
+++ b/mp_api/client/routes/materials/xas.py
@@ -1,12 +1,12 @@
 from __future__ import annotations
 
-import warnings
 from typing import TYPE_CHECKING
 
 from emmet.core.xas import XASDoc, validate_xas_spectrum_id
 from pymatgen.core.periodic_table import Element
 
 from mp_api.client.core import BaseRester
+from mp_api.client.core._display import mp_deprecation
 from mp_api.client.core.exceptions import MPRestError
 
 if TYPE_CHECKING:
@@ -72,11 +72,10 @@ def search(
                     "Please specify only `task_ids`."
                 )
             task_ids = kwargs.pop("material_ids")
-            warnings.warn(
+            mp_deprecation(
                 "`material_id` has been replaced by `task_id` in the xas endpoint. "
                 "Please migrate to using the newer field name and the `task_ids` kwarg "
                 "for searching.",
-                FutureWarning,
                 stacklevel=2,
             )
 
diff --git a/mp_api/mcp/_schemas.py b/mp_api/mcp/_schemas.py
index 08a9a87d..5de05d4f 100644
--- a/mp_api/mcp/_schemas.py
+++ b/mp_api/mcp/_schemas.py
@@ -416,7 +416,7 @@ class FetchResult(BaseModel):
     @model_validator(mode="before")
     def set_url(cls, config: Any) -> Any:
         """Set default Materials Project URL and title."""
-        formatted_mpid = validate_ids([config.get("id")])[0]
+        formatted_mpid = validate_ids([config.get("id")], user_input=False)[0]
         if not config.get("title"):
             config["title"] = formatted_mpid
 
diff --git a/tests/client/core/test_external.py b/tests/client/core/test_external.py
new file mode 100644
index 00000000..f35e96b0
--- /dev/null
+++ b/tests/client/core/test_external.py
@@ -0,0 +1,673 @@
+"""Routing other libraries' warnings / log records through the client. No network."""
+
+from __future__ import annotations
+
+import inspect
+import io
+import logging
+import os
+import re
+import sys
+import threading
+import types
+import warnings
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager
+
+import pytest
+from rich.console import Console
+
+import mp_api.client.core._display as display
+from mp_api.client import quiet
+from mp_api.client.core._display import MP_THEME, mp_deprecation, mp_warning
+from mp_api.client.core._external import (
+    EXTERNAL_LOGGER,
+    _WarningHook,
+    capturing,
+    in_current_context,
+    is_capturing,
+    track_calls,
+)
+from mp_api.client.core.exceptions import MPRestWarning
+
+LIB_SOURCE = '''
+import logging, warnings
+
+log = logging.getLogger("fakelib.core")
+
+def process(i):
+    warnings.warn(f"WARNING! Discarding entry mp-{i}", stacklevel=2)
+
+def deprecated():
+    warnings.warn("old api", DeprecationWarning, stacklevel=2)
+
+def runtime():
+    warnings.warn("divide by zero", RuntimeWarning)
+
+def logs():
+    log.warning("library log record")
+'''
+
+
+@pytest.fixture
+def fakelib(tmp_path, monkeypatch):
+    """A third-party package that warns with `stacklevel=2`, as pymatgen does."""
+    pkg = tmp_path / "fakelib"
+    pkg.mkdir()
+    (pkg / "__init__.py").write_text(LIB_SOURCE)
+    monkeypatch.syspath_prepend(str(tmp_path))
+    monkeypatch.delitem(sys.modules, "fakelib", raising=False)
+    import fakelib
+
+    return fakelib
+
+
+@pytest.fixture(autouse=True)
+def routed():
+    """Chain the client's warnings hook over pytest's.
+
+    pytest runs each test inside `catch_warnings(record=True)`, which replaces
+    `warnings.showwarning`, exactly the "application replaced the hook" case
+    the client respects. Chaining it back in mirrors a session where nothing
+    replaced it, while pytest's recorder still sees what passes through.
+    """
+    previous = warnings.showwarning
+    warnings.showwarning = _WarningHook(previous)
+    yield
+    warnings.showwarning = previous
+
+
+@pytest.fixture
+def records():
+    """Records reaching `mp_api.client.external`, whatever handlers are set up."""
+    seen: list[logging.LogRecord] = []
+
+    class Recorder(logging.Handler):
+        def emit(self, record):
+            seen.append(record)
+
+    logger = logging.getLogger(EXTERNAL_LOGGER)
+    handler = Recorder(level=logging.DEBUG)
+    logger.addHandler(handler)
+    yield seen
+    logger.removeHandler(handler)
+
+
+@pytest.fixture
+def no_app_logging(monkeypatch):
+    """An application that hasn't configured logging: the client's records go
+    to its default handler, not to pytest's root handlers."""
+    monkeypatch.setattr(display, "_app_handlers", lambda logger: [])
+
+
+@contextmanager
+def unhandled(logger: logging.Logger):
+    """Make `logger`'s records reach no handler, so Python uses `lastResort`.
+
+    pytest attaches capture handlers to the root logger, and to loggers that
+    don't propagate, for each test phase, so this is applied in the test body.
+    """
+    propagate, handlers = logger.propagate, logger.handlers
+    logger.propagate, logger.handlers = False, []
+    try:
+        yield
+    finally:
+        logger.propagate, logger.handlers = propagate, handlers
+
+
+def _console(terminal: bool) -> tuple[Console, io.StringIO]:
+    buf = io.StringIO()
+    console = Console(
+        file=buf,
+        force_terminal=terminal,
+        force_jupyter=False,
+        theme=MP_THEME,
+        width=200,
+        color_system="truecolor" if terminal else None,
+    )
+    return console, buf
+
+
+@pytest.fixture
+def term(no_app_logging):
+    console, buf = _console(terminal=True)
+    display.set_console(console)
+    yield buf
+    display.set_console(None)
+
+
+@pytest.fixture
+def pipe(no_app_logging):
+    console, buf = _console(terminal=False)
+    display.set_console(console)
+    yield buf
+    display.set_console(None)
+
+
+def plain(text: str) -> str:
+    return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text)
+
+
+@track_calls
+class Client:
+    """Stands in for a rester."""
+
+    def __init__(self, lib=None):
+        self.lib = lib
+
+    def run(self, n: int = 1, *, flag: bool = False) -> str:
+        """Docs."""
+        for i in range(n):
+            self.lib.process(i)
+        return "done"
+
+    def nested(self):
+        assert is_capturing()
+        return self.run()
+
+    @staticmethod
+    def static_call(lib):
+        lib.process(7)
+
+    def advise(self):
+        mp_warning("check your arguments", stacklevel=2)
+
+    def deprecated_api(self):
+        mp_deprecation("use something else", stacklevel=2)
+
+    def _private(self):
+        return is_capturing()
+
+    @property
+    def prop(self):
+        return is_capturing()
+
+
+# --- capturing / wrapping -------------------------------------------------------
+
+
+def test_public_methods_capture_private_and_properties_dont():
+    client = Client()
+    assert not is_capturing()
+    assert client._private() is False and client.prop is False
+    assert Client.nested.__mp_tracked__
+
+
+def test_wrapping_preserves_signature_and_is_idempotent():
+    signature = inspect.signature(Client.run)
+    assert list(signature.parameters) == ["self", "n", "flag"]
+    assert Client.run.__doc__ == "Docs."
+    run = Client.run
+    track_calls(Client)
+    assert Client.run is run  # not wrapped twice
+
+
+def test_resters_are_tracked():
+    from mp_api.client import MPRester
+    from mp_api.client.core.client import BaseRester
+    from mp_api.client.core.utils import MPDataset
+    from mp_api.client.routes.materials.thermo import ThermoRester
+
+    assert MPRester.get_stability.__mp_tracked__
+    assert MPRester.__init__.__mp_tracked__
+    assert BaseRester.count.__mp_tracked__
+    assert ThermoRester.get_phase_diagram_from_chemsys.__mp_tracked__
+    assert MPDataset.__getitem__.__mp_tracked__
+
+
+# --- warnings ------------------------------------------------------------------
+
+
+def test_library_warning_becomes_attributed_record(fakelib, records):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        assert Client(fakelib).run(2) == "done"
+    assert [r.levelno for r in records] == [logging.WARNING] * 2
+    record = records[0]
+    assert record.name == f"{EXTERNAL_LOGGER}.fakelib"
+    # the library line that warned, not the caller `stacklevel=2` points at
+    assert record.pathname == fakelib.__file__ and record.lineno == 7
+    assert record.mp_origin == "fakelib/__init__.py:7"
+    assert record.mp_message == "WARNING! Discarding entry mp-0"
+    assert record.getMessage() == (
+        "WARNING! Discarding entry mp-0 (fakelib/__init__.py:7)"
+    )
+
+
+def test_every_category_is_logged_at_warning_with_its_name(fakelib, records):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        with capturing():
+            fakelib.runtime()
+    assert records[0].levelno == logging.WARNING
+    assert records[0].mp_message == "RuntimeWarning: divide by zero"
+
+
+def test_outside_client_calls_warnings_are_untouched(fakelib, records):
+    with pytest.warns(UserWarning, match="Discarding entry mp-0"):
+        fakelib.process(0)
+    assert not records
+
+
+def test_library_deprecations_pass_through(fakelib, records):
+    with pytest.warns(DeprecationWarning, match="old api"), capturing():
+        fakelib.deprecated()
+    assert not records
+
+
+def test_client_warnings_pass_through(records):
+    client = Client()
+    with pytest.warns(MPRestWarning, match="check your arguments"):
+        client.advise()
+    with pytest.warns(FutureWarning, match="use something else"):
+        client.deprecated_api()
+    assert not records
+
+
+def test_client_warnings_point_past_the_wrappers(recwarn):
+    warnings.simplefilter("always")
+    Client().advise()
+    Client().deprecated_api()
+    assert [w.filename for w in recwarn] == [__file__] * 2
+
+
+@pytest.mark.parametrize("action", ["error", "ignore", "once"])
+def test_warning_filters_apply_first(fakelib, records, action):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        warnings.filterwarnings(action, message="WARNING! Discarding")
+        if action == "error":
+            with pytest.raises(UserWarning):
+                Client(fakelib).run()
+        else:
+            Client(fakelib).run(1)
+            Client(fakelib).run(1)
+    assert len(records) == {"error": 0, "ignore": 0, "once": 1}[action]
+
+
+def test_replaced_showwarning_gets_everything(fakelib, records):
+    """E.g. `pytest.warns`: the application's hook wins, without being displaced."""
+    with warnings.catch_warnings(record=True) as caught:
+        warnings.simplefilter("always")
+        Client(fakelib).run()
+    assert [str(w.message) for w in caught] == ["WARNING! Discarding entry mp-0"]
+    assert not records
+
+
+def test_quiet_silences_library_warnings(fakelib, records, capsys):
+    with quiet(), warnings.catch_warnings():
+        warnings.simplefilter("always")
+        Client(fakelib).run(3)
+    assert not records
+    assert capsys.readouterr().err == ""
+
+
+# --- threads -------------------------------------------------------------------
+
+
+def test_nested_calls_and_static_methods(fakelib, records):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        Client(fakelib).nested()
+        Client.static_call(fakelib)
+    assert len(records) == 2
+    assert not is_capturing()
+
+
+def test_client_pools_carry_the_call_context(fakelib, records):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        with capturing(), ThreadPoolExecutor(2) as pool:
+            assert all(pool.map(in_current_context(lambda _: is_capturing()), [1, 2]))
+            list(pool.map(in_current_context(fakelib.process), [1, 2]))
+    assert len(records) == 2
+
+
+def test_unrelated_threads_are_not_captured(fakelib, records):
+    seen = []
+    with capturing():
+        thread = threading.Thread(target=lambda: seen.append(is_capturing()))
+        thread.start()
+        thread.join()
+    assert seen == [False]
+
+
+def test_multi_thread_requests_run_in_call_context(monkeypatch):
+    from mp_api.client.core.client import BaseRester
+
+    rester = BaseRester.__new__(BaseRester)
+    seen = []
+
+    def func(**params):
+        seen.append(is_capturing())
+        return [params], 1
+
+    with capturing():
+        rester._multi_thread(func, [{"a": 1}, {"a": 2}])
+    assert seen == [True, True]
+
+
+# --- library logging -----------------------------------------------------------
+
+
+def test_fallback_log_records_are_routed(fakelib, records, capsys):
+    with unhandled(fakelib.log):
+        with capturing():
+            fakelib.logs()
+        assert [r.mp_message for r in records] == ["library log record"]
+        assert records[0].name == f"{EXTERNAL_LOGGER}.fakelib"
+        assert records[0].levelno == logging.WARNING
+        assert records[0].mp_origin == "fakelib/__init__.py:16"
+
+        records.clear()
+        fakelib.logs()  # outside client calls: Python's usual fallback to stderr
+    assert not records
+    assert capsys.readouterr().err == "library log record\n"
+
+
+def test_fallback_log_records_silenced_by_quiet(fakelib, records, capsys):
+    with unhandled(fakelib.log), quiet(), capturing():
+        fakelib.logs()
+    assert not records and capsys.readouterr().err == ""
+
+
+def test_configured_app_logging_is_untouched(fakelib, records):
+    """With handlers on the root logger (pytest's here), the fallback isn't used."""
+    with capturing():
+        fakelib.logs()
+    assert not records
+
+
+# --- rendering -----------------------------------------------------------------
+
+
+def test_terminal_rendering(fakelib, term):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        Client(fakelib).run()
+    raw = term.getvalue()
+    lines = [line.rstrip() for line in plain(raw).splitlines()]
+    assert lines[0].split() == [
+        "WARNING",
+        "mp_api.client.external.fakelib",
+        "WARNING!",
+        "Discarding",
+        "entry",
+        "mp-0",
+    ]
+    assert lines[1].strip() == "fakelib/__init__.py:7"
+    assert lines[1].index("fakelib") == lines[0].index("mp_api")  # message column
+    # level amber, logger name secondary, origin muted, message unstyled
+    assert "\x1b[38;2;214;158;0mWARNING" in raw
+    assert "\x1b[38;2;147;159;200mmp_api.client.external.fakelib" in raw
+    assert "\x1b[38;2;122;122;122mfakelib/__init__.py:7" in raw
+    assert "WARNING! Discarding entry mp-0" in raw  # no highlighting codes inside
+
+
+def test_client_messages_are_not_highlighted(term):
+    logging.getLogger("mp_api.client.core").warning("Reused 12 entries for mp-149")
+    assert "Reused 12 entries for mp-149" in term.getvalue()
+
+
+def test_plain_rendering_is_one_line(fakelib, pipe):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        Client(fakelib).run()
+    assert pipe.getvalue() == (
+        "mp_api.client.external.fakelib - WARNING - "
+        "WARNING! Discarding entry mp-0 (fakelib/__init__.py:7)\n"
+    )
+
+
+def test_per_library_level(fakelib, pipe):
+    logger = logging.getLogger(f"{EXTERNAL_LOGGER}.fakelib")
+    logger.setLevel(logging.ERROR)
+    try:
+        with warnings.catch_warnings():
+            warnings.simplefilter("always")
+            Client(fakelib).run()
+    finally:
+        logger.setLevel(logging.NOTSET)
+    assert pipe.getvalue() == ""
+
+
+# --- deprecations --------------------------------------------------------------
+
+
+def test_quiet_keeps_deprecations_by_default():
+    with quiet(), pytest.warns(FutureWarning):
+        Client().deprecated_api()
+
+
+def test_quiet_deprecations(recwarn):
+    warnings.simplefilter("always")
+    with quiet(deprecations=True):
+        Client().deprecated_api()
+        mp_deprecation("direct")
+    assert not recwarn
+    with pytest.warns(FutureWarning):  # restored on exit
+        Client().deprecated_api()
+
+
+def test_deprecations_flag_needs_quiet(recwarn):
+    warnings.simplefilter("always")
+    with quiet(False, deprecations=True):
+        Client().deprecated_api()
+    assert [w.category for w in recwarn] == [FutureWarning]
+
+
+def test_quiet_deprecations_setting(monkeypatch):
+    from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS
+
+    monkeypatch.setattr(MAPI_CLIENT_SETTINGS, "QUIET", True)
+    monkeypatch.setattr(MAPI_CLIENT_SETTINGS, "QUIET_DEPRECATIONS", True)
+    display._configure_from_settings()
+    assert display.is_quiet() and display._QUIET.deprecations
+
+
+def test_client_deprecations_use_the_helper():
+    """No direct FutureWarning in client code, so `quiet(deprecations=True)` covers all."""
+    import pathlib
+
+    import mp_api.client
+
+    root = pathlib.Path(mp_api.client.__file__).parent
+    offenders = [
+        str(path.relative_to(root))
+        for path in root.rglob("*.py")
+        if path.name not in {"_display.py", "_external.py", "settings.py"}
+        and "FutureWarning" in path.read_text()
+    ]
+    assert offenders == []
+
+
+# --- the reported case ---------------------------------------------------------
+
+
+def test_get_stability_quiet_silences_pymatgen_warnings(monkeypatch, capsys, records):
+    """`with quiet(): mpr.get_stability(...)` printed pymatgen's per-entry warnings."""
+    from pymatgen.analysis.phase_diagram import PhaseDiagram
+    from pymatgen.entries.computed_entries import ComputedEntry
+
+    from mp_api.client import MPRester
+    from mp_api.client.core.client import _Rester
+    from mp_api.client.routes.materials.thermo import ThermoRester
+
+    monkeypatch.setattr(
+        _Rester, "_get_heartbeat_info", staticmethod(lambda ep: ("2026.04.13", []))
+    )
+    monkeypatch.setattr(MPRester, "get_emmet_version", staticmethod(lambda ep: None))
+    refs = [
+        ComputedEntry("Li", -1.9, entry_id="mp-1"),
+        ComputedEntry("O2", -9.8, entry_id="mp-2"),
+        ComputedEntry("Li2O", -14.0, entry_id="mp-3"),
+    ]
+    monkeypatch.setattr(
+        ThermoRester,
+        "get_phase_diagram_from_chemsys",
+        lambda self, *a, **k: PhaseDiagram(refs),
+    )
+    # A correction scheme living in pymatgen that warns per entry with
+    # `stacklevel=2`, like the mixing scheme's "Discarding r2SCAN entry ...".
+    module = types.ModuleType("pymatgen.analysis.compatibility.fake")
+    module.__file__ = "/site-packages/pymatgen/analysis/compatibility/fake.py"
+    exec(
+        compile(
+            "import warnings\n"
+            "from pymatgen.entries.compatibility import Compatibility\n"
+            "class Scheme(Compatibility):\n"
+            "    def get_adjustments(self, entry):\n"
+            "        return []\n"
+            "    def process_entries(self, entries, **kwargs):\n"
+            "        for e in entries:\n"
+            "            warnings.warn(f'WARNING! Discarding entry {e.entry_id}', stacklevel=2)\n"
+            "        return list(entries)\n",
+            module.__file__,
+            "exec",
+        ),
+        module.__dict__,
+    )
+    monkeypatch.setattr(
+        "pymatgen.entries.compatibility.MaterialsProject2020Compatibility",
+        module.Scheme,
+    )
+    mine = [ComputedEntry("Li2O2", -24.0, entry_id="mine-1")]
+    mpr = MPRester(api_key="a" * 32, mute_progress_bars=True)
+
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        with quiet():
+            assert mpr.get_stability(mine, thermo_type="GGA_GGA+U")
+        assert not records and capsys.readouterr().err == ""
+
+        mpr.get_stability(mine, thermo_type="GGA_GGA+U")  # not quiet
+    assert len(records) == 4
+    assert {r.name for r in records} == {f"{EXTERNAL_LOGGER}.pymatgen"}
+    assert records[0].mp_message == "WARNING! Discarding entry mine-1"
+    assert records[0].mp_origin == "pymatgen/analysis/compatibility/fake.py:8"
+
+
+# --- the client's own warnings ---------------------------------------------------
+
+
+@pytest.fixture
+def default_printer(monkeypatch):
+    """Python's default warning printing, with what it would print collected.
+
+    (pytest records warnings by replacing the printer, which the client
+    rightly leaves alone, so the default setup is rebuilt here.)
+    """
+    printed: list[str] = []
+    monkeypatch.setattr(
+        warnings,
+        "_showwarnmsg_impl",
+        lambda msg: printed.append(f"{msg.category.__name__}: {msg.message}"),
+    )
+    monkeypatch.setattr(warnings, "showwarning", _WarningHook(warnings._showwarning_orig))
+    return printed
+
+
+def own_warnings():
+    Client().advise()
+    Client().deprecated_api()
+
+
+def test_own_warnings_styled_in_terminal(term, default_printer):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        own_warnings()
+    raw = term.getvalue()
+    lines = [line.rstrip() for line in plain(raw).splitlines()]
+    here = os.path.relpath(__file__)
+    assert lines[0].split() == ["MPRestWarning", "check", "your", "arguments"]
+    assert lines[1].strip().startswith(f"{here}:")
+    assert lines[1].index(here) == lines[0].index("check")  # message column
+    assert lines[2].split() == ["FutureWarning", "use", "something", "else"]
+    assert "\x1b[38;2;214;158;0mMPRestWarning" in raw
+    assert "\x1b[38;2;214;158;0mFutureWarning" in raw
+    assert "check your arguments" in raw  # message unstyled
+    assert f"\x1b[38;2;122;122;122m{here}:" in raw
+    assert default_printer == []  # not printed a second time
+
+
+def test_own_warnings_plain_when_not_interactive(pipe, default_printer):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        own_warnings()
+    assert pipe.getvalue() == ""
+    assert default_printer == [
+        "MPRestWarning: check your arguments",
+        "FutureWarning: use something else",
+    ]
+
+
+def test_own_warnings_respect_filters(term, default_printer):
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", MPRestWarning)
+        warnings.simplefilter("error", FutureWarning)
+        Client().advise()
+        with pytest.raises(FutureWarning):
+            Client().deprecated_api()
+    assert term.getvalue() == ""
+
+
+def test_own_warnings_shown_once_per_location_by_default(term, default_printer):
+    with warnings.catch_warnings():
+        warnings.simplefilter("default")
+        for _ in range(3):
+            Client().advise()
+    assert plain(term.getvalue()).count("MPRestWarning") == 1
+
+
+def test_own_warnings_go_to_an_app_hook_below_ours(term, monkeypatch):
+    """E.g. `logging.captureWarnings(True)` before importing the client."""
+    seen = []
+    monkeypatch.setattr(
+        warnings, "showwarning", _WarningHook(lambda m, c, *a, **k: seen.append(c))
+    )
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        own_warnings()
+    assert seen == [MPRestWarning, FutureWarning]
+    assert term.getvalue() == ""
+
+
+def test_own_warnings_recorded_by_pytest(term):
+    with pytest.warns(MPRestWarning, match="check your arguments"):
+        Client().advise()
+    assert term.getvalue() == ""
+
+
+def test_user_warnings_are_not_styled(term, default_printer):
+    with warnings.catch_warnings():
+        warnings.simplefilter("always")
+        warnings.warn("user's own", FutureWarning)
+    assert term.getvalue() == ""
+    assert default_printer == ["FutureWarning: user's own"]
+
+
+def test_quiet_silences_dataset_warnings(tmp_path):
+    import pyarrow as pa
+    from deltalake import write_deltalake
+
+    from mp_api.client.core.exceptions import (
+        MPDatasetIndexingWarning,
+        MPDatasetIterationWarning,
+        MPDatasetSlicingWarning,
+    )
+    from mp_api.client.core.utils import MPDataset
+
+    write_deltalake(str(tmp_path), pa.table({"x": [1, 2, 3]}))
+    dataset = MPDataset(str(tmp_path), document_model=None, use_document_model=False)
+    with pytest.warns(MPDatasetIndexingWarning) as record:
+        assert dataset[0] == {"x": 1}
+    assert record[0].filename == __file__
+    with pytest.warns(MPDatasetSlicingWarning):
+        dataset[0:2]
+    with pytest.warns(MPDatasetIterationWarning):
+        list(dataset)
+
+    with warnings.catch_warnings(record=True) as caught, quiet():
+        warnings.simplefilter("always")
+        dataset[0], dataset[0:2], list(dataset)
+    assert caught == []
diff --git a/tests/client/test_entry_progress.py b/tests/client/test_entry_progress.py
index 2ad6bbba..21f5a6d2 100644
--- a/tests/client/test_entry_progress.py
+++ b/tests/client/test_entry_progress.py
@@ -17,13 +17,13 @@
     ComputedStructureEntry,
     GibbsComputedStructureEntry,
 )
-from pymatgen.entries.mixing_scheme import MaterialsProjectDFTMixingScheme
 from rich.console import Console
 
 import mp_api.client.core._display as display
 from mp_api.client import quiet
 from mp_api.client.core._display import MP_THEME, progress_bar
 from mp_api.client.core._entries import (
+    mixing_scheme_class,
     _track_calls,
     apply_corrections,
     _private_copy,
@@ -33,6 +33,7 @@
 from mp_api.client.core.exceptions import MPRestWarning
 
 CLIENT = "mp_api.client"
+MaterialsProjectDFTMixingScheme = mixing_scheme_class()
 
 
 def messages(caplog) -> list[str]:

From 18a0f8cc1f0492ac2b9001669dade863665d88b3 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:05:09 -0700
Subject: [PATCH 24/30] adopt strict validation from emmet-core for ids

---
 mp_api/client/core/utils.py     |  80 +++++++++++--
 pyproject.toml                  |   2 +-
 tests/client/core/test_ids.py   | 192 ++++++++++++++++++++++++++++++++
 tests/client/core/test_utils.py |   4 +-
 4 files changed, 269 insertions(+), 9 deletions(-)
 create mode 100644 tests/client/core/test_ids.py

diff --git a/mp_api/client/core/utils.py b/mp_api/client/core/utils.py
index 7f6f08f6..92ea9d2e 100644
--- a/mp_api/client/core/utils.py
+++ b/mp_api/client/core/utils.py
@@ -131,26 +131,92 @@ def validate_api_key(api_key: str | None = None) -> str | None:
     return api_key
 
 
-def validate_ids(id_list: list[str]) -> list[str]:
-    """Function to validate material and task IDs.
+LEGACY_ID_PREFIXES: frozenset[str] = frozenset({"mvc"})
+"""Prefixes of identifiers that used to exist in the database but were removed."""
+
+
+def _shortlist(items: list[Any], sep: str = ", ", limit: int = 10) -> str:
+    """The first `limit` items joined by `sep`, noting how many were left out."""
+    shown = sep.join(map(str, items[:limit]))
+    if len(items) > limit:
+        shown += f"{sep}... ({len(items) - limit:,} more)"
+    return shown
+
+
+def _is_legacy_id(idx: Any) -> bool:
+    return isinstance(idx, str) and idx.split("-", 1)[0] in LEGACY_ID_PREFIXES
+
+
+def validate_ids(id_list: Iterable[Any], *, user_input: bool = True) -> list[str]:
+    """Validate and format Materials Project identifiers (material and task IDs).
+
+    Identifiers are checked strictly (see `emmet.core.mpid.validate_identifier`
+    with `strict=True`): a bare ID ("149", "aaaaaaft") or "-" with
+    a known prefix ("mp-149"). Invalid identifiers are dropped and reported
+    together in one `MPRestWarning`, which the caller can act on. Legacy
+    identifiers (e.g. "mvc-149"), which were removed from the database, are
+    dropped with a notice of their own.
 
     Args:
-        id_list (list[str]): List of material or task IDs.
+        id_list (Iterable) : identifiers given by the user, or returned by the API
+        user_input (bool) : True for identifiers the user passed. False for
+            identifiers the API returned, for which an invalid one is a bug, so
+            an `MPRestError` is raised instead of a warning.
 
     Raises:
-        MPRestError: If at least one ID is not formatted correctly.
+        MPRestError: if too many identifiers are given, if none is valid, or
+            (with `user_input=False`) if any is invalid.
 
     Returns:
-        id_list: Returns original ID list if everything is formatted correctly.
+        list of str : the valid identifiers, formatted, in their input order
     """
+    from mp_api.client.core._external import user_stacklevel
+
+    id_list = list(id_list)
     if len(id_list) > MAPI_CLIENT_SETTINGS.MAX_LIST_LENGTH:
         raise MPRestError(
             "List of material/molecule IDs provided is too long. Consider removing the ID filter to automatically pull"
             " data for all IDs and filter locally."
         )
 
-    validated = [validate_identifier(idx, serialize=False) for idx in id_list]
-    return [getattr(idx, "string", str(idx)) for idx in validated]
+    valid: list[str] = []
+    legacy: list[str] = []
+    invalid: list[str] = []
+    for idx in id_list:
+        if _is_legacy_id(idx):
+            legacy.append(idx)
+            continue
+        try:
+            parsed = validate_identifier(idx, serialize=False, strict=True)
+        except ValueError as exc:
+            invalid.append(str(exc))
+            continue
+        valid.append(getattr(parsed, "string", str(parsed)))
+
+    if not user_input and (invalid or legacy):
+        raise MPRestError(
+            "The Materials Project API returned invalid identifiers: "
+            + _shortlist(invalid + [repr(idx) for idx in legacy], sep="; ")
+        )
+
+    stacklevel = user_stacklevel()
+    if legacy:
+        mp_warning(
+            "These IDs refer to legacy calculations that have been removed from the "
+            f"Materials Project database, and are ignored: {_shortlist(legacy)}",
+            stacklevel=stacklevel,
+        )
+    if invalid:
+        reasons = [f"{reason.rstrip('.')}." for reason in invalid]
+        mp_warning(
+            "Ignoring invalid IDs. Materials Project IDs are a bare ID, e.g. "
+            "'149' or 'aaaaaaft', or '-', e.g. 'mp-149'. "
+            + _shortlist(reasons, sep=" "),
+            stacklevel=stacklevel,
+        )
+    if id_list and not valid:
+        raise MPRestError(f"No valid IDs to query (ignored: {_shortlist(id_list)}).")
+    return valid
 
 
 def validate_endpoint(endpoint: str | None, suffix: str | None = None) -> str:
diff --git a/pyproject.toml b/pyproject.toml
index ea5c117e..c17d5365 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@ dependencies = [
   "pymatgen>2024.2.20",
   "requests>=2.23.0",
   "monty>=2024.12.10",
-  "emmet-core>=0.88.0rc,<0.88.1",
+  "emmet-core>=0.88.0rc3,<0.88.1",
   "boto3",
   "orjson >= 3.10,<4",
   "pyarrow >= 20.0.0",
diff --git a/tests/client/core/test_ids.py b/tests/client/core/test_ids.py
new file mode 100644
index 00000000..703d4934
--- /dev/null
+++ b/tests/client/core/test_ids.py
@@ -0,0 +1,192 @@
+"""ID validation and `get_entries` input handling. No network access."""
+
+import warnings
+
+import pytest
+
+from mp_api.client.core.exceptions import MPRestError, MPRestWarning
+from mp_api.client.core.utils import validate_ids
+
+
+def caught(fn, *args, **kwargs):
+    with warnings.catch_warnings(record=True) as record:
+        warnings.simplefilter("always")
+        result = fn(*args, **kwargs)
+    return result, [w for w in record if issubclass(w.category, MPRestWarning)]
+
+
+def test_valid_ids_unchanged():
+    ids = ["mp-149", "149", "mp-aaaaaaft", "aaabivno"]
+    result, record = caught(validate_ids, ids)
+    assert result == ["mp-149", "149", "mp-149", "612132"]
+    assert not record
+
+
+def test_molecule_ids_valid():
+    """`mol-` IDs are valid (needs emmet-core with "mol" in STRICT_ID_PREFIXES)."""
+    result, record = caught(validate_ids, ["mol-45228"])
+    assert result == ["mol-45228"] and not record
+
+
+def test_invalid_ids_warn_and_are_dropped():
+    result, record = caught(validate_ids, ["mp-149", "fake-149", "x_y", "149"])
+    assert result == ["mp-149", "149"]
+    assert len(record) == 1
+    message = str(record[0].message)
+    assert "Ignoring invalid IDs" in message
+    assert "'fake-149': prefix 'fake'" in message and "'x_y'" in message
+    assert record[0].filename == __file__  # points at the caller
+
+
+def test_legacy_ids_get_their_own_notice():
+    result, record = caught(validate_ids, ["mvc-149", "mp-149"])
+    assert result == ["mp-149"]
+    assert [str(w.message) for w in record] == [
+        "These IDs refer to legacy calculations that have been removed from the "
+        "Materials Project database, and are ignored: mvc-149"
+    ]
+
+
+def test_no_valid_ids_raises():
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        with pytest.raises(MPRestError, match=r"No valid IDs to query \(ignored: "):
+            validate_ids(["mvc-1", "fake-2"])
+
+
+def test_non_strings_are_invalid():
+    result, record = caught(validate_ids, [149, "mp-13"])
+    assert result == ["mp-13"] and "expected a string" in str(record[0].message)
+
+
+def test_api_returned_ids_raise_instead_of_warning():
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")  # no warning either
+        assert validate_ids(["mp-149"], user_input=False) == ["mp-149"]
+        with pytest.raises(MPRestError, match="API returned invalid identifiers"):
+            validate_ids(["mp-149", "fake-1"], user_input=False)
+        with pytest.raises(MPRestError, match="mvc-1"):
+            validate_ids(["mvc-1"], user_input=False)
+
+
+def test_too_many_ids_raise():
+    from mp_api.client.core.settings import MAPI_CLIENT_SETTINGS
+
+    with pytest.raises(MPRestError, match="too long"):
+        validate_ids(["mp-1"] * (MAPI_CLIENT_SETTINGS.MAX_LIST_LENGTH + 1))
+
+
+def test_quiet_silences_id_warnings():
+    from mp_api.client import quiet
+
+    with quiet():
+        result, record = caught(validate_ids, ["mvc-1", "fake-2", "mp-3"])
+    assert result == ["mp-3"] and not record
+
+
+def test_rester_warning_points_at_user_code(monkeypatch):
+    from mp_api.client.core.client import BaseRester, _Rester
+    from mp_api.client.routes.materials.thermo import ThermoRester
+
+    monkeypatch.setattr(
+        _Rester, "_get_heartbeat_info", staticmethod(lambda ep: ("2026.04.13", []))
+    )
+    seen = {}
+    monkeypatch.setattr(BaseRester, "_search", lambda self, **kw: seen.update(kw) or [])
+    rester = ThermoRester(api_key="a" * 32)
+    _, record = caught(rester.search, material_ids=["mp-149", "fake-1"])
+    assert seen["material_ids"] == "mp-149"
+    assert record[0].filename == __file__
+
+
+# --- get_entries --------------------------------------------------------------
+
+
+def test_classify_inputs():
+    from mp_api.client.mprester import _classify_chemsys_formula_ids
+
+    assert _classify_chemsys_formula_ids(
+        ["Li-Fe-O", "Si-*", "Fe2O3", "Si*", "O", "mp-149", "mvc-149", "149", "Fe-x"]
+    ) == {
+        "chemsys": ["Li-Fe-O", "Si-*"],
+        "formula": ["Fe2O3", "Si*", "O"],
+        "material_ids": ["mp-149", "mvc-149", "149", "Fe-x"],
+    }
+    assert _classify_chemsys_formula_ids(["Fe2O3"]) == {"formula": ["Fe2O3"]}
+
+
+@pytest.fixture
+def mpr_and_queries(monkeypatch):
+    from mp_api.client import MPRester
+    from mp_api.client.core.client import _Rester
+    from mp_api.client.routes.materials.thermo import ThermoRester
+
+    monkeypatch.setattr(
+        _Rester, "_get_heartbeat_info", staticmethod(lambda ep: ("2026.04.13", []))
+    )
+    monkeypatch.setattr(MPRester, "get_emmet_version", staticmethod(lambda ep: None))
+    queries: list[dict] = []
+
+    def search(self, **kwargs):
+        queries.append(kwargs)
+        return []
+
+    monkeypatch.setattr(ThermoRester, "search", search)
+    return MPRester(api_key="a" * 32, mute_progress_bars=True), queries
+
+
+def test_get_entries_one_query_per_kind(mpr_and_queries):
+    mpr, queries = mpr_and_queries
+    _, record = caught(
+        mpr.get_entries,
+        ["Li-O", "Fe2O3", "mp-149", "mvc-149"],
+        additional_criteria={"is_stable": True},
+    )
+    assert [
+        {k: v for k, v in q.items() if k not in {"fields", "all_fields"}}
+        for q in queries
+    ] == [
+        {"chemsys": ["Li-O"], "is_stable": True},
+        {"formula": ["Fe2O3"], "is_stable": True},
+        {"material_ids": ["mp-149"], "is_stable": True},
+    ]
+    assert len(record) == 1 and "mvc-149" in str(record[0].message)
+    assert record[0].filename == __file__
+
+
+def test_get_entries_never_sends_ids_as_chemsys(mpr_and_queries):
+    mpr, queries = mpr_and_queries
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        with pytest.raises(MPRestError, match="No valid IDs"):
+            mpr.get_entries(["mvc-149", "mp-149-GGA"])
+    assert queries == []
+
+
+def test_get_entries_merges_and_deduplicates(mpr_and_queries, monkeypatch):
+    from tests.client.test_entry_progress import _thermo_docs, fcc_entry
+
+    from mp_api.client.routes.materials.thermo import ThermoRester
+
+    shared = fcc_entry("mp-1")
+    by_kind = {
+        "chemsys": _thermo_docs([shared, fcc_entry("mp-2")]),
+        "material_ids": _thermo_docs([shared]),
+    }
+    monkeypatch.setattr(
+        ThermoRester,
+        "search",
+        lambda self, **kw: by_kind[next(k for k in by_kind if k in kw)],
+    )
+    mpr, _ = mpr_and_queries
+    entries = mpr.get_entries(["Cu-Al", "mp-1"])
+    assert sorted(str(e.entry_id) for e in entries) == ["mp-1-GGA", "mp-2-GGA"]
+
+
+def test_messages_are_bounded():
+    with warnings.catch_warnings(record=True) as record:
+        warnings.simplefilter("always")
+        with pytest.raises(MPRestError) as error:
+            validate_ids([f"bad_{i}" for i in range(500)])
+    assert "(490 more)" in str(error.value) and len(str(error.value)) < 300
+    assert "(490 more)" in str(record[0].message)
diff --git a/tests/client/core/test_utils.py b/tests/client/core/test_utils.py
index cf5c9f59..bed913d6 100644
--- a/tests/client/core/test_utils.py
+++ b/tests/client/core/test_utils.py
@@ -125,7 +125,9 @@ def test_id_validation():
     # For all new AlphaIDs, ensure these validate correctly
     assert all(
         isinstance(x, str) and AlphaID(x).string == x
-        for x in validate_ids([y + AlphaID._cut_point for y in range(max_num_idxs)])
+        for x in validate_ids(
+            [str(y + AlphaID._cut_point) for y in range(max_num_idxs)]
+        )
     )
 
 

From 93a391d843c77a57b462964014e2f6422132f605 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:16:18 -0700
Subject: [PATCH 25/30] linting

---
 mp_api/client/core/_external.py    | 61 ++++++++++++++++--------------
 tests/client/core/test_external.py |  8 ++--
 2 files changed, 38 insertions(+), 31 deletions(-)

diff --git a/mp_api/client/core/_external.py b/mp_api/client/core/_external.py
index f06db015..f949d56a 100644
--- a/mp_api/client/core/_external.py
+++ b/mp_api/client/core/_external.py
@@ -51,9 +51,7 @@
 if TYPE_CHECKING:
     from collections.abc import Callable, Iterator
     from types import FrameType
-    from typing import Any, TextIO, TypeVar
-
-    T = TypeVar("T")
+    from typing import Any, TextIO
 
 EXTERNAL_LOGGER = "mp_api.client.external"
 
@@ -95,7 +93,7 @@ def is_capturing() -> bool:
     return _depth.get() > 0
 
 
-def in_current_context(fn: Callable[..., T]) -> Callable[..., T]:
+def in_current_context[T](fn: Callable[..., T]) -> Callable[..., T]:
     """Wrap `fn` to run in a copy of the caller's context, e.g. in worker threads.
 
     Each call gets its own copy, so the wrapper can run in several threads at once.
@@ -163,7 +161,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
     return wrapper
 
 
-def track_calls(cls: type[T]) -> type[T]:
+def track_calls[C: type](cls: C) -> C:
     """Make the public methods `cls` defines run inside `capturing()`.
 
     Covers plain functions, static and class methods (not properties or other
@@ -236,7 +234,9 @@ def _location(filename: str, lineno: int) -> str:
     return f"{filename}:{lineno}"
 
 
-def _render_own(category: type[Warning], message: str, filename: str, lineno: int) -> bool:
+def _render_own(
+    category: type[Warning], message: str, filename: str, lineno: int
+) -> bool:
     """Show one of the client's warnings on the console, if it's interactive."""
     from mp_api.client.core._display import print_warning
 
@@ -285,24 +285,29 @@ def __call__(
         origin = _warning_origin()
         module = origin.f_globals.get("__name__", "") if origin else ""
         if module.split(".")[0] == "mp_api":
-            if file is None and self._prints_by_default() and _render_own(
-                category, str(message), filename, lineno
+            if (
+                file is None
+                and self._prints_by_default()
+                and _render_own(category, str(message), filename, lineno)
             ):
                 return
-        elif is_capturing() and not issubclass(category, _PASS_THROUGH_CATEGORIES):
-            if origin is not None:
-                text = str(message)
-                if category is not UserWarning:
-                    text = f"{category.__name__}: {text}"
-                _emit(
-                    module.split(".")[0] or "unknown",
-                    logging.WARNING,
-                    text,
-                    origin.f_code.co_filename,
-                    origin.f_lineno,
-                    origin.f_code.co_name,
-                )
-                return
+        elif (
+            origin is not None
+            and is_capturing()
+            and not issubclass(category, _PASS_THROUGH_CATEGORIES)
+        ):
+            text = str(message)
+            if category is not UserWarning:
+                text = f"{category.__name__}: {text}"
+            _emit(
+                module.split(".")[0] or "unknown",
+                logging.WARNING,
+                text,
+                origin.f_code.co_filename,
+                origin.f_lineno,
+                origin.f_code.co_name,
+            )
+            return
         self.previous(message, category, filename, lineno, file, line)
 
 
@@ -331,17 +336,17 @@ def emit(self, record: logging.LogRecord) -> None:  # pragma: no cover - unused
         self.handle(record)
 
 
-_install_lock = threading.Lock()
-_installed = False
+class _Installed:
+    lock = threading.Lock()
+    done = False
 
 
 def install() -> None:
     """Install the warnings hook and the logging fallback, once per process."""
-    global _installed
-    with _install_lock:
-        if _installed:
+    with _Installed.lock:
+        if _Installed.done:
             return
         warnings.showwarning = _WarningHook(warnings.showwarning)
         if logging.lastResort is not None:
             logging.lastResort = _LastResortHandler(logging.lastResort)
-        _installed = True
+        _Installed.done = True
diff --git a/tests/client/core/test_external.py b/tests/client/core/test_external.py
index f35e96b0..97b6e780 100644
--- a/tests/client/core/test_external.py
+++ b/tests/client/core/test_external.py
@@ -30,7 +30,7 @@
 )
 from mp_api.client.core.exceptions import MPRestWarning
 
-LIB_SOURCE = '''
+LIB_SOURCE = """
 import logging, warnings
 
 log = logging.getLogger("fakelib.core")
@@ -46,7 +46,7 @@ def runtime():
 
 def logs():
     log.warning("library log record")
-'''
+"""
 
 
 @pytest.fixture
@@ -563,7 +563,9 @@ def default_printer(monkeypatch):
         "_showwarnmsg_impl",
         lambda msg: printed.append(f"{msg.category.__name__}: {msg.message}"),
     )
-    monkeypatch.setattr(warnings, "showwarning", _WarningHook(warnings._showwarning_orig))
+    monkeypatch.setattr(
+        warnings, "showwarning", _WarningHook(warnings._showwarning_orig)
+    )
     return printed
 
 

From f72e2e191d1d9432fefa373b7894af5cd18d9c45 Mon Sep 17 00:00:00 2001
From: github-actions 
Date: Wed, 14 Oct 2026 21:31:33 +0000
Subject: [PATCH 26/30] auto dependency upgrades

---
 .../requirements-ubuntu-latest_py3.12.txt     | 29 +++----
 ...quirements-ubuntu-latest_py3.12_extras.txt | 79 ++++++++++---------
 .../requirements-ubuntu-latest_py3.13.txt     | 29 +++----
 ...quirements-ubuntu-latest_py3.13_extras.txt | 79 ++++++++++---------
 .../requirements-ubuntu-latest_py3.14.txt     | 29 +++----
 ...quirements-ubuntu-latest_py3.14_extras.txt | 79 ++++++++++---------
 6 files changed, 174 insertions(+), 150 deletions(-)

diff --git a/requirements/requirements-ubuntu-latest_py3.12.txt b/requirements/requirements-ubuntu-latest_py3.12.txt
index 8edf03c6..832613ae 100644
--- a/requirements/requirements-ubuntu-latest_py3.12.txt
+++ b/requirements/requirements-ubuntu-latest_py3.12.txt
@@ -6,15 +6,15 @@
 #
 annotated-types==0.8.0
     # via pydantic
-arro3-core==0.9.0
+arro3-core==0.9.1
     # via deltalake
 bibtexparser==1.4.4
     # via pymatgen-core
 blake3==1.0.11
     # via emmet-core
-boto3==1.43.110
+boto3==1.43.114
     # via mp-api (pyproject.toml)
-botocore==1.43.110
+botocore==1.43.114
     # via
     #   boto3
     #   s3transfer
@@ -32,9 +32,9 @@ deltalake==1.5.1
     # via mp-api (pyproject.toml)
 deprecated==3.0.0
     # via deltalake
-emmet-core==0.88.0rc0
+emmet-core==0.88.0rc3
     # via mp-api (pyproject.toml)
-fonttools==4.66.1
+fonttools==4.67.0
     # via matplotlib
 idna==3.20
     # via requests
@@ -63,15 +63,15 @@ monty==2026.7.16
     #   emmet-core
     #   mp-api (pyproject.toml)
     #   pymatgen-core
-more-itertools==11.1.0
+more-itertools==11.2.1
     # via inflect
 mpmath==1.3.0
     # via sympy
-narwhals==2.26.0
+narwhals==2.27.1
     # via plotly
 networkx==3.7
     # via pymatgen-core
-numpy==2.5.3
+numpy==2.5.4
     # via
     #   contourpy
     #   matplotlib
@@ -99,18 +99,18 @@ plotly==7.1.0
     # via pymatgen-core
 pubchempy==1.0.5
     # via emmet-core
-pyarrow==25.0.1
+pyarrow==26.0.0
     # via mp-api (pyproject.toml)
 pybtex==0.26.1
     # via emmet-core
-pydantic==2.14.0
+pydantic==2.14.1
     # via
     #   emmet-core
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.50.0
+pydantic-core==2.50.1
     # via pydantic
-pydantic-settings==2.15.0
+pydantic-settings==2.16.0
     # via
     #   emmet-core
     #   pymatgen-io-validation
@@ -155,7 +155,7 @@ scipy==1.18.1
     # via pymatgen-core
 six==1.17.0
     # via python-dateutil
-spglib==2.7.0
+spglib==2.8.0
     # via pymatgen-core
 sympy==1.14.0
     # via pymatgen-core
@@ -169,6 +169,7 @@ typing-extensions==4.16.0
     # via
     #   pydantic
     #   pydantic-core
+    #   pydantic-settings
     #   spglib
     #   typeguard
     #   typing-inspection
@@ -182,5 +183,5 @@ urllib3==2.8.0
     # via
     #   botocore
     #   requests
-wrapt==2.5.0
+wrapt==2.5.1
     # via deprecated
diff --git a/requirements/requirements-ubuntu-latest_py3.12_extras.txt b/requirements/requirements-ubuntu-latest_py3.12_extras.txt
index 70056458..ffbcf630 100644
--- a/requirements/requirements-ubuntu-latest_py3.12_extras.txt
+++ b/requirements/requirements-ubuntu-latest_py3.12_extras.txt
@@ -18,13 +18,13 @@ anyio==4.15.1
     #   sse-starlette
     #   starlette
     #   watchfiles
-arro3-core==0.9.0
+arro3-core==0.9.1
     # via deltalake
 arrow==1.4.0
     # via isoduration
 ase==3.29.0
     # via pymatgen-analysis-diffusion
-ast-serialize==0.12.1
+ast-serialize==0.13.0
     # via mypy
 asttokens==3.0.2
     # via stack-data
@@ -34,11 +34,11 @@ attrs==26.1.0
     #   jsonschema
     #   jsonschema-path
     #   referencing
-authlib==1.8.0
+authlib==1.9.1
     # via fastmcp-slim
 babel==2.18.0
     # via sphinx
-beartype==0.22.9
+beartype==0.23.1
     # via py-key-value-aio
 bibtexparser==1.4.4
     # via pymatgen-core
@@ -48,9 +48,9 @@ blinker==1.9.0
     # via flask
 boltons==26.2.0
     # via mp-api (pyproject.toml)
-boto3==1.43.110
+boto3==1.43.114
     # via mp-api (pyproject.toml)
-botocore==1.43.110
+botocore==1.43.114
     # via
     #   boto3
     #   s3transfer
@@ -90,7 +90,7 @@ cryptography==50.0.2
     #   joserfc
     #   pyjwt
     #   secretstorage
-custodian==2025.12.14
+custodian==2026.10.11
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -104,7 +104,7 @@ deprecated==3.0.0
     # via deltalake
 distlib==0.4.3
     # via virtualenv
-dnspython==2.8.0
+dnspython==2.9.0
     # via
     #   email-validator
     #   pyisemail
@@ -115,19 +115,19 @@ docutils==0.22.4
     # via sphinx
 email-validator==2.3.0
     # via pydantic
-emmet-core[all]==0.88.0rc0
+emmet-core[all]==0.88.0rc3
     # via mp-api (pyproject.toml)
 exceptiongroup==1.3.1
     # via fastmcp-slim
 execnet==2.1.2
     # via pytest-xdist
-executing==2.2.1
+executing==2.3.0
     # via stack-data
 fastmcp==4.1.0
     # via mp-api (pyproject.toml)
 fastmcp-slim[client,server]==4.1.0
     # via fastmcp
-filelock==4.0.12
+filelock==4.2.0
     # via
     #   python-discovery
     #   virtualenv
@@ -139,7 +139,7 @@ flexcache==0.3
     # via pint
 flexparser==0.4
     # via pint
-fonttools==4.66.1
+fonttools==4.67.0
     # via matplotlib
 fqdn==1.6.0
     # via jsonschema
@@ -194,7 +194,7 @@ jaraco-context==6.1.2
     # via keyring
 jaraco-functools==4.6.0
     # via keyring
-jedi==0.20.0
+jedi==0.20.1
     # via ipython
 jeepney==0.9.0
     # via
@@ -218,7 +218,7 @@ joserfc==1.7.5
     #   fastmcp-slim
 json2html==1.3.0
     # via mp-api (pyproject.toml)
-jsonpointer==3.2.0
+jsonpointer==3.2.1
     # via jsonschema
 jsonref==1.1.0
     # via
@@ -264,6 +264,7 @@ matplotlib==3.11.2
     # via
     #   ase
     #   phonopy
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   seaborn
 matplotlib-inline==0.2.2
@@ -283,8 +284,9 @@ monty==2026.7.16
     #   custodian
     #   emmet-core
     #   mp-api (pyproject.toml)
+    #   pymatgen-analysis-defects
     #   pymatgen-core
-more-itertools==11.1.0
+more-itertools==11.2.1
     # via
     #   inflect
     #   jaraco-classes
@@ -301,7 +303,7 @@ mypy==2.4.0
     # via mp-api (pyproject.toml)
 mypy-extensions==1.1.0
     # via mypy
-narwhals==2.26.0
+narwhals==2.27.1
     # via plotly
 networkx==3.7
     # via
@@ -309,7 +311,7 @@ networkx==3.7
     #   scikit-image
 nodeenv==1.11.0
     # via pre-commit
-numpy==2.5.3
+numpy==2.5.4
     # via
     #   ase
     #   contourpy
@@ -359,6 +361,7 @@ palettable==3.3.3
     # via pymatgen-core
 pandas==3.0.6
     # via
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   seaborn
 parso==0.8.7
@@ -369,7 +372,7 @@ pathspec==1.1.1
     # via mypy
 pexpect==4.9.0
     # via ipython
-phonopy==4.8.1
+phonopy==4.8.3
     # via emmet-core
 phonors==0.6.0
     # via phonopy
@@ -380,18 +383,20 @@ pillow==12.3.0
     #   scikit-image
 pint==0.26.1
     # via mp-api (pyproject.toml)
-platformdirs==4.12.4
+platformdirs==4.13.0
     # via
     #   fastmcp-slim
     #   pint
     #   virtualenv
 plotly==7.1.0
-    # via pymatgen-core
+    # via
+    #   pymatgen-analysis-defects
+    #   pymatgen-core
 pluggy==1.6.0
     # via
     #   pytest
     #   pytest-cov
-pre-commit==4.6.2
+pre-commit==4.7.0
     # via mp-api (pyproject.toml)
 prompt-toolkit==3.0.53
     # via ipython
@@ -407,7 +412,7 @@ pure-eval==0.2.4
     # via stack-data
 py-key-value-aio[filetree,keyring,memory]==0.4.6
     # via fastmcp-slim
-pyarrow==25.0.1
+pyarrow==26.0.0
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -415,9 +420,9 @@ pybtex==0.26.1
     # via emmet-core
 pycodcif==3.0.1
     # via emmet-core
-pycparser==3.1
+pycparser==3.11
     # via cffi
-pydantic[email]==2.14.0
+pydantic[email]==2.14.1
     # via
     #   emmet-core
     #   fastmcp-slim
@@ -427,9 +432,9 @@ pydantic[email]==2.14.0
     #   optimade
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.50.0
+pydantic-core==2.50.1
     # via pydantic
-pydantic-settings==2.15.0
+pydantic-settings==2.16.0
     # via
     #   emmet-core
     #   fastmcp-slim
@@ -454,11 +459,10 @@ pymatgen==2026.9.24
     #   mp-api (pyproject.toml)
     #   mp-pyrho
     #   pymatgen-analysis-alloys
-    #   pymatgen-analysis-defects
     #   pymatgen-analysis-diffusion
 pymatgen-analysis-alloys==0.0.9
     # via emmet-core
-pymatgen-analysis-defects==2026.3.20
+pymatgen-analysis-defects==2026.10.14
     # via emmet-core
 pymatgen-analysis-diffusion==2026.9.23
     # via emmet-core
@@ -466,10 +470,11 @@ pymatgen-core==2026.10.2
     # via
     #   emmet-core
     #   pymatgen
+    #   pymatgen-analysis-defects
     #   pymatgen-io-validation
 pymatgen-io-validation==0.1.4
     # via emmet-core
-pymongo==4.18.3
+pymongo==4.19.0
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -502,7 +507,7 @@ python-dateutil==2.9.0.post0
     #   bravado-core
     #   matplotlib
     #   pandas
-python-discovery==1.6.1
+python-discovery==1.6.2
     # via virtualenv
 python-dotenv==1.2.4
     # via
@@ -573,6 +578,7 @@ scikit-image==0.26.0
 scipy==1.18.1
     # via
     #   ase
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   scikit-image
     #   symfc
@@ -596,7 +602,7 @@ six==1.17.0
     #   rfc3339-validator
 snowballstemmer==3.1.1
     # via sphinx
-spglib==2.7.0
+spglib==2.8.0
     # via
     #   phonopy
     #   pymatgen-core
@@ -620,7 +626,7 @@ sse-starlette==3.5.0
     # via mcp
 stack-data==0.6.3
     # via ipython
-starlette==1.7.0
+starlette==1.8.0
     # via
     #   fastmcp-slim
     #   mcp
@@ -644,7 +650,7 @@ traitlets==5.16.1
     #   ipython
     #   ipywidgets
     #   matplotlib-inline
-truststore==0.10.4
+truststore==0.10.5
     # via
     #   httpcore2
     #   httpx2
@@ -672,6 +678,7 @@ typing-extensions==4.16.0
     #   py-key-value-aio
     #   pydantic
     #   pydantic-core
+    #   pydantic-settings
     #   pytest-asyncio
     #   referencing
     #   spglib
@@ -686,7 +693,7 @@ typing-inspection==0.4.4
     #   pydantic-settings
 tzdata==2026.5
     # via arrow
-uncalled-for==0.4.0
+uncalled-for==0.4.1
     # via fastmcp-slim
 uncertainties==3.2.3
     # via pymatgen-core
@@ -701,7 +708,7 @@ uvicorn==0.54.0
     # via
     #   fastmcp-slim
     #   mcp
-virtualenv==21.14.6
+virtualenv==21.14.7
     # via pre-commit
 watchfiles==1.3.0
     # via fastmcp-slim
@@ -715,5 +722,5 @@ werkzeug==3.1.9
     # via flask
 widgetsnbextension==4.0.16
     # via ipywidgets
-wrapt==2.5.0
+wrapt==2.5.1
     # via deprecated
diff --git a/requirements/requirements-ubuntu-latest_py3.13.txt b/requirements/requirements-ubuntu-latest_py3.13.txt
index 89917b68..d47cfcaa 100644
--- a/requirements/requirements-ubuntu-latest_py3.13.txt
+++ b/requirements/requirements-ubuntu-latest_py3.13.txt
@@ -6,15 +6,15 @@
 #
 annotated-types==0.8.0
     # via pydantic
-arro3-core==0.9.0
+arro3-core==0.9.1
     # via deltalake
 bibtexparser==1.4.4
     # via pymatgen-core
 blake3==1.0.11
     # via emmet-core
-boto3==1.43.110
+boto3==1.43.114
     # via mp-api (pyproject.toml)
-botocore==1.43.110
+botocore==1.43.114
     # via
     #   boto3
     #   s3transfer
@@ -32,9 +32,9 @@ deltalake==1.5.1
     # via mp-api (pyproject.toml)
 deprecated==3.0.0
     # via deltalake
-emmet-core==0.88.0rc0
+emmet-core==0.88.0rc3
     # via mp-api (pyproject.toml)
-fonttools==4.66.1
+fonttools==4.67.0
     # via matplotlib
 idna==3.20
     # via requests
@@ -63,15 +63,15 @@ monty==2026.7.16
     #   emmet-core
     #   mp-api (pyproject.toml)
     #   pymatgen-core
-more-itertools==11.1.0
+more-itertools==11.2.1
     # via inflect
 mpmath==1.3.0
     # via sympy
-narwhals==2.26.0
+narwhals==2.27.1
     # via plotly
 networkx==3.7
     # via pymatgen-core
-numpy==2.5.3
+numpy==2.5.4
     # via
     #   contourpy
     #   matplotlib
@@ -99,18 +99,18 @@ plotly==7.1.0
     # via pymatgen-core
 pubchempy==1.0.5
     # via emmet-core
-pyarrow==25.0.1
+pyarrow==26.0.0
     # via mp-api (pyproject.toml)
 pybtex==0.26.1
     # via emmet-core
-pydantic==2.14.0
+pydantic==2.14.1
     # via
     #   emmet-core
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.50.0
+pydantic-core==2.50.1
     # via pydantic
-pydantic-settings==2.15.0
+pydantic-settings==2.16.0
     # via
     #   emmet-core
     #   pymatgen-io-validation
@@ -155,7 +155,7 @@ scipy==1.18.1
     # via pymatgen-core
 six==1.17.0
     # via python-dateutil
-spglib==2.7.0
+spglib==2.8.0
     # via pymatgen-core
 sympy==1.14.0
     # via pymatgen-core
@@ -169,6 +169,7 @@ typing-extensions==4.16.0
     # via
     #   pydantic
     #   pydantic-core
+    #   pydantic-settings
     #   typeguard
     #   typing-inspection
 typing-inspection==0.4.4
@@ -181,5 +182,5 @@ urllib3==2.8.0
     # via
     #   botocore
     #   requests
-wrapt==2.5.0
+wrapt==2.5.1
     # via deprecated
diff --git a/requirements/requirements-ubuntu-latest_py3.13_extras.txt b/requirements/requirements-ubuntu-latest_py3.13_extras.txt
index b5445d18..155b9b47 100644
--- a/requirements/requirements-ubuntu-latest_py3.13_extras.txt
+++ b/requirements/requirements-ubuntu-latest_py3.13_extras.txt
@@ -18,13 +18,13 @@ anyio==4.15.1
     #   sse-starlette
     #   starlette
     #   watchfiles
-arro3-core==0.9.0
+arro3-core==0.9.1
     # via deltalake
 arrow==1.4.0
     # via isoduration
 ase==3.29.0
     # via pymatgen-analysis-diffusion
-ast-serialize==0.12.1
+ast-serialize==0.13.0
     # via mypy
 asttokens==3.0.2
     # via stack-data
@@ -34,11 +34,11 @@ attrs==26.1.0
     #   jsonschema
     #   jsonschema-path
     #   referencing
-authlib==1.8.0
+authlib==1.9.1
     # via fastmcp-slim
 babel==2.18.0
     # via sphinx
-beartype==0.22.9
+beartype==0.23.1
     # via py-key-value-aio
 bibtexparser==1.4.4
     # via pymatgen-core
@@ -48,9 +48,9 @@ blinker==1.9.0
     # via flask
 boltons==26.2.0
     # via mp-api (pyproject.toml)
-boto3==1.43.110
+boto3==1.43.114
     # via mp-api (pyproject.toml)
-botocore==1.43.110
+botocore==1.43.114
     # via
     #   boto3
     #   s3transfer
@@ -90,7 +90,7 @@ cryptography==50.0.2
     #   joserfc
     #   pyjwt
     #   secretstorage
-custodian==2025.12.14
+custodian==2026.10.11
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -104,7 +104,7 @@ deprecated==3.0.0
     # via deltalake
 distlib==0.4.3
     # via virtualenv
-dnspython==2.8.0
+dnspython==2.9.0
     # via
     #   email-validator
     #   pyisemail
@@ -115,19 +115,19 @@ docutils==0.22.4
     # via sphinx
 email-validator==2.3.0
     # via pydantic
-emmet-core[all]==0.88.0rc0
+emmet-core[all]==0.88.0rc3
     # via mp-api (pyproject.toml)
 exceptiongroup==1.3.1
     # via fastmcp-slim
 execnet==2.1.2
     # via pytest-xdist
-executing==2.2.1
+executing==2.3.0
     # via stack-data
 fastmcp==4.1.0
     # via mp-api (pyproject.toml)
 fastmcp-slim[client,server]==4.1.0
     # via fastmcp
-filelock==4.0.12
+filelock==4.2.0
     # via
     #   python-discovery
     #   virtualenv
@@ -139,7 +139,7 @@ flexcache==0.3
     # via pint
 flexparser==0.4
     # via pint
-fonttools==4.66.1
+fonttools==4.67.0
     # via matplotlib
 fqdn==1.6.0
     # via jsonschema
@@ -194,7 +194,7 @@ jaraco-context==6.1.2
     # via keyring
 jaraco-functools==4.6.0
     # via keyring
-jedi==0.20.0
+jedi==0.20.1
     # via ipython
 jeepney==0.9.0
     # via
@@ -218,7 +218,7 @@ joserfc==1.7.5
     #   fastmcp-slim
 json2html==1.3.0
     # via mp-api (pyproject.toml)
-jsonpointer==3.2.0
+jsonpointer==3.2.1
     # via jsonschema
 jsonref==1.1.0
     # via
@@ -264,6 +264,7 @@ matplotlib==3.11.2
     # via
     #   ase
     #   phonopy
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   seaborn
 matplotlib-inline==0.2.2
@@ -283,8 +284,9 @@ monty==2026.7.16
     #   custodian
     #   emmet-core
     #   mp-api (pyproject.toml)
+    #   pymatgen-analysis-defects
     #   pymatgen-core
-more-itertools==11.1.0
+more-itertools==11.2.1
     # via
     #   inflect
     #   jaraco-classes
@@ -301,7 +303,7 @@ mypy==2.4.0
     # via mp-api (pyproject.toml)
 mypy-extensions==1.1.0
     # via mypy
-narwhals==2.26.0
+narwhals==2.27.1
     # via plotly
 networkx==3.7
     # via
@@ -309,7 +311,7 @@ networkx==3.7
     #   scikit-image
 nodeenv==1.11.0
     # via pre-commit
-numpy==2.5.3
+numpy==2.5.4
     # via
     #   ase
     #   contourpy
@@ -359,6 +361,7 @@ palettable==3.3.3
     # via pymatgen-core
 pandas==3.0.6
     # via
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   seaborn
 parso==0.8.7
@@ -369,7 +372,7 @@ pathspec==1.1.1
     # via mypy
 pexpect==4.9.0
     # via ipython
-phonopy==4.8.1
+phonopy==4.8.3
     # via emmet-core
 phonors==0.6.0
     # via phonopy
@@ -380,18 +383,20 @@ pillow==12.3.0
     #   scikit-image
 pint==0.26.1
     # via mp-api (pyproject.toml)
-platformdirs==4.12.4
+platformdirs==4.13.0
     # via
     #   fastmcp-slim
     #   pint
     #   virtualenv
 plotly==7.1.0
-    # via pymatgen-core
+    # via
+    #   pymatgen-analysis-defects
+    #   pymatgen-core
 pluggy==1.6.0
     # via
     #   pytest
     #   pytest-cov
-pre-commit==4.6.2
+pre-commit==4.7.0
     # via mp-api (pyproject.toml)
 prompt-toolkit==3.0.53
     # via ipython
@@ -407,7 +412,7 @@ pure-eval==0.2.4
     # via stack-data
 py-key-value-aio[filetree,keyring,memory]==0.4.6
     # via fastmcp-slim
-pyarrow==25.0.1
+pyarrow==26.0.0
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -415,9 +420,9 @@ pybtex==0.26.1
     # via emmet-core
 pycodcif==3.0.1
     # via emmet-core
-pycparser==3.1
+pycparser==3.11
     # via cffi
-pydantic[email]==2.14.0
+pydantic[email]==2.14.1
     # via
     #   emmet-core
     #   fastmcp-slim
@@ -427,9 +432,9 @@ pydantic[email]==2.14.0
     #   optimade
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.50.0
+pydantic-core==2.50.1
     # via pydantic
-pydantic-settings==2.15.0
+pydantic-settings==2.16.0
     # via
     #   emmet-core
     #   fastmcp-slim
@@ -454,11 +459,10 @@ pymatgen==2026.9.24
     #   mp-api (pyproject.toml)
     #   mp-pyrho
     #   pymatgen-analysis-alloys
-    #   pymatgen-analysis-defects
     #   pymatgen-analysis-diffusion
 pymatgen-analysis-alloys==0.0.9
     # via emmet-core
-pymatgen-analysis-defects==2026.3.20
+pymatgen-analysis-defects==2026.10.14
     # via emmet-core
 pymatgen-analysis-diffusion==2026.9.23
     # via emmet-core
@@ -466,10 +470,11 @@ pymatgen-core==2026.10.2
     # via
     #   emmet-core
     #   pymatgen
+    #   pymatgen-analysis-defects
     #   pymatgen-io-validation
 pymatgen-io-validation==0.1.4
     # via emmet-core
-pymongo==4.18.3
+pymongo==4.19.0
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -502,7 +507,7 @@ python-dateutil==2.9.0.post0
     #   bravado-core
     #   matplotlib
     #   pandas
-python-discovery==1.6.1
+python-discovery==1.6.2
     # via virtualenv
 python-dotenv==1.2.4
     # via
@@ -573,6 +578,7 @@ scikit-image==0.26.0
 scipy==1.18.1
     # via
     #   ase
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   scikit-image
     #   symfc
@@ -596,7 +602,7 @@ six==1.17.0
     #   rfc3339-validator
 snowballstemmer==3.1.1
     # via sphinx
-spglib==2.7.0
+spglib==2.8.0
     # via
     #   phonopy
     #   pymatgen-core
@@ -620,7 +626,7 @@ sse-starlette==3.5.0
     # via mcp
 stack-data==0.6.3
     # via ipython
-starlette==1.7.0
+starlette==1.8.0
     # via
     #   fastmcp-slim
     #   mcp
@@ -644,7 +650,7 @@ traitlets==5.16.1
     #   ipython
     #   ipywidgets
     #   matplotlib-inline
-truststore==0.10.4
+truststore==0.10.5
     # via
     #   httpcore2
     #   httpx2
@@ -670,6 +676,7 @@ typing-extensions==4.16.0
     #   py-key-value-aio
     #   pydantic
     #   pydantic-core
+    #   pydantic-settings
     #   swagger-spec-validator
     #   typeguard
     #   typing-inspection
@@ -680,7 +687,7 @@ typing-inspection==0.4.4
     #   pydantic-settings
 tzdata==2026.5
     # via arrow
-uncalled-for==0.4.0
+uncalled-for==0.4.1
     # via fastmcp-slim
 uncertainties==3.2.3
     # via pymatgen-core
@@ -695,7 +702,7 @@ uvicorn==0.54.0
     # via
     #   fastmcp-slim
     #   mcp
-virtualenv==21.14.6
+virtualenv==21.14.7
     # via pre-commit
 watchfiles==1.3.0
     # via fastmcp-slim
@@ -709,5 +716,5 @@ werkzeug==3.1.9
     # via flask
 widgetsnbextension==4.0.16
     # via ipywidgets
-wrapt==2.5.0
+wrapt==2.5.1
     # via deprecated
diff --git a/requirements/requirements-ubuntu-latest_py3.14.txt b/requirements/requirements-ubuntu-latest_py3.14.txt
index 67976bb5..2759e1c8 100644
--- a/requirements/requirements-ubuntu-latest_py3.14.txt
+++ b/requirements/requirements-ubuntu-latest_py3.14.txt
@@ -6,15 +6,15 @@
 #
 annotated-types==0.8.0
     # via pydantic
-arro3-core==0.9.0
+arro3-core==0.9.1
     # via deltalake
 bibtexparser==1.4.4
     # via pymatgen-core
 blake3==1.0.11
     # via emmet-core
-boto3==1.43.110
+boto3==1.43.114
     # via mp-api (pyproject.toml)
-botocore==1.43.110
+botocore==1.43.114
     # via
     #   boto3
     #   s3transfer
@@ -32,9 +32,9 @@ deltalake==1.5.1
     # via mp-api (pyproject.toml)
 deprecated==3.0.0
     # via deltalake
-emmet-core==0.88.0rc0
+emmet-core==0.88.0rc3
     # via mp-api (pyproject.toml)
-fonttools==4.66.1
+fonttools==4.67.0
     # via matplotlib
 idna==3.20
     # via requests
@@ -63,15 +63,15 @@ monty==2026.7.16
     #   emmet-core
     #   mp-api (pyproject.toml)
     #   pymatgen-core
-more-itertools==11.1.0
+more-itertools==11.2.1
     # via inflect
 mpmath==1.3.0
     # via sympy
-narwhals==2.26.0
+narwhals==2.27.1
     # via plotly
 networkx==3.7
     # via pymatgen-core
-numpy==2.5.3
+numpy==2.5.4
     # via
     #   contourpy
     #   matplotlib
@@ -99,18 +99,18 @@ plotly==7.1.0
     # via pymatgen-core
 pubchempy==1.0.5
     # via emmet-core
-pyarrow==25.0.1
+pyarrow==26.0.0
     # via mp-api (pyproject.toml)
 pybtex==0.26.1
     # via emmet-core
-pydantic==2.14.0
+pydantic==2.14.1
     # via
     #   emmet-core
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.50.0
+pydantic-core==2.50.1
     # via pydantic
-pydantic-settings==2.15.0
+pydantic-settings==2.16.0
     # via
     #   emmet-core
     #   pymatgen-io-validation
@@ -155,7 +155,7 @@ scipy==1.18.1
     # via pymatgen-core
 six==1.17.0
     # via python-dateutil
-spglib==2.7.0
+spglib==2.8.0
     # via pymatgen-core
 sympy==1.14.0
     # via pymatgen-core
@@ -169,6 +169,7 @@ typing-extensions==4.16.0
     # via
     #   pydantic
     #   pydantic-core
+    #   pydantic-settings
     #   typeguard
     #   typing-inspection
 typing-inspection==0.4.4
@@ -181,5 +182,5 @@ urllib3==2.8.0
     # via
     #   botocore
     #   requests
-wrapt==2.5.0
+wrapt==2.5.1
     # via deprecated
diff --git a/requirements/requirements-ubuntu-latest_py3.14_extras.txt b/requirements/requirements-ubuntu-latest_py3.14_extras.txt
index a349212f..88e6f1ff 100644
--- a/requirements/requirements-ubuntu-latest_py3.14_extras.txt
+++ b/requirements/requirements-ubuntu-latest_py3.14_extras.txt
@@ -18,13 +18,13 @@ anyio==4.15.1
     #   sse-starlette
     #   starlette
     #   watchfiles
-arro3-core==0.9.0
+arro3-core==0.9.1
     # via deltalake
 arrow==1.4.0
     # via isoduration
 ase==3.29.0
     # via pymatgen-analysis-diffusion
-ast-serialize==0.12.1
+ast-serialize==0.13.0
     # via mypy
 asttokens==3.0.2
     # via stack-data
@@ -34,11 +34,11 @@ attrs==26.1.0
     #   jsonschema
     #   jsonschema-path
     #   referencing
-authlib==1.8.0
+authlib==1.9.1
     # via fastmcp-slim
 babel==2.18.0
     # via sphinx
-beartype==0.22.9
+beartype==0.23.1
     # via py-key-value-aio
 bibtexparser==1.4.4
     # via pymatgen-core
@@ -48,9 +48,9 @@ blinker==1.9.0
     # via flask
 boltons==26.2.0
     # via mp-api (pyproject.toml)
-boto3==1.43.110
+boto3==1.43.114
     # via mp-api (pyproject.toml)
-botocore==1.43.110
+botocore==1.43.114
     # via
     #   boto3
     #   s3transfer
@@ -90,7 +90,7 @@ cryptography==50.0.2
     #   joserfc
     #   pyjwt
     #   secretstorage
-custodian==2025.12.14
+custodian==2026.10.11
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -104,7 +104,7 @@ deprecated==3.0.0
     # via deltalake
 distlib==0.4.3
     # via virtualenv
-dnspython==2.8.0
+dnspython==2.9.0
     # via
     #   email-validator
     #   pyisemail
@@ -115,19 +115,19 @@ docutils==0.22.4
     # via sphinx
 email-validator==2.3.0
     # via pydantic
-emmet-core[all]==0.88.0rc0
+emmet-core[all]==0.88.0rc3
     # via mp-api (pyproject.toml)
 exceptiongroup==1.3.1
     # via fastmcp-slim
 execnet==2.1.2
     # via pytest-xdist
-executing==2.2.1
+executing==2.3.0
     # via stack-data
 fastmcp==4.1.0
     # via mp-api (pyproject.toml)
 fastmcp-slim[client,server]==4.1.0
     # via fastmcp
-filelock==4.0.12
+filelock==4.2.0
     # via
     #   python-discovery
     #   virtualenv
@@ -139,7 +139,7 @@ flexcache==0.3
     # via pint
 flexparser==0.4
     # via pint
-fonttools==4.66.1
+fonttools==4.67.0
     # via matplotlib
 fqdn==1.6.0
     # via jsonschema
@@ -194,7 +194,7 @@ jaraco-context==6.1.2
     # via keyring
 jaraco-functools==4.6.0
     # via keyring
-jedi==0.20.0
+jedi==0.20.1
     # via ipython
 jeepney==0.9.0
     # via
@@ -218,7 +218,7 @@ joserfc==1.7.5
     #   fastmcp-slim
 json2html==1.3.0
     # via mp-api (pyproject.toml)
-jsonpointer==3.2.0
+jsonpointer==3.2.1
     # via jsonschema
 jsonref==1.1.0
     # via
@@ -264,6 +264,7 @@ matplotlib==3.11.2
     # via
     #   ase
     #   phonopy
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   seaborn
 matplotlib-inline==0.2.2
@@ -283,8 +284,9 @@ monty==2026.7.16
     #   custodian
     #   emmet-core
     #   mp-api (pyproject.toml)
+    #   pymatgen-analysis-defects
     #   pymatgen-core
-more-itertools==11.1.0
+more-itertools==11.2.1
     # via
     #   inflect
     #   jaraco-classes
@@ -301,7 +303,7 @@ mypy==2.4.0
     # via mp-api (pyproject.toml)
 mypy-extensions==1.1.0
     # via mypy
-narwhals==2.26.0
+narwhals==2.27.1
     # via plotly
 networkx==3.7
     # via
@@ -309,7 +311,7 @@ networkx==3.7
     #   scikit-image
 nodeenv==1.11.0
     # via pre-commit
-numpy==2.5.3
+numpy==2.5.4
     # via
     #   ase
     #   contourpy
@@ -359,6 +361,7 @@ palettable==3.3.3
     # via pymatgen-core
 pandas==3.0.6
     # via
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   seaborn
 parso==0.8.7
@@ -369,7 +372,7 @@ pathspec==1.1.1
     # via mypy
 pexpect==4.9.0
     # via ipython
-phonopy==4.8.1
+phonopy==4.8.3
     # via emmet-core
 phonors==0.6.0
     # via phonopy
@@ -380,18 +383,20 @@ pillow==12.3.0
     #   scikit-image
 pint==0.26.1
     # via mp-api (pyproject.toml)
-platformdirs==4.12.4
+platformdirs==4.13.0
     # via
     #   fastmcp-slim
     #   pint
     #   virtualenv
 plotly==7.1.0
-    # via pymatgen-core
+    # via
+    #   pymatgen-analysis-defects
+    #   pymatgen-core
 pluggy==1.6.0
     # via
     #   pytest
     #   pytest-cov
-pre-commit==4.6.2
+pre-commit==4.7.0
     # via mp-api (pyproject.toml)
 prompt-toolkit==3.0.53
     # via ipython
@@ -407,7 +412,7 @@ pure-eval==0.2.4
     # via stack-data
 py-key-value-aio[filetree,keyring,memory]==0.4.6
     # via fastmcp-slim
-pyarrow==25.0.1
+pyarrow==26.0.0
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -415,9 +420,9 @@ pybtex==0.26.1
     # via emmet-core
 pycodcif==3.0.1
     # via emmet-core
-pycparser==3.1
+pycparser==3.11
     # via cffi
-pydantic[email]==2.14.0
+pydantic[email]==2.14.1
     # via
     #   emmet-core
     #   fastmcp-slim
@@ -427,9 +432,9 @@ pydantic[email]==2.14.0
     #   optimade
     #   pydantic-settings
     #   pymatgen-io-validation
-pydantic-core==2.50.0
+pydantic-core==2.50.1
     # via pydantic
-pydantic-settings==2.15.0
+pydantic-settings==2.16.0
     # via
     #   emmet-core
     #   fastmcp-slim
@@ -454,11 +459,10 @@ pymatgen==2026.9.24
     #   mp-api (pyproject.toml)
     #   mp-pyrho
     #   pymatgen-analysis-alloys
-    #   pymatgen-analysis-defects
     #   pymatgen-analysis-diffusion
 pymatgen-analysis-alloys==0.0.9
     # via emmet-core
-pymatgen-analysis-defects==2026.3.20
+pymatgen-analysis-defects==2026.10.14
     # via emmet-core
 pymatgen-analysis-diffusion==2026.9.23
     # via emmet-core
@@ -466,10 +470,11 @@ pymatgen-core==2026.10.2
     # via
     #   emmet-core
     #   pymatgen
+    #   pymatgen-analysis-defects
     #   pymatgen-io-validation
 pymatgen-io-validation==0.1.4
     # via emmet-core
-pymongo==4.18.3
+pymongo==4.19.0
     # via
     #   emmet-core
     #   mp-api (pyproject.toml)
@@ -502,7 +507,7 @@ python-dateutil==2.9.0.post0
     #   bravado-core
     #   matplotlib
     #   pandas
-python-discovery==1.6.1
+python-discovery==1.6.2
     # via virtualenv
 python-dotenv==1.2.4
     # via
@@ -573,6 +578,7 @@ scikit-image==0.26.0
 scipy==1.18.1
     # via
     #   ase
+    #   pymatgen-analysis-defects
     #   pymatgen-core
     #   scikit-image
     #   symfc
@@ -596,7 +602,7 @@ six==1.17.0
     #   rfc3339-validator
 snowballstemmer==3.1.1
     # via sphinx
-spglib==2.7.0
+spglib==2.8.0
     # via
     #   phonopy
     #   pymatgen-core
@@ -620,7 +626,7 @@ sse-starlette==3.5.0
     # via mcp
 stack-data==0.6.3
     # via ipython
-starlette==1.7.0
+starlette==1.8.0
     # via
     #   fastmcp-slim
     #   mcp
@@ -644,7 +650,7 @@ traitlets==5.16.1
     #   ipython
     #   ipywidgets
     #   matplotlib-inline
-truststore==0.10.4
+truststore==0.10.5
     # via
     #   httpcore2
     #   httpx2
@@ -670,6 +676,7 @@ typing-extensions==4.16.0
     #   py-key-value-aio
     #   pydantic
     #   pydantic-core
+    #   pydantic-settings
     #   swagger-spec-validator
     #   typeguard
     #   typing-inspection
@@ -680,7 +687,7 @@ typing-inspection==0.4.4
     #   pydantic-settings
 tzdata==2026.5
     # via arrow
-uncalled-for==0.4.0
+uncalled-for==0.4.1
     # via fastmcp-slim
 uncertainties==3.2.3
     # via pymatgen-core
@@ -695,7 +702,7 @@ uvicorn==0.54.0
     # via
     #   fastmcp-slim
     #   mcp
-virtualenv==21.14.6
+virtualenv==21.14.7
     # via pre-commit
 watchfiles==1.3.0
     # via fastmcp-slim
@@ -709,5 +716,5 @@ werkzeug==3.1.9
     # via flask
 widgetsnbextension==4.0.16
     # via ipywidgets
-wrapt==2.5.0
+wrapt==2.5.1
     # via deprecated

From 26b960ff92e6e3c90b88c341d7bfb8aa407bea64 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Fri, 16 Oct 2026 16:40:30 -0700
Subject: [PATCH 27/30] bump emmet-core into stable segment of next minor ver

---
 pyproject.toml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index c17d5365..15342ee1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@ dependencies = [
   "pymatgen>2024.2.20",
   "requests>=2.23.0",
   "monty>=2024.12.10",
-  "emmet-core>=0.88.0rc3,<0.88.1",
+  "emmet-core>=0.88.0",
   "boto3",
   "orjson >= 3.10,<4",
   "pyarrow >= 20.0.0",
@@ -70,7 +70,7 @@ contribs = [
 ]
 all = [
   "custodian",
-  "emmet-core[all]>=0.88.0rc,<0.88.1",
+  "emmet-core[all]>=0.88.0",
   "fastmcp",
   "flask",
   "ipywidgets",

From ea814077e8f50ccff0da746c52e5057a78d713ae Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Mon, 19 Oct 2026 11:31:21 -0700
Subject: [PATCH 28/30] fix ordering of entries during comparisions for testing
 get_entries

---
 tests/client/test_mprester.py | 48 +++++++++++++++++++++++------------
 1 file changed, 32 insertions(+), 16 deletions(-)

diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py
index 255797e5..10e32560 100644
--- a/tests/client/test_mprester.py
+++ b/tests/client/test_mprester.py
@@ -241,24 +241,40 @@ def test_get_entries(self, mpr):
 
         assert all(e.data.get("energy_above_hull", None) is not None for e in entries)
 
-        # Conventional structure
-        as_conv = mpr.get_entry_by_material_id(
-            thermo_docs[3].material_id, conventional_unit_cell=True
-        )
-        assert all(e.structure == e.structure.to_conventional() for e in as_conv)
+        # Conventional structure, compared one thermo type at a time: entries from
+        # different thermo types can share an ``entry_id`` (e.g. the uncorrected
+        # ``R2SCAN`` entry and the mixed ``GGA_GGA+U_R2SCAN`` entry of the same
+        # material), so ``entry_id`` only identifies an entry within one thermo type
+        for thermo_type in (
+            ThermoType.GGA_GGA_U,
+            ThermoType.GGA_GGA_U_R2SCAN,
+            ThermoType.R2SCAN,
+        ):
+            criteria = {"thermo_types": [thermo_type.value]}
+            as_conv = mpr.get_entry_by_material_id(
+                thermo_docs[3].material_id,
+                conventional_unit_cell=True,
+                additional_criteria=criteria,
+            )
+            assert all(e.structure == e.structure.to_conventional() for e in as_conv)
 
-        # Ensure energy per atom is same
-        non_standardized = mpr.get_entry_by_material_id(
-            thermo_docs[3].material_id, conventional_unit_cell=False
-        )
-        for e in as_conv:
-            ref = next(f for f in non_standardized if f.entry_id == e.entry_id)
-            assert e.uncorrected_energy_per_atom == pytest.approx(
-                ref.uncorrected_energy_per_atom
+            # Ensure energy per atom is same
+            non_standardized = mpr.get_entry_by_material_id(
+                thermo_docs[3].material_id,
+                conventional_unit_cell=False,
+                additional_criteria=criteria,
+            )
+            assert len({e.entry_id for e in non_standardized}) == len(
+                non_standardized
             )
-            # corrected too: every adjustment must scale with the cell, including
-            # extensive ones with no ``n_atoms``, e.g. the r2SCAN mixing correction
-            assert e.energy_per_atom == pytest.approx(ref.energy_per_atom)
+            for e in as_conv:
+                ref = next(f for f in non_standardized if f.entry_id == e.entry_id)
+                assert e.uncorrected_energy_per_atom == pytest.approx(
+                    ref.uncorrected_energy_per_atom
+                )
+                # corrected too: every adjustment must scale with the cell, including
+                # extensive ones with no ``n_atoms``, e.g. the r2SCAN mixing correction
+                assert e.energy_per_atom == pytest.approx(ref.energy_per_atom)
 
         # Additional criteria
         entry = mpr.get_entries(

From eb7da779dfaeb3d7e1c1894d8952faadb79d219c Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Mon, 19 Oct 2026 11:32:00 -0700
Subject: [PATCH 29/30] more descriptive doc str for get_entries

---
 mp_api/client/mprester.py | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/mp_api/client/mprester.py b/mp_api/client/mprester.py
index baff3577..0fab2eca 100644
--- a/mp_api/client/mprester.py
+++ b/mp_api/client/mprester.py
@@ -764,8 +764,13 @@ def get_entries(
 
         This returns a list of ComputedStructureEntry with final structures for all thermo types
         represented in the database. Each type corresponds to a different mixing scheme
-        (i.e. GGA/GGA+U, GGA/GGA+U/R2SCAN, R2SCAN). By default the thermo_type of the
-        entry is also returned.
+        (i.e. GGA/GGA+U, GGA/GGA+U/R2SCAN, R2SCAN).
+
+        Entries from different thermo types can share an ``entry_id`` and differ only
+        in their energy adjustments: e.g. the R2SCAN and GGA/GGA+U/R2SCAN entries of a
+        material are the same r2SCAN calculation, the latter with the mixing correction
+        applied. To get the entries of a single thermo type, pass it in
+        ``additional_criteria``, e.g. ``{"thermo_types": ["GGA_GGA+U_R2SCAN"]}``.
 
         Args:
             chemsys_formula_mpids (str | list[str]): A chemical system, list of chemical systems

From b270e2bcdcd3053c8f3399babbb42674f1702675 Mon Sep 17 00:00:00 2001
From: Tyler Mathis <35553152+tsmathis@users.noreply.github.com>
Date: Mon, 19 Oct 2026 11:37:21 -0700
Subject: [PATCH 30/30] linting

---
 tests/client/test_mprester.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/tests/client/test_mprester.py b/tests/client/test_mprester.py
index 10e32560..1dbb8f04 100644
--- a/tests/client/test_mprester.py
+++ b/tests/client/test_mprester.py
@@ -264,9 +264,7 @@ def test_get_entries(self, mpr):
                 conventional_unit_cell=False,
                 additional_criteria=criteria,
             )
-            assert len({e.entry_id for e in non_standardized}) == len(
-                non_standardized
-            )
+            assert len({e.entry_id for e in non_standardized}) == len(non_standardized)
             for e in as_conv:
                 ref = next(f for f in non_standardized if f.entry_id == e.entry_id)
                 assert e.uncorrected_energy_per_atom == pytest.approx(