Repository: conda/conda
Pull request #14780: Pluginize `PrefixData` interoperability
Author: login: jaimergp
Labels: name: cla-signed
description: [bot] added once the contributor has signed the CLA
Created: 2025-04-22T21:41:45Z | Merged: 2025-05-02T12:40:01Z

<!-- Hello! Thanks for submitting a PR! To help make things go a bit more
     smoothly, we would appreciate it if you follow this template. -->

### Description

<!-- Good things to put here include:
       - reasons for the change (please link any relevant issues!),
       - any noteworthy (or hacky) choices to be aware of,
       - or what the problem resolved here looked like. -->

Part of https://github.com/conda/conda/issues/14070

Adds plugin hooks for `PrefixData` loaders and refactors the current PyPI records loading logic as a plugin. The same can be used to list e.g. R packages, Node, and others.

### Checklist - did you ...

<!-- If any of the following items aren't relevant to your contribution,
     please either tick them or use ~strikethrough~ so we know you've gone
     through the checklist. -->

- [x] Add a file to the `news` directory ([using the template](https://github.com/conda/conda/blob/main/news/TEMPLATE)) for the next release's release notes?
     <!-- All "significant" changes should get an entry:
            - user-facing changes or enhancements
            - bug fixes
            - deprecations
            - documentation updates
            - etc -->
- [x] Add / update necessary tests?
- [x] Add / update outdated documentation?


<!-- Just as a reminder, everyone in all conda org spaces (including PRs)
     must follow the Conda Org Code of Conduct (link below).

     Finally, once again, thanks for your time and effort. If you have any
     feedback in regards to your experience contributing here, please
     let us know!

     Helpful links:
       - Conda Org COC: https://github.com/conda/conda/blob/main/CODE_OF_CONDUCT.md
       - Contributing docs: https://github.com/conda/conda/blob/main/CONTRIBUTING.md -->



Diff:
diff --git a/conda/base/context.py b/conda/base/context.py
index 55237544f8b..f87b29fc44d 100644
--- a/conda/base/context.py
+++ b/conda/base/context.py
@@ -246,7 +246,14 @@ class Context(Configuration):
     enable_private_envs = ParameterLoader(PrimitiveParameter(False))
     force_32bit = ParameterLoader(PrimitiveParameter(False))
     non_admin_enabled = ParameterLoader(PrimitiveParameter(True))
-    pip_interop_enabled = ParameterLoader(PrimitiveParameter(False))
+    prefix_data_interoperability = ParameterLoader(
+        PrimitiveParameter(False), aliases="pip_interop_enabled"
+    )
+
+    @property
+    @deprecated("25.9", "26.3", addendum="Use 'Context.prefix_data_interoperability'.")
+    def pip_interop_enabled(self):
+        return self.prefix_data_interoperability
 
     # multithreading in various places
     _default_threads = ParameterLoader(
@@ -1255,7 +1262,7 @@ def category_map(self):
                 "disallowed_packages",
                 "force_reinstall",
                 "pinned_packages",
-                "pip_interop_enabled",
+                "prefix_data_interoperability",
                 "track_features",
                 "solver",
             ),
@@ -1694,9 +1701,9 @@ def description_map(self):
                 This parameter is in BETA, and its behavior may change in a future release.
                 """
             ),
-            pip_interop_enabled=dals(
+            prefix_data_interoperability=dals(
                 """
-                Allow the conda solver to interact with non-conda-installed python packages.
+                Enable plugins to allow conda to interact with non-conda-installed packages.
                 """
             ),
             pkgs_dirs=dals(
diff --git a/conda/cli/main_compare.py b/conda/cli/main_compare.py
index d893a50071e..e46d341886b 100644
--- a/conda/cli/main_compare.py
+++ b/conda/cli/main_compare.py
@@ -68,7 +68,7 @@ def get_packages(prefix):
         raise EnvironmentLocationNotFound(prefix)
 
     return sorted(
-        PrefixData(prefix, pip_interop_enabled=True).iter_records(),
+        PrefixData(prefix, interoperability=True).iter_records(),
         key=lambda x: x.name,
     )
 
diff --git a/conda/cli/main_list.py b/conda/cli/main_list.py
index b34b0e41efa..e93d366e721 100644
--- a/conda/cli/main_list.py
+++ b/conda/cli/main_list.py
@@ -171,7 +171,7 @@ def list_packages(
 
     res = 0
 
-    prefix_data = PrefixData(prefix, pip_interop_enabled=True)
+    prefix_data = PrefixData(prefix, interoperability=True)
     if reload_records:
         prefix_data.load()
     installed = sorted(prefix_data.iter_records(), key=lambda x: x.name)
diff --git a/conda/core/prefix_data.py b/conda/core/prefix_data.py
index 478865fc243..76cbaae3018 100644
--- a/conda/core/prefix_data.py
+++ b/conda/core/prefix_data.py
@@ -12,7 +12,6 @@
 from pathlib import Path
 from typing import TYPE_CHECKING
 
-from ..auxlib.exceptions import ValidationError
 from ..base.constants import (
     CONDA_ENV_VARS_UNSET_VAR,
     CONDA_PACKAGE_EXTENSIONS,
@@ -27,14 +26,12 @@
 from ..common.io import time_recorder
 from ..common.path import (
     expand,
-    get_python_site_packages_short_path,
     paths_equal,
-    win_path_ok,
 )
-from ..common.pkg_formats.python import get_site_packages_anchor_files
 from ..common.serialize import json_load
 from ..common.url import mask_anaconda_token
 from ..common.url import remove_auth as url_remove_auth
+from ..deprecations import deprecated
 from ..exceptions import (
     BasicClobberError,
     CondaDependencyError,
@@ -48,7 +45,6 @@
 )
 from ..gateways.disk.create import first_writable_envs_dir, write_as_json_to_file
 from ..gateways.disk.delete import rm_rf
-from ..gateways.disk.read import read_python_record
 from ..gateways.disk.test import file_path_is_writable
 from ..models.match_spec import MatchSpec
 from ..models.prefix_graph import PrefixGraph
@@ -69,19 +65,27 @@
 class PrefixDataType(type):
     """Basic caching of PrefixData instance objects."""
 
+    @deprecated.argument(
+        "25.9", "26.3", "pip_interop_enabled", rename="interoperability"
+    )
     def __call__(
         cls,
         prefix_path: str | os.PathLike | Path,
-        pip_interop_enabled: bool | None = None,
+        interoperability: bool | None = None,
     ) -> PrefixData:
         if isinstance(prefix_path, PrefixData):
             return prefix_path
         prefix_path = Path(prefix_path)
-        cache_key = prefix_path, pip_interop_enabled
+        interoperability = (
+            interoperability
+            if interoperability is not None
+            else context.prefix_data_interoperability
+        )
+        cache_key = prefix_path, interoperability
         if cache_key in PrefixData._cache_:
             return PrefixData._cache_[cache_key]
         else:
-            prefix_data_instance = super().__call__(prefix_path, pip_interop_enabled)
+            prefix_data_instance = super().__call__(prefix_path, interoperability)
             PrefixData._cache_[cache_key] = prefix_data_instance
             return prefix_data_instance
 
@@ -94,19 +98,22 @@ class PrefixData(metaclass=PrefixDataType):
 
     This class supports different types of tasks:
 
-    - Reading and querying `conda-meta/*.json` files as `PackageRecord` objects
-    - Reading PyPI-only packages, installed next to conda packages
+    - Reading and querying `conda-meta/*.json` files as `PrefixRecord` objects
     - Reading and writing environment-specific configuration (env vars, state file,
       nonadmin markers, etc)
     - Existence checks and validations of name, path, and magic files / markers
+    - Exposing non-conda packages installed in prefix as `PrefixRecord`, via the plugin system
     """
 
     _cache_: dict[tuple[Path, bool | None], PrefixData] = {}
 
+    @deprecated.argument(
+        "25.9", "26.3", "pip_interop_enabled", rename="interoperability"
+    )
     def __init__(
         self,
         prefix_path: str | os.PathLike[str] | Path,
-        pip_interop_enabled: bool | None = None,
+        interoperability: bool | None = None,
     ):
         # pip_interop_enabled is a temporary parameter; DO NOT USE
         # TODO: when removing pip_interop_enabled, also remove from meta class
@@ -114,10 +121,10 @@ def __init__(
         self._magic_file: Path = self.prefix_path / PREFIX_MAGIC_FILE
         self.__prefix_records: dict[str, PrefixRecord] | None = None
         self.__is_writable: bool | None | _Null = NULL
-        self._pip_interop_enabled: bool = (
-            pip_interop_enabled
-            if pip_interop_enabled is not None
-            else context.pip_interop_enabled
+        self.interoperability: bool = (
+            interoperability
+            if interoperability is not None
+            else context.prefix_data_interoperability
         )
 
     @classmethod
@@ -326,13 +333,19 @@ def load(self) -> None:
             )
             for meta_file in conda_meta_json_paths:
                 self._load_single_record(meta_file)
-        if self._pip_interop_enabled:
-            self._load_site_packages()
+        if self.interoperability:
+            for loader in context.plugin_manager.get_prefix_data_loaders():
+                loader(self.prefix_path, self.__prefix_records)
 
     def reload(self) -> PrefixData:
         self.load()
         return self
 
+    @property
+    @deprecated("25.9", "26.3", addendum="Use PrefixData.interoperability.")
+    def _pip_interop_enabled(self):
+        return self.interoperability
+
     def _get_json_fn(self, prefix_record: PrefixRecord) -> str:
         fn = prefix_record.fn
         known_ext = False
@@ -478,6 +491,7 @@ def _load_single_record(self, prefix_record_json_path: PathType) -> None:
     # region Python records
 
     @property
+    @deprecated("25.9", "26.3", addendum="Use PrefixData.get('python').")
     def _python_pkg_record(self) -> PrefixRecord | None:
         """Return the prefix record for the package python."""
         return next(
@@ -489,107 +503,15 @@ def _python_pkg_record(self) -> PrefixRecord | None:
             None,
         )
 
+    @deprecated(
+        "25.9",
+        "26.3",
+        addendum="Use 'conda.plugins.prefix_data_loaders.pypi.load_site_packages' instead.",
+    )
     def _load_site_packages(self) -> dict[str, PrefixRecord]:
-        """
-        Load non-conda-installed python packages in the site-packages of the prefix.
-
-        Python packages not handled by conda are installed via other means,
-        like using pip or using python setup.py develop for local development.
-
-        Packages found that are not handled by conda are converted into a
-        prefix record and handled in memory.
-
-        Packages clobbering conda packages (i.e. the conda-meta record) are
-        removed from the in memory representation.
-        """
-        python_pkg_record = self._python_pkg_record
-
-        if not python_pkg_record:
-            return {}
-
-        site_packages_dir = get_python_site_packages_short_path(
-            python_pkg_record.version
-        )
-        site_packages_path = self.prefix_path / win_path_ok(site_packages_dir)
-
-        if not site_packages_path.is_dir():
-            return {}
-
-        # Get anchor files for corresponding conda (handled) python packages
-        prefix_graph = PrefixGraph(self.iter_records())
-        python_records = prefix_graph.all_descendants(python_pkg_record)
-        conda_python_packages = get_conda_anchor_files_and_records(
-            site_packages_dir, python_records
-        )
-
-        # Get all anchor files and compare against conda anchor files to find clobbered conda
-        # packages and python packages installed via other means (not handled by conda)
-        sp_anchor_files = get_site_packages_anchor_files(
-            site_packages_path, site_packages_dir
-        )
-        conda_anchor_files = set(conda_python_packages)
-        clobbered_conda_anchor_files = conda_anchor_files - sp_anchor_files
-        non_conda_anchor_files = sp_anchor_files - conda_anchor_files
-
-        # If there's a mismatch for anchor files between what conda expects for a package
-        # based on conda-meta, and for what is actually in site-packages, then we'll delete
-        # the in-memory record for the conda package.  In the future, we should consider
-        # also deleting the record on disk in the conda-meta/ directory.
-        for conda_anchor_file in clobbered_conda_anchor_files:
-            prefix_rec = self._prefix_records.pop(
-                conda_python_packages[conda_anchor_file].name
-            )
-            try:
-                extracted_package_dir = basename(prefix_rec.extracted_package_dir)
-            except AttributeError:
-                extracted_package_dir = "-".join(
-                    (prefix_rec.name, prefix_rec.version, prefix_rec.build)
-                )
-            prefix_rec_json_path = (
-                self.prefix_path / "conda-meta" / f"{extracted_package_dir}.json"
-            )
-            try:
-                rm_rf(prefix_rec_json_path)
-            except OSError:
-                log.debug(
-                    "stale information, but couldn't remove: %s", prefix_rec_json_path
-                )
-            else:
-                log.debug("removed due to stale information: %s", prefix_rec_json_path)
-
-        # Create prefix records for python packages not handled by conda
-        new_packages = {}
-        for af in non_conda_anchor_files:
-            try:
-                python_record = read_python_record(
-                    self.prefix_path, af, python_pkg_record.version
-                )
-            except OSError as e:
-                log.info(
-                    "Python record ignored for anchor path '%s'\n  due to %s", af, e
-                )
-                continue
-            except ValidationError:
-                import sys
-
-                exc_type, exc_value, exc_traceback = sys.exc_info()
-                import traceback
-
-                tb = traceback.format_exception(exc_type, exc_value, exc_traceback)
-                log.warning(
-                    "Problem reading non-conda package record at %s. Please verify that you "
-                    "still need this, and if so, that this is still installed correctly. "
-                    "Reinstalling this package may help.",
-                    af,
-                )
-                log.debug("ValidationError: \n%s\n", "\n".join(tb))
-                continue
-            if not python_record:
-                continue
-            self.__prefix_records[python_record.name] = python_record
-            new_packages[python_record.name] = python_record
+        from ..plugins.prefix_data_loaders.pypi import load_site_packages
 
-        return new_packages
+        return load_site_packages(self.prefix_path, self.__prefix_records)
 
     # endregion
     # region State and environment variables
@@ -680,6 +602,7 @@ def get_conda_anchor_files_and_records(
     return conda_python_packages
 
 
+@deprecated("25.9", "25.3", addendum="Use `PrefixData.get('python', None)`")
 def python_record_for_prefix(prefix: os.PathLike) -> PrefixRecord | None:
     """
     For the given conda prefix, return the PrefixRecord of the Python installed
@@ -700,6 +623,7 @@ def python_record_for_prefix(prefix: os.PathLike) -> PrefixRecord | None:
     return record
 
 
+@deprecated("25.9", "25.3", addendum="Use `PrefixData.get('python').version`")
 def get_python_version_for_prefix(prefix: os.PathLike) -> str | None:
     """
     For the given conda prefix, return the version of the Python installation
@@ -717,12 +641,12 @@ def get_python_version_for_prefix(prefix: os.PathLike) -> str | None:
 def delete_prefix_from_linked_data(path: str | os.PathLike | Path) -> bool:
     """Here, path may be a complete prefix or a dist inside a prefix"""
     path = Path(path)
-    for prefix, pip_interop in sorted(
+    for prefix, interoperability in sorted(
         PrefixData._cache_, reverse=True, key=lambda key: key[0]
     ):
         try:
             path.relative_to(prefix)
-            del PrefixData._cache_[(prefix, pip_interop)]
+            del PrefixData._cache_[(prefix, interoperability)]
             return True
         except ValueError:
             # ValueError: path is not relative to prefix
diff --git a/conda/env/env.py b/conda/env/env.py
index a1b2fdc8c97..55f41311241 100644
--- a/conda/env/env.py
+++ b/conda/env/env.py
@@ -78,7 +78,7 @@ def from_environment(
 
     Returns:     Environment object
     """
-    pd = PrefixData(prefix, pip_interop_enabled=True)
+    pd = PrefixData(prefix, interoperability=True)
     variables = pd.get_environment_env_vars()
 
     if from_history:
diff --git a/conda/plugins/__init__.py b/conda/plugins/__init__.py
index 703e674b097..df2ee9fb585 100644
--- a/conda/plugins/__init__.py
+++ b/conda/plugins/__init__.py
@@ -31,6 +31,7 @@
     CondaPostCommand,
     CondaPostSolve,
     CondaPreCommand,
+    CondaPrefixDataLoader,
     CondaPreSolve,
     CondaReporterBackend,
     CondaRequestHeader,
diff --git a/conda/plugins/hookspec.py b/conda/plugins/hookspec.py
index 4f0f8d7cccd..3ce7db3fd2b 100644
--- a/conda/plugins/hookspec.py
+++ b/conda/plugins/hookspec.py
@@ -23,6 +23,7 @@
         CondaPostCommand,
         CondaPostSolve,
         CondaPreCommand,
+        CondaPrefixDataLoader,
         CondaPreSolve,
         CondaReporterBackend,
         CondaRequestHeader,
@@ -461,3 +462,46 @@ def conda_request_headers(host: str, path: str):
                    )
         """
         yield from ()
+
+    @_hookspec
+    def conda_prefix_data_loaders() -> Iterable[CondaPrefixDataLoader]:
+        """
+        Register new loaders for PrefixData
+
+        The example below defines how to expose the packages installed
+        by a hypothetical 'penguin' tool as conda packages.
+
+        **Example:**
+
+        .. code-block:: python
+
+            from pathlib import Path
+
+            from conda import plugins
+            from conda.common.path import PathType
+            from conda.models.records import PrefixRecord
+            from conda.plugins.types import CondaPrefixDataLoader
+
+
+            @plugins.hookimpl
+            def conda_prefix_data_loaders():
+                yield CondaPrefixDataLoader(
+                    "hypothetical",
+                    load_hypothetical_packages,
+                )
+
+
+            def load_hypothetical_packages(
+                prefix: PathType, records: dict[str, PrefixRecord]
+            ) -> dict[str, PrefixRecord]:
+                penguin_records = {}
+                for info in Path(prefix).glob("lib/penguin/*.penguin-info"):
+                    name, version = info.name.rsplit("-", 1)
+                    kwargs = {}  # retrieve extra fields here
+                    penguin_records[name] = PrefixRecord(
+                        name=name, version=version, build_number=0, build="0", **kwargs
+                    )
+                records.update(penguin_records)
+                return penguin_records
+        """
+        yield from ()
diff --git a/conda/plugins/manager.py b/conda/plugins/manager.py
index 69fb02274cb..0a01bdccf17 100644
--- a/conda/plugins/manager.py
+++ b/conda/plugins/manager.py
@@ -25,6 +25,7 @@
 from ..exceptions import CondaValueError, PluginError
 from . import (
     post_solves,
+    prefix_data_loaders,
     reporter_backends,
     solvers,
     subcommands,
@@ -34,6 +35,7 @@
 from .subcommands.doctor import health_checks
 
 if TYPE_CHECKING:
+    from collections.abc import Iterable
     from typing import Literal
 
     from requests.auth import AuthBase
@@ -48,6 +50,8 @@
         CondaPostCommand,
         CondaPostSolve,
         CondaPreCommand,
+        CondaPrefixDataLoader,
+        CondaPrefixDataLoaderCallable,
         CondaPreSolve,
         CondaReporterBackend,
         CondaRequestHeader,
@@ -223,6 +227,11 @@ def get_hook_results(
         self, name: Literal["reporter_backends"]
     ) -> list[CondaReporterBackend]: ...
 
+    @overload
+    def get_hook_results(
+        self, name: Literal["prefix_data_loaders"]
+    ) -> list[CondaPrefixDataLoader]: ...
+
     def get_hook_results(self, name, **kwargs):
         """
         Return results of the plugin hooks with the given name and
@@ -417,6 +426,10 @@ def get_request_headers(self, host: str, path: str) -> dict[str, str]:
             for hook in self.get_hook_results("request_headers", host=host, path=path)
         }
 
+    def get_prefix_data_loaders(self) -> Iterable[CondaPrefixDataLoaderCallable]:
+        for hook in self.get_hook_results("prefix_data_loaders"):
+            yield hook.loader
+
     def invoke_health_checks(self, prefix: str, verbose: bool) -> None:
         for hook in self.get_hook_results("health_checks"):
             try:
@@ -479,6 +492,7 @@ def get_plugin_manager() -> CondaPluginManager:
         health_checks,
         *post_solves.plugins,
         *reporter_backends.plugins,
+        *prefix_data_loaders.plugins,
     )
     plugin_manager.load_entrypoints(spec_name)
     return plugin_manager
diff --git a/conda/plugins/prefix_data_loaders/__init__.py b/conda/plugins/prefix_data_loaders/__init__.py
new file mode 100644
index 00000000000..de899adfffc
--- /dev/null
+++ b/conda/plugins/prefix_data_loaders/__init__.py
@@ -0,0 +1,5 @@
+# Copyright (C) 2012 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+from . import pypi
+
+plugins = [pypi]
diff --git a/conda/plugins/prefix_data_loaders/pypi.py b/conda/plugins/prefix_data_loaders/pypi.py
new file mode 100644
index 00000000000..7e774e564ff
--- /dev/null
+++ b/conda/plugins/prefix_data_loaders/pypi.py
@@ -0,0 +1,161 @@
+# Copyright (C) 2012 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+"""Reads PyPI packages in a conda prefix that have been installed with non-conda tools."""
+
+from __future__ import annotations
+
+import re
+import sys
+import traceback
+from logging import getLogger
+from os.path import basename
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from ...auxlib.exceptions import ValidationError
+from ...common.path import (
+    get_python_site_packages_short_path,
+    win_path_ok,
+)
+from ...common.pkg_formats.python import get_site_packages_anchor_files
+from ...gateways.disk.delete import rm_rf
+from ...gateways.disk.read import read_python_record
+from ...models.prefix_graph import PrefixGraph
+from .. import hookimpl
+from ..types import CondaPrefixDataLoader
+
+if TYPE_CHECKING:
+    from ...common.path import PathType
+    from ...models.records import PrefixRecord
+
+log = getLogger(__name__)
+
+
+def load_site_packages(
+    prefix: PathType, records: dict[str, PrefixRecord]
+) -> dict[str, PrefixRecord]:
+    """
+    Load non-conda-installed python packages in the site-packages of the prefix.
+
+    Python packages not handled by conda are installed via other means,
+    like using pip or using python setup.py develop for local development.
+
+    Packages found that are not handled by conda are converted into a
+    prefix record and handled in memory.
+
+    Packages clobbering conda packages (i.e. the conda-meta record) are
+    removed from the in memory representation.
+    """
+    python_pkg_record = records.get("python")
+    if not python_pkg_record or not python_pkg_record.version:
+        return {}
+
+    prefix_path = Path(prefix)
+
+    site_packages_dir = get_python_site_packages_short_path(python_pkg_record.version)
+    site_packages_path = prefix_path / win_path_ok(site_packages_dir)
+
+    if not site_packages_path.is_dir():
+        return {}
+
+    # Get anchor files for corresponding conda (handled) python packages
+    prefix_graph = PrefixGraph(records.values())
+    python_records = prefix_graph.all_descendants(python_pkg_record)
+    conda_python_packages = get_conda_anchor_files_and_records(
+        site_packages_dir, python_records
+    )
+
+    # Get all anchor files and compare against conda anchor files to find clobbered conda
+    # packages and python packages installed via other means (not handled by conda)
+    sp_anchor_files = get_site_packages_anchor_files(
+        site_packages_path, site_packages_dir
+    )
+    conda_anchor_files = set(conda_python_packages)
+    clobbered_conda_anchor_files = conda_anchor_files - sp_anchor_files
+    non_conda_anchor_files = sp_anchor_files - conda_anchor_files
+
+    # If there's a mismatch for anchor files between what conda expects for a package
+    # based on conda-meta, and for what is actually in site-packages, then we'll delete
+    # the in-memory record for the conda package.  In the future, we should consider
+    # also deleting the record on disk in the conda-meta/ directory.
+    for conda_anchor_file in clobbered_conda_anchor_files:
+        prefix_rec = records.pop(conda_python_packages[conda_anchor_file].name)
+        try:
+            extracted_package_dir = basename(prefix_rec.extracted_package_dir)
+        except AttributeError:
+            extracted_package_dir = "-".join(
+                map(str, (prefix_rec.name, prefix_rec.version, prefix_rec.build))
+            )
+        prefix_rec_json_path = (
+            prefix_path / "conda-meta" / f"{extracted_package_dir}.json"
+        )
+        try:
+            rm_rf(prefix_rec_json_path)
+        except OSError:
+            log.debug(
+                "stale information, but couldn't remove: %s", prefix_rec_json_path
+            )
+        else:
+            log.debug("removed due to stale information: %s", prefix_rec_json_path)
+
+    # Create prefix records for python packages not handled by conda
+    new_packages = {}
+    for af in non_conda_anchor_files:
+        try:
+            python_record = read_python_record(
+                prefix_path, af, python_pkg_record.version
+            )
+        except OSError as e:
+            log.info("Python record ignored for anchor path '%s'\n  due to %s", af, e)
+            continue
+        except ValidationError:
+            exc_type, exc_value, exc_traceback = sys.exc_info()
+
+            tb = traceback.format_exception(exc_type, exc_value, exc_traceback)
+            log.warning(
+                "Problem reading non-conda package record at %s. Please verify that you "
+                "still need this, and if so, that this is still installed correctly. "
+                "Reinstalling this package may help.",
+                af,
+            )
+            log.debug("ValidationError: \n%s\n", "\n".join(tb))
+            continue
+        if not python_record:
+            continue
+        records[python_record.name] = python_record
+        new_packages[python_record.name] = python_record
+
+    return new_packages
+
+
+def get_conda_anchor_files_and_records(site_packages_short_path, python_records):
+    """Return the anchor files for the conda records of python packages."""
+    anchor_file_endings = (".egg-info/PKG-INFO", ".dist-info/RECORD", ".egg-info")
+    conda_python_packages = {}
+
+    matcher = re.compile(
+        r"^{}/[^/]+(?:{})$".format(
+            re.escape(site_packages_short_path),
+            r"|".join(re.escape(fn) for fn in anchor_file_endings),
+        )
+    ).match
+
+    for prefix_record in python_records:
+        anchor_paths = tuple(fpath for fpath in prefix_record.files if matcher(fpath))
+        if len(anchor_paths) > 1:
+            anchor_path = sorted(anchor_paths, key=len)[0]
+            log.info(
+                "Package %s has multiple python anchor files.\n  Using %s",
+                prefix_record.record_id(),
+                anchor_path,
+            )
+            conda_python_packages[anchor_path] = prefix_record
+        elif anchor_paths:
+            conda_python_packages[anchor_paths[0]] = prefix_record
+
+    return conda_python_packages
+
+
+@hookimpl(tryfirst=True)
+def conda_prefix_data_loaders():
+    yield CondaPrefixDataLoader("pypi", load_site_packages)
diff --git a/conda/plugins/types.py b/conda/plugins/types.py
index 25df854b539..75bec1ebabd 100644
--- a/conda/plugins/types.py
+++ b/conda/plugins/types.py
@@ -21,11 +21,18 @@
 if TYPE_CHECKING:
     from argparse import ArgumentParser, Namespace
     from contextlib import AbstractContextManager
-    from typing import Any, Callable
+    from typing import Any, Callable, TypeAlias
 
     from ..common.configuration import Parameter
+    from ..common.path import PathType
     from ..core.solve import Solver
     from ..models.match_spec import MatchSpec
+    from ..models.records import PrefixRecord
+
+    CondaPrefixDataLoaderCallable: TypeAlias = Callable[
+        [PathType, dict[str, PrefixRecord]],
+        dict[str, PrefixRecord],
+    ]
 
 
 @dataclass
@@ -347,3 +354,20 @@ class CondaRequestHeader:
 
     name: str
     value: str
+
+
+@dataclass
+class CondaPrefixDataLoader:
+    """
+    Define new loaders to expose non-conda packages in a given prefix
+    as ``PrefixRecord`` objects.
+
+    :param name: name of the loader
+    :param loader: a function that takes a prefix and a dictionary that maps
+        package names to ``PrefixRecord`` objects. The newly loaded packages
+        must be inserted in the passed dictionary accordingly, and also
+        returned as a separate dictionary.
+    """
+
+    name: str
+    loader: CondaPrefixDataLoaderCallable
diff --git a/conda/testing/integration.py b/conda/testing/integration.py
index bd08e41c007..bc2f699a3ce 100644
--- a/conda/testing/integration.py
+++ b/conda/testing/integration.py
@@ -77,7 +77,7 @@ def package_is_installed(
     reload_records: bool = True,
 ) -> PrefixRecord | None:
     spec = MatchSpec(spec)
-    prefix_data = PrefixData(prefix, pip_interop_enabled=True)
+    prefix_data = PrefixData(prefix, interoperability=True)
     if reload_records:
         prefix_data.load()
     prefix_recs = tuple(prefix_data.query(spec))
diff --git a/docs/source/dev-guide/plugins/index.rst b/docs/source/dev-guide/plugins/index.rst
index 38d997fb0f4..26a17a5927b 100644
--- a/docs/source/dev-guide/plugins/index.rst
+++ b/docs/source/dev-guide/plugins/index.rst
@@ -102,6 +102,7 @@ For examples of how to use other plugin hooks, please read their respective docu
    request_headers
    post_commands
    pre_commands
+   prefix_data_loaders
    reporter_backends
    settings
    solvers
diff --git a/docs/source/dev-guide/plugins/prefix_data_loaders.rst b/docs/source/dev-guide/plugins/prefix_data_loaders.rst
new file mode 100644
index 00000000000..910acaa7669
--- /dev/null
+++ b/docs/source/dev-guide/plugins/prefix_data_loaders.rst
@@ -0,0 +1,15 @@
+======================
+``PrefixData`` loaders
+======================
+
+The ``PrefixData`` class exposes the contents of a given conda environment
+(prefix) as a series of ``PrefixRecord`` objects. This plugin hook allows
+users to write logic to expose non-conda packages as conda-like metadata.
+This can be useful to allow ``conda list`` to report additional ecosystems,
+like PyPI or others.
+
+.. autoapiclass:: conda.plugins.types.CondaPrefixDataLoader
+   :members:
+   :undoc-members:
+
+.. autoapifunction:: conda.plugins.hookspec.CondaSpecs.conda_prefix_data_loaders
diff --git a/docs/source/user-guide/configuration/pip-interoperability.rst b/docs/source/user-guide/configuration/pip-interoperability.rst
index 27a8b0bb83f..fbfa1468c96 100644
--- a/docs/source/user-guide/configuration/pip-interoperability.rst
+++ b/docs/source/user-guide/configuration/pip-interoperability.rst
@@ -12,11 +12,11 @@ conda packages when appropriate.
 
 If you’d like to try the feature, you can set this ``.condarc`` setting::
 
-   conda config --set pip_interop_enabled True
+   conda config --set prefix_data_interoperability True
 
 .. note::
 
-   Setting ``pip_interop_enabled`` to ``True`` may slow down conda.
+   Setting ``prefix_data_interoperability`` to ``True`` may slow down conda.
 
 Even without activating this feature, conda now understands pip metadata
 more intelligently. For example, if we create an environment with conda::
diff --git a/news/14780-prefixdata-hooks b/news/14780-prefixdata-hooks
new file mode 100644
index 00000000000..f0e0e3b8b01
--- /dev/null
+++ b/news/14780-prefixdata-hooks
@@ -0,0 +1,24 @@
+### Enhancements
+
+* Add a plugin hook for `PrefixData` loaders and use it to wrap the existing PyPI interoperability features. These are now present in `conda.plugins.prefix_data_loaders.pypi`. (#14780)
+
+### Bug fixes
+
+* <news item>
+
+### Deprecations
+
+* Mark `conda.base.context.Context.pip_interop_enabled` as pending deprecation, to be removed in 26.3. Use `conda.base.context.Context.prefix_data_interoperability`. instead. (#14750)
+* Mark `conda.core.prefix_data.get_python_version_for_prefix()` as pending deprecation, to be removed in 26.3. Use `conda.core.prefix_data.PrefixData(prefix).get("python").version` instead. (#14750)
+* Mark `conda.core.prefix_data.PrefixData._load_site_packages()` as pending deprecation, to be removed in 26.3. Use `conda.plugins.prefix_data_loaders.pypi.load_site_packages()` instead. (#14750)
+* Mark `conda.core.prefix_data.PrefixData._python_pkg_record` as pending deprecation, to be removed in 26.3. Use `PrefixData.get("python")` instead. (#14750)
+* Mark `conda.core.prefix_data.PrefixData` keyword argument `pip_interop_enabled` as pending deprecation, to be removed in 26.3. Use the `interoperability` keyword argument instead. (#14750)
+* Mark `conda.core.prefix_data.python_record_for_prefix()` as pending deprecation. Use `conda.core.prefix_data.PrefixData(prefix).get("python")` instead. (#14750)
+
+### Docs
+
+* <news item>
+
+### Other
+
+* <news item>
diff --git a/tests/cli/test_main_install.py b/tests/cli/test_main_install.py
index 1b47146a233..7e9b95ddbbc 100644
--- a/tests/cli/test_main_install.py
+++ b/tests/cli/test_main_install.py
@@ -53,9 +53,9 @@ def test_install_freezes_env_by_default(
 def test_conda_pip_interop_dependency_satisfied_by_pip(
     monkeypatch: MonkeyPatch, tmp_env: TmpEnvFixture, conda_cli: CondaCLIFixture
 ):
-    monkeypatch.setenv("CONDA_PIP_INTEROP_ENABLED", "true")
+    monkeypatch.setenv("CONDA_PREFIX_DATA_INTEROPERABILITY", "true")
     reset_context()
-    assert context.pip_interop_enabled
+    assert context.prefix_data_interoperability
     with tmp_env("python=3.10", "pip") as prefix:
         assert package_is_installed(prefix, "python=3.10")
         assert package_is_installed(prefix, "pip")
diff --git a/tests/core/test_prefix_data.py b/tests/core/test_prefix_data.py
index 619aba48c25..9a1f72e3558 100644
--- a/tests/core/test_prefix_data.py
+++ b/tests/core/test_prefix_data.py
@@ -14,6 +14,7 @@
 from conda.common.compat import on_win
 from conda.core.prefix_data import PrefixData, get_conda_anchor_files_and_records
 from conda.exceptions import CorruptedEnvironmentError
+from conda.plugins.prefix_data_loaders.pypi import load_site_packages
 from conda.testing.helpers import record
 
 if TYPE_CHECKING:
@@ -262,9 +263,9 @@ def test_pip_interop(
     # `python setup.py install`
     mocker.patch("conda.core.prefix_data.rm_rf")
 
-    prefixdata = PrefixData(path, pip_interop_enabled=True)
+    prefixdata = PrefixData(path, interoperability=True)
     prefixdata.load()
-    records = prefixdata._load_site_packages()
+    records = load_site_packages(prefixdata.prefix_path, prefixdata._prefix_records)
 
     assert set(records) == expected_output
 
diff --git a/tests/test_create.py b/tests/test_create.py
index 0f6587b4eeb..655d3cedbd2 100644
--- a/tests/test_create.py
+++ b/tests/test_create.py
@@ -834,7 +834,7 @@ def test_list_with_pip_no_binary(tmp_env: TmpEnvFixture, conda_cli: CondaCLIFixt
 
         # regression test for #5847
         #   when using rm_rf on a directory
-        # cache key is prefix, pip_interop_enabled, which is default=True on conda list
+        # cache key is prefix, interoperability, which is default=True on conda list
         assert (prefix, True) in PrefixData._cache_
         _rm_rf(prefix / get_python_site_packages_short_path(py_ver))
         assert prefix not in PrefixData._cache_
@@ -1325,9 +1325,9 @@ def test_update_all_updates_pip_pkg(
     tmp_env: TmpEnvFixture,
     conda_cli: CondaCLIFixture,
 ):
-    monkeypatch.setenv("CONDA_PIP_INTEROP_ENABLED", "true")
+    monkeypatch.setenv("CONDA_PREFIX_DATA_INTEROPERABILITY", "true")
     reset_context()
-    assert context.pip_interop_enabled
+    assert context.prefix_data_interoperability
 
     with tmp_env("python", "pip", "pytz<2023") as prefix:
         # install an old version of itsdangerous from pip
@@ -1697,9 +1697,9 @@ def test_conda_pip_interop_pip_clobbers_conda(
     #   File "C:\Users\builder\AppData\Local\Temp\f903_固ō한ñђáγßê家ôç_35\lib\site-packages\pip\_vendor\urllib3\util\ssl_.py", line 313, in ssl_wrap_socket
     #     context.load_verify_locations(ca_certs, ca_cert_dir)
     #   TypeError: cafile should be a valid filesystem path
-    monkeypatch.setenv("CONDA_PIP_INTEROP_ENABLED", "true")
+    monkeypatch.setenv("CONDA_PREFIX_DATA_INTEROPERABILITY", "true")
     reset_context()
-    assert context.pip_interop_enabled
+    assert context.prefix_data_interoperability
 
     with tmp_env(
         "--channel=https://repo.anaconda.com/pkgs/free",
@@ -1905,9 +1905,9 @@ def test_conda_pip_interop_conda_editable_package(
         )
     )
 
-    monkeypatch.setenv("CONDA_PIP_INTEROP_ENABLED", "true")
+    monkeypatch.setenv("CONDA_PREFIX_DATA_INTEROPERABILITY", "true")
     reset_context()
-    assert context.pip_interop_enabled
+    assert context.prefix_data_interoperability
 
     with tmp_env("python=3.12", "pip", "git") as prefix:
         assert package_is_installed(prefix, "python")
@@ -2031,9 +2031,9 @@ def test_conda_pip_interop_compatible_release_operator(
         pytest.skip("This test is too slow with conda-forge as default channel.")
     # Regression test for #7776
     # important to start the env with six 1.9.  That version forces an upgrade later in the test
-    monkeypatch.setenv("CONDA_PIP_INTEROP_ENABLED", "true")
+    monkeypatch.setenv("CONDA_PREFIX_DATA_INTEROPERABILITY", "true")
     reset_context()
-    assert context.pip_interop_enabled
+    assert context.prefix_data_interoperability
 
     with tmp_env(
         "--channel=https://repo.anaconda.com/pkgs/free",


Reviews:
user: login: kenodegard
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:38:42Z
user: login: kenodegard
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:41:01Z
user: login: kenodegard
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:46:18Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T07:53:26Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T07:53:52Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T07:54:01Z
user: login: jezdez
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-30T18:39:04Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T10:59:59Z
user: login: jjhelmus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T11:54:54Z
user: login: jjhelmus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T11:55:41Z
user: login: jjhelmus
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T11:56:03Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T12:23:35Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T12:39:47Z

Review comments:
diff_hunk: @@ -320,8 +316,9 @@ def load(self):
             )
             for meta_file in conda_meta_json_paths:
                 self._load_single_record(meta_file)
-        if self._pip_interop_enabled:
-            self._load_site_packages()
+        if self._pip_interop_enabled:  # TODO: Rename as general interop
path: conda/core/prefix_data.py
user: login: kenodegard
body: Alternatively (or in addition) the interoperability could be toggled per plugin?
created_at: 2025-04-24T22:38:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -470,6 +467,7 @@ def _load_single_record(self, prefix_record_json_path):
     # region Python records
 
     @property
+    @deprecated("25.9", "26.3", addendum="Use PrefixData.get('python').")
path: conda/core/prefix_data.py
user: login: kenodegard
body: any downsides to changing the body here to do that?

```python
@property
@deprecated("25.9", "26.3", addendum="Use PrefixData.get('python').")
def _python_pkg_record(self):
    """Return the prefix record for the package python."""
    return self.get("python", None)
```
created_at: 2025-04-24T22:41:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+### Enhancements
+
+* Add a plugin hook for `PrefixData` loaders and use it to wrap the existing PyPI interoperability features. These are now present in `conda.plugins.prefix_data_loaders.pypi`. (#14780)
+
+### Bug fixes
+
+* <news item>
+
+### Deprecations
+
+* Mark `conda.core.prefix_data.get_python_version_for_prefix()` as pending deprecation. Use `conda.core.prefix_data.PrefixData(prefix).get("python").version` instead. It will be permanently removed in 26.3. (#14750)
path: news/14780-prefixdata-hooks
user: login: kenodegard
body: Thoughts on moving all of the deprecation info to the front and leave only the fix at the end:

```suggestion
* Mark `conda.core.prefix_data.get_python_version_for_prefix()` as pending deprecation, to be removed in 26.3. Use `conda.core.prefix_data.PrefixData(prefix).get("python").version` instead. (#14750)
```
created_at: 2025-04-24T22:46:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -320,8 +316,9 @@ def load(self):
             )
             for meta_file in conda_meta_json_paths:
                 self._load_single_record(meta_file)
-        if self._pip_interop_enabled:
-            self._load_site_packages()
+        if self._pip_interop_enabled:  # TODO: Rename as general interop
path: conda/core/prefix_data.py
user: login: jaimergp
body: Yes but not at this API level, I think. Interoperability can be used in two very different ways:

- To report extra stuff in `conda list`
- To enable the solver to "see" more packages as "installed" and consider them as part of the solve process

For (1) we can have as much interoperability as we want because it's harmless, but for (2) it can cause a lot of havoc. So I was more leaning towards having a global bool switch `with_loader_plugins`. Then each plugin can be enabled / disabled via config I guess? It would also bring some trouble with the metaclass cache stuff...

But for now, we only need this part. I can rename it now, though.
created_at: 2025-04-25T07:53:26Z
author_association: MEMBER
diff_hunk: @@ -470,6 +467,7 @@ def _load_single_record(self, prefix_record_json_path):
     # region Python records
 
     @property
+    @deprecated("25.9", "26.3", addendum="Use PrefixData.get('python').")
path: conda/core/prefix_data.py
user: login: jaimergp
body: Since it's marked for deprecation, I'd rather not touch it.
created_at: 2025-04-25T07:53:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,22 @@
+### Enhancements
+
+* Add a plugin hook for `PrefixData` loaders and use it to wrap the existing PyPI interoperability features. These are now present in `conda.plugins.prefix_data_loaders.pypi`. (#14780)
+
+### Bug fixes
+
+* <news item>
+
+### Deprecations
+
+* Mark `conda.core.prefix_data.get_python_version_for_prefix()` as pending deprecation. Use `conda.core.prefix_data.PrefixData(prefix).get("python").version` instead. It will be permanently removed in 26.3. (#14750)
path: news/14780-prefixdata-hooks
user: login: jaimergp
body: I like it! Makes more sense.
created_at: 2025-04-25T07:54:01Z
author_association: MEMBER
path: conda/plugins/prefix_data_loaders/pypi.py
user: login: jezdez
body: I'd prefer if we'd move that to conda-pypi and start shipping it with conda distributions
created_at: 2025-04-30T18:37:19Z
author_association: MEMBER
path: conda/plugins/prefix_data_loaders/pypi.py
user: login: jaimergp
body: Yep, eventually I want to do that, but I don't think `conda-pypi` is ready for prime time yet. For now it will have to live here and then we can move it. The split is done, next step is just a copy-paste + deprecate exercise :D
created_at: 2025-05-01T10:59:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,161 @@
+# Copyright (C) 2012 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+"""Reads PyPI packages in a conda prefix that have been installed with non-conda tools."""
+
+from __future__ import annotations
+
+import re
+import sys
+import traceback
+from logging import getLogger
+from os.path import basename
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from ...auxlib.exceptions import ValidationError
+from ...common.path import (
+    get_python_site_packages_short_path,
+    win_path_ok,
+)
+from ...common.pkg_formats.python import get_site_packages_anchor_files
path: conda/plugins/prefix_data_loaders/pypi.py
user: login: jjhelmus
body: I believe outside of tests this is the only use of `get_site_packages_anchor_files`. This could be inlined into this plugin and deprecated elsewhere.
created_at: 2025-05-02T11:54:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,161 @@
+# Copyright (C) 2012 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+"""Reads PyPI packages in a conda prefix that have been installed with non-conda tools."""
+
+from __future__ import annotations
+
+import re
+import sys
+import traceback
+from logging import getLogger
+from os.path import basename
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from ...auxlib.exceptions import ValidationError
+from ...common.path import (
+    get_python_site_packages_short_path,
+    win_path_ok,
+)
+from ...common.pkg_formats.python import get_site_packages_anchor_files
+from ...gateways.disk.delete import rm_rf
+from ...gateways.disk.read import read_python_record
path: conda/plugins/prefix_data_loaders/pypi.py
user: login: jjhelmus
body: Same as above, inline `read_python_record` and deprecate?
created_at: 2025-05-02T11:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,161 @@
+# Copyright (C) 2012 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+"""Reads PyPI packages in a conda prefix that have been installed with non-conda tools."""
+
+from __future__ import annotations
+
+import re
+import sys
+import traceback
+from logging import getLogger
+from os.path import basename
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from ...auxlib.exceptions import ValidationError
+from ...common.path import (
+    get_python_site_packages_short_path,
+    win_path_ok,
+)
+from ...common.pkg_formats.python import get_site_packages_anchor_files
path: conda/plugins/prefix_data_loaders/pypi.py
user: login: jaimergp
body: 100%. The whole `pkg_formats` subpackage is ripe to remove from there. It's oddly specific and it should definitely move. I'll do it here now that there have been a few reviews so the larger diff won't be that scary.
created_at: 2025-05-02T12:23:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,161 @@
+# Copyright (C) 2012 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+"""Reads PyPI packages in a conda prefix that have been installed with non-conda tools."""
+
+from __future__ import annotations
+
+import re
+import sys
+import traceback
+from logging import getLogger
+from os.path import basename
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from ...auxlib.exceptions import ValidationError
+from ...common.path import (
+    get_python_site_packages_short_path,
+    win_path_ok,
+)
+from ...common.pkg_formats.python import get_site_packages_anchor_files
path: conda/plugins/prefix_data_loaders/pypi.py
user: login: jaimergp
body: Actually I'll merge this as is and proceed with the refactors it in another PR, it's getting a bit unwieldy. 
created_at: 2025-05-02T12:39:47Z
author_association: MEMBER

PR comments:
user: login: codspeed-hq[bot]
created_at: 2025-04-22T21:57:48Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/conda/conda/branches/jaimergp%3Aprefix-data-hooks)

### Merging #14780 will **improve performances by 91.93%**

<sub>Comparing <code>jaimergp:prefix-data-hooks</code> (698559e) with <code>main</code> (2283e3a)</sub>



### Summary

`⚡ 1` improvements  
`✅ 20` untouched benchmarks  



### Benchmarks breakdown

|     | Benchmark | `BASE` | `HEAD` | Change |
| --- | --------- | ----------------------- | ------------------- | ------ |
| ⚡ | `` test_env_update[libmamba] `` | 3.2 s | 1.7 s | +91.93% |

author_association: NONE
user: login: jjhelmus
created_at: 2025-05-01T18:46:41Z
body: These plugins will need the ability to register additional package types if they are to be effective. The `pypi` plugin introduced here only works because conda already has [package types for common Python distributions](https://github.com/conda/conda/blob/25.3.1/conda/models/enums.py#L119-L122) which [`conda.gateway.disk.read::read_python_record`](https://github.com/conda/conda/blob/25.3.1/conda/gateways/disk/read.py#L252-L321) uses. A future R or node plugin would not work unless the detected packages were of a type already supported by conda.

Additionally, are these plugins allowed to serialize information about the packages they detect? If so can they do this into `conda-meta`? Once done can conda manager (update/remove) these packages? What is the expected behavior if a plugin serializes this data and is then uninstalled?
author_association: CONTRIBUTOR
user: login: jezdez
created_at: 2025-05-02T08:44:10Z
body: > These plugins will need the ability to register additional package types if they are to be effective. The `pypi` plugin introduced here only works because conda already has [package types for common Python distributions](https://github.com/conda/conda/blob/25.3.1/conda/models/enums.py?rgh-link-date=2025-05-01T18%3A46%3A41Z#L119-L122) which [`conda.gateway.disk.read::read_python_record`](https://github.com/conda/conda/blob/25.3.1/conda/gateways/disk/read.py?rgh-link-date=2025-05-01T18%3A46%3A41Z#L252-L321) uses. A future R or node plugin would not work unless the detected packages were of a type already supported by conda.

True, I'd say that would be left for a future iteration, you're right that this is only halfway there.

> Additionally, are these plugins allowed to serialize information about the packages they detect? If so can they do this into `conda-meta`?

That might be a nice plugin hook, `conda_meta_serializes` or somesuch so that the history is complete. But that seems out of scope of this refactor.

> Once done can conda manager (update/remove) these packages? What is the expected behavior if a plugin serializes this data and is then uninstalled?

I'd say hypothetical language specific plugins would implement install, update, remove, etc APIs (either with separate hooks, or with with an umbrella ~`Installer` API that has those methods. I'd say we're still in the discovery phase for the API for what projects like conda-pypi might become, and how we decompose conda into an orchestrator of 3rd party package managers. Sophia's #14794 is probably the closest at the moment?
author_association: MEMBER
user: login: jaimergp
created_at: 2025-05-02T10:51:42Z
body: > These plugins will need the ability to register additional package types if they are to be effective.

Good catch. I wonder though if we should abandon this idea super-specific `PackageType`s and just have all the `conda` types, and then non-conda (with perhaps a distinction between manageable and unmanageable, but that's a solver detail that is not really used these days). 

> Additionally, are these plugins allowed to serialize information about the packages they detect? If so can they do this into conda-meta? Once done can conda manager (update/remove) these packages? What is the expected behavior if a plugin serializes this data and is then uninstalled?

I don't think that's needed or expected. `conda-meta/*.json` should only be for conda packages or, if we stretch it, packages that `conda` knows how to manage. Managing non-conda packages seems to be out of the scope for this plugin hook, where we only offer a "read-only" view of the prefix state. The idea seems to be that these `PackageRecords` only exist in memory so they are exposed in `conda list`.


author_association: MEMBER
user: login: jezdez
created_at: 2025-05-02T11:03:05Z
body: > I don't think that's needed or expected. `conda-meta/*.json` should only be for conda packages or, if we stretch it, packages that `conda` knows how to manage. 

Are you saying that the collection of plugin hooks doesn't reach the level of "management", and instead we're focusing on "orchestrating" of non-conda package managers and "use" of non-conda packages?

For the record, I think conda only really knows how to manage conda packages (in the various formats), which is a reasonable scope given the implementation complexity for Python packages alone.
author_association: MEMBER
user: login: jaimergp
created_at: 2025-05-02T11:13:37Z
body: Yes, I think the most we can do for now is to expose non-conda packages to `conda list` so it can, well, list them in the output. Maybe to the solver as well so they show up as "installed" and they are not overwritten by a conda package, but that code path is not really finalized yet.
author_association: MEMBER
user: login: jezdez
created_at: 2025-05-02T11:30:05Z
body: Gotcha, I've updated my comment a little to note that it is out of scope of this refactor here. I think we should go ahead with this PR.
author_association: MEMBER
user: login: jjhelmus
created_at: 2025-05-02T11:52:47Z
body: > Good catch. I wonder though if we should abandon this idea super-specific PackageTypes and just have all the conda types, and then non-conda (with perhaps a distinction between manageable and unmanageable, but that's a solver detail that is not really used these days).

I like this idea. Perhaps adding two new types, `OtherManageable` and `OtherNonManageable` would allow the flexibility for plugins to use.
author_association: CONTRIBUTOR