Repository: conda/conda
Pull request #14750: Teach `PrefixData` how to perform prefix checks and validations
Author: login: jaimergp
Labels: name: cla-signed
description: [bot] added once the contributor has signed the CLA
name: locked
description: [bot] locked due to inactivity
Created: 2025-04-09T20:17:01Z | Merged: 2025-04-22T14:01:52Z

<!-- 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. -->

Closes #14747 

### 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?
- [ ] 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 -->


Linked issue #14747: Consolidate prefix validation logic
### Checklist

- [x] I added a descriptive title
- [x] I searched open requests and couldn't find a duplicate

### What is the idea?

`conda`'s CLI runs a few checks to make sure that the target prefix of an operation exists, is in good shape, etc. There are a few utility functions spread around the code base, with overlapping checks, scopes and responsibilities. This is not only inefficient (e.g. repeated filesystem calls to check whether `prefix` exists), but also difficult to maintain and improve. @soapy1 has been digging this up in a couple PRs (e.g. https://github.com/conda/conda/pull/14670, which brought up [this review](https://github.com/conda/conda/pull/14670#pullrequestreview-2742443805)).

Utility functions that check prefix paths in some way:

- [`check_prefix()`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/install.py#L96)
- [`check_protected_dirs()`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/main_rename.py#L72)
- [`validate_prefix()`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/common.py#L254)
- [`validate_prefix_is_writable()`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/common.py#L271)
- [`validate_prefix_exists()`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/install.py#L68)
- [`validate_new_prefix()`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/install.py#L77)
- [`validate_prefix_name()`](https://github.com/conda/conda/blob/main/conda/base/context.py#L2131)
- [`is_conda_environment()`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/gateways/disk/test.py#L87)

And there are a few more checks without a dedicated function in the CLI layer:

- In [`conda list`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/main_list.py#L230-L233) and [`--explicit`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/main_list.py#L263-L266)
- [`conda compare`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/main_compare.py#L67) (which already uses `PrefixData`!)
- `conda [env]create|install|update|remove` have too many too list. [Example (1)](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/main_create.py#L131), [(2)](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/install.py#L106), [(3)](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/install.py#L127), [(4)](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/install.py#L243)
- [`conda activate`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/activate.py#L344)
- The [`envs_manager` helper](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/core/envs_manager.py#L108)
- Strange [`which_prefix`](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/cli/main_package.py#L293) helper in `conda package`
- [Linking logic](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/core/link.py#L976)
- [`.nonadmin` marker logic](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/misc.py#L231) for Windows

... and that's just checking for `os.path.isdir()`.


### Why is this needed?

This will also be a problem with the implementation of frozen environments (CEP 22) for `base` env protection. @travishathaway also noticed the "entanglement" in his `conda-protect` plugin (see [comment](https://github.com/conda-incubator/conda-protect/blob/9ca71859290c89cb7cb17abf51479de30fd20fcb/conda_protect/main.py#L259-L260)).

### What should happen?

I'd like to propose a refactor where we consolidate all these utility functions in a class, where we can control the common logic better.

It should have these properties:

- Common logic for path manipulation and checks.
- Awareness of stale caches.
- Methods for fine-grained checks.
- Single point of access to all prefix checks. Just finding all the utility functions (listed above) was a bit of a pain.

I think the best way to place this in is `PrefixData`, where we already have an [`is_writable` property](https://github.com/conda/conda/blob/3259a9c914446a44b12e7c4fa191972892b8e394/conda/core/prefix_data.py#L259).

### Additional Context

_No response_
Linked issue comments: user: login: jaimergp
created_at: 2025-04-09T19:24:17Z
body: Types of checks observed in the above functions (note these are fewer than the links above, highlighting the amount of overlap):

- Validate name or path:
    - Name must not contain `os.pathsep`, "/", " ", ":", "#"
    - Name must not be reserved (`base`, `root`)
    - Prefix should not contain spaces
    - Prefix must not result in a nested environment
- Check existence of `$PREFIX`, `$PREFIX/conda-meta/` and `$PREFIX/conda-meta/history`, and/or whether it is writable.
- Check that `$PREFIX` does not exist yet (so we can create it)
author_association: MEMBER

Diff:
diff --git a/conda/base/context.py b/conda/base/context.py
index c52cb9f4910..ed9011f092e 100644
--- a/conda/base/context.py
+++ b/conda/base/context.py
@@ -2128,6 +2128,9 @@ def validate_channels(channels: Iterator[str]) -> tuple[str, ...]:
     return tuple(IndexedSet(channels))
 
 
+@deprecated(
+    "25.9", "26.3", addendum="Use PrefixData.validate_name() + PrefixData.from_name()"
+)
 def validate_prefix_name(prefix_name: str, ctx: Context, allow_base=True) -> str:
     """Run various validations to make sure prefix_name is valid"""
     from ..exceptions import CondaValueError
@@ -2154,14 +2157,15 @@ def validate_prefix_name(prefix_name: str, ctx: Context, allow_base=True) -> str
 
     else:
         from ..exceptions import EnvironmentNameNotFound
+        from ..gateways.disk.create import first_writable_envs_dir
 
         try:
             return locate_prefix_by_name(prefix_name)
         except EnvironmentNameNotFound:
-            return join(_first_writable_envs_dir(), prefix_name)
+            return join(first_writable_envs_dir(), prefix_name)
 
 
-def determine_target_prefix(ctx, args=None):
+def determine_target_prefix(ctx, args=None) -> os.PathLike:
     """Get the prefix to operate in.  The prefix may not yet exist.
 
     Args:
@@ -2196,37 +2200,18 @@ def determine_target_prefix(ctx, args=None):
     elif prefix_path is not None:
         return expand(prefix_path)
     else:
-        return validate_prefix_name(prefix_name, ctx=ctx)
-
+        from ..core.prefix_data import PrefixData
 
-def _first_writable_envs_dir():
-    # Calling this function will *create* an envs directory if one does not already
-    # exist. Any caller should intend to *use* that directory for *writing*, not just reading.
-    for envs_dir in context.envs_dirs:
-        if envs_dir == os.devnull:
-            continue
+        return str(PrefixData.from_name(prefix_name).prefix_path)
 
-        # The magic file being used here could change in the future.  Don't write programs
-        # outside this code base that rely on the presence of this file.
-        # This value is duplicated in conda.gateways.disk.create.create_envs_directory().
-        envs_dir_magic_file = join(envs_dir, ".conda_envs_dir_test")
 
-        if isfile(envs_dir_magic_file):
-            try:
-                open(envs_dir_magic_file, "a").close()
-                return envs_dir
-            except OSError:
-                log.log(TRACE, "Tried envs_dir but not writable: %s", envs_dir)
-        else:
-            from ..gateways.disk.create import create_envs_directory
-
-            was_created = create_envs_directory(envs_dir)
-            if was_created:
-                return envs_dir
-
-    from ..exceptions import NoWritableEnvsDirError
+@deprecated(
+    "25.9", "26.3", addendum="Use conda.gateways.disk.create.first_writable_envs_dir"
+)
+def _first_writable_envs_dir():
+    from conda.gateways.disk.create import first_writable_envs_dir
 
-    raise NoWritableEnvsDirError(context.envs_dirs)
+    first_writable_envs_dir()
 
 
 def get_plugin_config_data(
diff --git a/conda/cli/common.py b/conda/cli/common.py
index a669da1498c..199220c8237 100644
--- a/conda/cli/common.py
+++ b/conda/cli/common.py
@@ -255,6 +255,7 @@ def check_non_admin():
         )
 
 
+@deprecated("25.9", "26.3", addendum="Use PrefixData.assert_environment()")
 def validate_prefix(prefix) -> str:
     """Verifies the prefix is a valid conda environment.
 
@@ -272,6 +273,7 @@ def validate_prefix(prefix) -> str:
     return prefix
 
 
+@deprecated("25.9", "26.3", addendum="Use PrefixData.assert_writable()")
 def validate_prefix_is_writable(prefix: str) -> str:
     """Verifies the environment directory is writable by trying to access
     the conda-meta/history file. If this file is not writable then we assume
@@ -337,14 +339,14 @@ def print_activate(env_name_or_prefix):  # pragma: no cover
             env_name_or_prefix = f'"{env_name_or_prefix}"'
         message = dals(
             f"""
-        #
-        # To activate this environment, use
-        #
-        #     $ conda activate {env_name_or_prefix}
-        #
-        # To deactivate an active environment, use
-        #
-        #     $ conda deactivate
-        """
+            #
+            # To activate this environment, use
+            #
+            #     $ conda activate {env_name_or_prefix}
+            #
+            # To deactivate an active environment, use
+            #
+            #     $ conda deactivate
+            """
         )
         print(message)  # TODO: use logger
diff --git a/conda/cli/install.py b/conda/cli/install.py
index 6663298f418..894781a6149 100644
--- a/conda/cli/install.py
+++ b/conda/cli/install.py
@@ -12,22 +12,21 @@
 
 import os
 from logging import getLogger
-from os.path import abspath, basename, exists, isdir, isfile, join
+from os.path import abspath, basename, exists, isdir
 from pathlib import Path
 
 from boltons.setutils import IndexedSet
 
 from .. import CondaError
 from ..base.constants import (
-    PREFIX_MAGIC_FILE,
     REPODATA_FN,
     ROOT_ENV_NAME,
     DepsModifier,
     UpdateModifier,
 )
-from ..base.context import context, locate_prefix_by_name
+from ..base.context import context
 from ..common.constants import NULL
-from ..common.path import is_package_file, paths_equal
+from ..common.path import is_package_file
 from ..core.index import (
     _supplement_index_with_prefix,
     calculate_channel_urls,
@@ -45,9 +44,7 @@
     CondaSystemExit,
     CondaValueError,
     DirectoryNotACondaEnvironmentError,
-    DirectoryNotFoundError,
     DryRunExit,
-    EnvironmentLocationNotFound,
     NoBaseEnvironmentError,
     PackageNotInstalledError,
     PackagesNotFoundError,
@@ -56,20 +53,20 @@
     UnsatisfiableError,
 )
 from ..gateways.disk.delete import delete_trash, path_is_clean
-from ..gateways.disk.test import is_conda_environment
 from ..history import History
 from ..misc import _get_best_prec_match, clone_env, explicit
 from ..models.match_spec import MatchSpec
 from ..models.prefix_graph import PrefixGraph
 from ..reporters import confirm_yn, get_spinner
 from . import common
-from .common import check_non_admin, validate_prefix_is_writable
+from .common import check_non_admin
 from .main_config import set_keys
 
 log = getLogger(__name__)
 stderrlog = getLogger("conda.stderr")
 
 
+@deprecated("25.9", "26.3", addendum="Use PrefixData.exists()")
 def validate_prefix_exists(prefix: str | Path) -> None:
     """
     Validate that we are receiving at least one valid value for --name or --prefix.
@@ -79,6 +76,9 @@ def validate_prefix_exists(prefix: str | Path) -> None:
         raise CondaEnvException("The environment you have specified does not exist.")
 
 
+@deprecated(
+    "25.9", "26.3", addendum="Use PrefixData.exists() + PrefixData.validate_path()"
+)
 def validate_new_prefix(dest: str, force: bool = False) -> str:
     """Ensure that the new prefix does not exist."""
     from ..base.context import context, validate_prefix_name
@@ -98,6 +98,11 @@ def validate_new_prefix(dest: str, force: bool = False) -> str:
     return dest
 
 
+@deprecated(
+    "25.9",
+    "26.3",
+    addendum="Use PrefixData.exists(), PrefixData.validate_path(), PrefixData.validate_name()",
+)
 def check_prefix(prefix: str, json=False):
     if os.pathsep in prefix:
         raise CondaValueError(
@@ -127,12 +132,13 @@ def check_prefix(prefix: str, json=False):
 
 
 def clone(src_arg, dst_prefix, json=False, quiet=False, index_args=None):
+    # Validate source
     if os.sep in src_arg:
-        src_prefix = abspath(src_arg)
-        if not isdir(src_prefix):
-            raise DirectoryNotFoundError(src_arg)
+        source_prefix_data = PrefixData(abspath(src_arg))
     else:
-        src_prefix = locate_prefix_by_name(src_arg)
+        source_prefix_data = PrefixData.from_name(src_arg)
+    source_prefix_data.assert_environment()
+    src_prefix = str(source_prefix_data.prefix_path)
 
     if not json:
         print(f"Source:      {src_prefix}")
@@ -274,30 +280,20 @@ def validate_install_command(prefix: str, command: str = "install"):
     context.validate_configuration()
     check_non_admin()
 
-    if context.force_32bit and paths_equal(prefix, context.root_prefix):
+    prefix_data = PrefixData(prefix)
+
+    if context.force_32bit and prefix_data.is_base():
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
 
-    # Ensure the prefix exists if it is meant to
-    if command == "create":
-        # new environments should not have a prefix that exists
-        pass
-    elif isdir(prefix):
-        # if the prefix exists (and this is not a new environment)
-        # the conda-meta/history file must also exist - if it does not
-        # then this is not a valid conda environment
-        if is_conda_environment(prefix):
-            # if the prefix exists, ensure that it is writable
-            validate_prefix_is_writable(prefix)
-        else:
-            if paths_equal(prefix, context.conda_prefix):
-                raise NoBaseEnvironmentError()
-            else:
-                if not path_is_clean(prefix):
-                    raise DirectoryNotACondaEnvironmentError(prefix)
-    else:
-        # if this is not a new env and the prefix does not exist, then
-        # there is no existing environment - this is an error
-        raise EnvironmentLocationNotFound(prefix)
+    if command in ("install", "update", "remove"):
+        try:
+            prefix_data.assert_writable()
+        except DirectoryNotACondaEnvironmentError as exc:
+            if prefix_data == PrefixData(context.conda_prefix):
+                raise NoBaseEnvironmentError() from exc
+            delete_trash(prefix)
+            if not path_is_clean(prefix):
+                raise
 
 
 def ensure_update_specs_exist(prefix: str, specs: list[str]):
@@ -346,24 +342,10 @@ def install(args, parser, command="install"):
     if context.use_only_tar_bz2:
         args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-
-    if isupdate or isinstall or isremove:
-        if isdir(prefix):
-            delete_trash(prefix)
-            if not isfile(join(prefix, PREFIX_MAGIC_FILE)):
-                if paths_equal(prefix, context.conda_prefix):
-                    raise NoBaseEnvironmentError()
-                else:
-                    if not path_is_clean(prefix):
-                        raise DirectoryNotACondaEnvironmentError(prefix)
-            else:
-                validate_prefix_is_writable(prefix)
-        else:
-            raise EnvironmentLocationNotFound(prefix)
+    newenv = command == "create"
+    isupdate = command == "update"
+    isinstall = command == "install"
+    isremove = command == "remove"
 
     # collect packages provided from the command line
     args_packages = [s.strip("\"'") for s in args.packages]
@@ -424,9 +406,7 @@ def install(args, parser, command="install"):
     if REPODATA_FN not in repodata_fns:
         repodata_fns.append(REPODATA_FN)
 
-    args_set_update_modifier = (
-        hasattr(args, "update_modifier") and args.update_modifier != NULL
-    )
+    args_set_update_modifier = getattr(args, "update_modifier", NULL) != NULL
     # This helps us differentiate between an update, the --freeze-installed option, and the retry
     # behavior in our initial fast frozen solve
     _should_retry_unfrozen = (
diff --git a/conda/cli/main_compare.py b/conda/cli/main_compare.py
index 2fe18b58a2e..d893a50071e 100644
--- a/conda/cli/main_compare.py
+++ b/conda/cli/main_compare.py
@@ -101,15 +101,15 @@ def compare_packages(active_pkgs, specification_pkgs) -> tuple[int, list[str]]:
 
 def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..base.context import context
+    from ..core.prefix_data import PrefixData
     from ..env import specs
-    from ..exceptions import EnvironmentLocationNotFound, SpecNotFound
+    from ..exceptions import SpecNotFound
     from ..gateways.connection.session import CONDA_SESSION_SCHEMES
-    from ..gateways.disk.test import is_conda_environment
     from .common import stdout_json
 
-    prefix = context.target_prefix
-    if not is_conda_environment(prefix):
-        raise EnvironmentLocationNotFound(prefix)
+    prefix_data = PrefixData.from_context()
+    prefix_data.assert_environment()
+    prefix = str(prefix_data.prefix_path)
 
     try:
         url_scheme = args.file.split("://", 1)[0]
diff --git a/conda/cli/main_config.py b/conda/cli/main_config.py
index 045fb4c4cee..974905b07aa 100644
--- a/conda/cli/main_config.py
+++ b/conda/cli/main_config.py
@@ -531,8 +531,7 @@ def execute_config(args, parser):
     from ..common.io import timeout
     from ..common.iterators import groupby_to_dict as groupby
     from ..common.serialize import yaml_round_trip_load
-    from ..exceptions import EnvironmentLocationNotFound
-    from ..gateways.disk.test import is_conda_environment
+    from ..core.prefix_data import PrefixData
 
     stdout_write = getLogger("conda.stdout").info
     stderr_write = getLogger("conda.stderr").info
@@ -680,10 +679,9 @@ def execute_config(args, parser):
     elif args.file:
         rc_path = args.file
     elif args.prefix or args.name:
-        if is_conda_environment(context.target_prefix):
-            rc_path = join(context.target_prefix, ".condarc")
-        else:
-            raise EnvironmentLocationNotFound(context.target_prefix)
+        prefix_data = PrefixData.from_context()
+        prefix_data.assert_environment()
+        rc_path = str(prefix_data.prefix_path / ".condarc")
     else:
         rc_path = user_rc_path
 
diff --git a/conda/cli/main_create.py b/conda/cli/main_create.py
index 1e43335ed36..34ec8ea8971 100644
--- a/conda/cli/main_create.py
+++ b/conda/cli/main_create.py
@@ -8,7 +8,6 @@
 from __future__ import annotations
 
 from logging import getLogger
-from os.path import isdir
 from typing import TYPE_CHECKING
 
 from ..notices import notices
@@ -91,15 +90,12 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
 
     from ..base.constants import UNUSED_ENV_NAME
     from ..base.context import context
-    from ..cli.main_rename import check_protected_dirs
-    from ..common.path import paths_equal
+    from ..core.prefix_data import PrefixData
     from ..exceptions import ArgumentError, CondaValueError, TooManyArgumentsError
     from ..gateways.disk.delete import rm_rf
-    from ..gateways.disk.test import is_conda_environment
-    from ..misc import touch_nonadmin
     from ..reporters import confirm_yn
     from .common import print_activate, validate_subdir_config
-    from .install import check_prefix, install, install_clone
+    from .install import install, install_clone
 
     # Ensure provided combination of command line argments are valid
     # At least one of the arguments -n/--name -p/--prefix is required
@@ -120,16 +116,9 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
             list(args.packages),
             "did not expect any arguments for --clone",
         )
+    prefix_data = PrefixData.from_context(validate=True)
 
-    # Validate configuration
-    # Ensure no protected dirs are getting overwritten
-    check_protected_dirs(context.target_prefix)
-
-    # Ensure the user wants to update the provided prefix if there is already
-    # an installed conda environment or other files in the target
-    if is_conda_environment(context.target_prefix):
-        if paths_equal(context.target_prefix, context.root_prefix):
-            raise CondaValueError("The target prefix is the base prefix. Aborting.")
+    if prefix_data.is_environment():
         if context.dry_run:
             # Taking the "easy" way out, rather than trying to fake removing
             # the existing environment before creating a new one.
@@ -145,7 +134,7 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
         )
         log.info("Removing existing environment %s", context.target_prefix)
         rm_rf(context.target_prefix)
-    elif isdir(context.target_prefix):
+    elif prefix_data.exists():
         confirm_yn(
             f"WARNING: A directory already exists at the target location '{context.target_prefix}'\n"
             "but it is not a conda environment.\n"
@@ -157,16 +146,13 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
     # Ensure the subdir config is valid
     validate_subdir_config()
 
-    # Ensure the provided prefix is of the right form
-    check_prefix(context.target_prefix)
-
     # Run appropriate install
     if args.clone:
         install_clone(args, parser)
     else:
         install(args, parser, "create")
     # Run post-install steps applicable to all new environments
-    touch_nonadmin(context.target_prefix)
+    prefix_data.set_nonadmin()
     print_activate(args.name or context.target_prefix)
 
     return 0
diff --git a/conda/cli/main_env_create.py b/conda/cli/main_env_create.py
index 2dc6a4c779a..1416480adbc 100644
--- a/conda/cli/main_env_create.py
+++ b/conda/cli/main_env_create.py
@@ -109,15 +109,12 @@ def configure_parser(sub_parsers: _SubParsersAction, **kwargs) -> ArgumentParser
 def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..auxlib.ish import dals
     from ..base.context import context, determine_target_prefix
-    from ..cli.main_rename import check_protected_dirs
     from ..core.prefix_data import PrefixData
     from ..env import specs
     from ..env.env import get_filename, print_result
     from ..env.installers.base import get_installer
     from ..exceptions import InvalidInstaller
     from ..gateways.disk.delete import rm_rf
-    from ..misc import touch_nonadmin
-    from . import install as cli_install
 
     spec = specs.detect(
         name=args.name,
@@ -132,11 +129,13 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
         args.name = env.name
 
     prefix = determine_target_prefix(context, args)
+    prefix_data = PrefixData(prefix)
 
-    if args.yes and prefix != context.root_prefix and os.path.exists(prefix):
+    if args.yes and not prefix_data.is_base() and prefix_data.exists():
         rm_rf(prefix)
-    cli_install.check_prefix(prefix, json=args.json)
-    check_protected_dirs(prefix)
+
+    prefix_data.validate_path()
+    prefix_data.validate_name()
 
     # TODO, add capability
     # common.ensure_override_channels_requires_channel(args)
@@ -194,10 +193,9 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
                     )
 
         if env.variables:
-            pd = PrefixData(prefix)
-            pd.set_environment_env_vars(env.variables)
+            prefix_data.set_environment_env_vars(env.variables)
 
-        touch_nonadmin(prefix)
+        prefix_data.set_nonadmin()
         print_result(args, prefix, result)
 
     return 0
diff --git a/conda/cli/main_env_update.py b/conda/cli/main_env_update.py
index 1f8aab014ad..f5febf2bcf1 100644
--- a/conda/cli/main_env_update.py
+++ b/conda/cli/main_env_update.py
@@ -90,7 +90,6 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..env.env import get_filename, print_result
     from ..env.installers.base import get_installer
     from ..exceptions import CondaEnvException, InvalidInstaller
-    from ..misc import touch_nonadmin
 
     spec = install_specs.detect(
         name=args.name,
@@ -101,7 +100,7 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
 
     if not (args.name or args.prefix):
         if not env.name:
-            # Note, this is a hack fofr get_prefix that assumes argparse results
+            # Note, this is a hack for get_prefix that assumes argparse results
             # TODO Refactor common.get_prefix
             name = os.environ.get("CONDA_DEFAULT_ENV", False)
             if not name:
@@ -124,6 +123,7 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
         args.name = env.name
 
     prefix = determine_target_prefix(context, args)
+    prefix_data = PrefixData(prefix)
     # CAN'T Check with this function since it assumes we will create prefix.
     # cli_install.check_prefix(prefix, json=args.json)
 
@@ -161,10 +161,9 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
         result[installer_type] = installer.install(prefix, specs, args, env)
 
     if env.variables:
-        pd = PrefixData(prefix)
-        pd.set_environment_env_vars(env.variables)
+        prefix_data.set_environment_env_vars(env.variables)
 
-    touch_nonadmin(prefix)
+    prefix_data.set_nonadmin()
     print_result(args, prefix, result)
 
     return 0
diff --git a/conda/cli/main_install.py b/conda/cli/main_install.py
index 5b3901dc522..7239efbdb70 100644
--- a/conda/cli/main_install.py
+++ b/conda/cli/main_install.py
@@ -126,7 +126,6 @@ def configure_parser(sub_parsers: _SubParsersAction, **kwargs) -> ArgumentParser
 def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..base.context import context
     from ..exceptions import CondaValueError
-    from .common import validate_prefix
     from .install import get_revision, install, install_revision
 
     if context.force:
@@ -147,9 +146,6 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
             "too few arguments, must supply command line packages, --file or --revision"
         )
 
-    # Ensure the target prefix is a valid conda environment
-    validate_prefix(context.target_prefix)
-
     if args.revision:
         install_revision(args, parser)
     else:
diff --git a/conda/cli/main_list.py b/conda/cli/main_list.py
index 7508fba6984..b34b0e41efa 100644
--- a/conda/cli/main_list.py
+++ b/conda/cli/main_list.py
@@ -162,6 +162,7 @@ def list_packages(
     format="human",
     reverse=False,
     show_channel_urls=None,
+    reload_records=True,
 ):
     from ..base.constants import DEFAULTS_CHANNEL_NAME
     from ..base.context import context
@@ -170,10 +171,10 @@ def list_packages(
 
     res = 0
 
-    installed = sorted(
-        PrefixData(prefix, pip_interop_enabled=True).iter_records(),
-        key=lambda x: x.name,
-    )
+    prefix_data = PrefixData(prefix, pip_interop_enabled=True)
+    if reload_records:
+        prefix_data.load()
+    installed = sorted(prefix_data.iter_records(), key=lambda x: x.name)
 
     packages = []
     for prec in get_packages(installed, regex) if regex else installed:
@@ -283,15 +284,13 @@ def print_explicit(prefix, add_md5=False, remove_auth=True, add_sha256=False):
 
 def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..base.context import context
-    from ..gateways.disk.test import is_conda_environment
+    from ..core.prefix_data import PrefixData
     from ..history import History
     from .common import stdout_json
 
-    prefix = context.target_prefix
-    if not is_conda_environment(prefix):
-        from ..exceptions import EnvironmentLocationNotFound
-
-        raise EnvironmentLocationNotFound(prefix)
+    prefix_data = PrefixData.from_context()
+    prefix_data.assert_environment()
+    prefix = str(prefix_data.prefix_path)
 
     if args.md5 and args.sha256:
         from ..exceptions import ArgumentError
diff --git a/conda/cli/main_remove.py b/conda/cli/main_remove.py
index f249ced92d0..d8a071b04e8 100644
--- a/conda/cli/main_remove.py
+++ b/conda/cli/main_remove.py
@@ -7,7 +7,6 @@
 
 import logging
 from argparse import ArgumentParser, Namespace, _SubParsersAction
-from os.path import isfile, join
 
 from ..reporters import confirm_yn
 
@@ -147,12 +146,9 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..exceptions import (
         CondaEnvironmentError,
         CondaValueError,
-        DirectoryNotACondaEnvironmentError,
-        EnvironmentLocationNotFound,
         PackagesNotFoundError,
     )
     from ..gateways.disk.delete import path_is_clean, rm_rf
-    from ..gateways.disk.test import is_conda_environment
     from ..models.match_spec import MatchSpec
     from .common import check_non_admin, specs_from_args
     from .install import handle_txn
@@ -162,13 +158,12 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
             'no package names supplied,\n       try "conda remove -h" for more details'
         )
 
-    prefix = context.target_prefix
+    prefix_data = PrefixData.from_context()
+    prefix_data.assert_environment()
+    prefix = str(prefix_data.prefix_path)
     check_non_admin()
 
-    if not is_conda_environment(prefix):
-        raise EnvironmentLocationNotFound(prefix)
-
-    if args.all and prefix == context.default_prefix:
+    if args.all and prefix_data == PrefixData(context.default_prefix):
         msg = "Cannot remove current environment. Deactivate and run conda remove again"
         raise CondaEnvironmentError(msg)
 
@@ -176,12 +171,10 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
         return 0
 
     if args.all:
-        if prefix == context.root_prefix:
+        if prefix_data.is_base():
             raise CondaEnvironmentError(
                 "cannot remove root environment, add -n NAME or -p PREFIX option"
             )
-        if not isfile(join(prefix, "conda-meta", "history")):
-            raise DirectoryNotACondaEnvironmentError(prefix)
         if not args.json:
             print(f"\nRemove all packages in environment {prefix}:\n")
 
@@ -206,7 +199,9 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
             if not args.keep_env:
                 if not args.json:
                     confirm_yn(
-                        f"Everything found within the environment ({prefix}), including any conda environment configurations and any non-conda files, will be deleted. Do you wish to continue?\n",
+                        f"Everything found within the environment ({prefix}), including "
+                        "any conda environment configurations and any non-conda files, will "
+                        "be deleted. Do you wish to continue?\n",
                         default="no",
                         dry_run=False,
                     )
diff --git a/conda/cli/main_rename.py b/conda/cli/main_rename.py
index 1946610cfd7..e4cb12a2784 100644
--- a/conda/cli/main_rename.py
+++ b/conda/cli/main_rename.py
@@ -14,7 +14,6 @@
 
 from conda.deprecations import deprecated
 from conda.exceptions import CondaEnvException
-from conda.gateways.disk.test import is_conda_environment
 
 if TYPE_CHECKING:
     from argparse import ArgumentParser, Namespace, _SubParsersAction
@@ -69,8 +68,10 @@ def configure_parser(sub_parsers: _SubParsersAction, **kwargs) -> ArgumentParser
     return p
 
 
+@deprecated("25.9", "26.3", addendum="Use PrefixData.validate_path()")
 def check_protected_dirs(prefix: str | Path, json: bool = False) -> None:
     """Ensure that the new prefix does not contain protected directories."""
+    from conda.gateways.disk.test import is_conda_environment
 
     if is_conda_environment(Path(prefix).parent):
         raise CondaEnvException(
@@ -84,6 +85,11 @@ def check_protected_dirs(prefix: str | Path, json: bool = False) -> None:
         )
 
 
+@deprecated(
+    "25.9",
+    "26.3",
+    addendum="Use PrefixData.validate_path(), PrefixData.validate_name()",
+)
 def validate_src() -> str:
     """
     Ensure that we are receiving at least one valid value for the environment
@@ -133,12 +139,32 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..base.constants import DRY_RUN_PREFIX
     from ..base.context import context
     from ..cli import install
+    from ..core.prefix_data import PrefixData
     from ..gateways.disk.delete import rm_rf
     from ..gateways.disk.update import rename_context
-    from .install import validate_new_prefix
 
-    source = validate_src()
-    destination = validate_new_prefix(args.destination, force=args.yes)
+    # Validate source
+    source_prefix_data = PrefixData.from_context()
+    source_prefix_data.assert_environment()
+    if source_prefix_data.is_base():
+        raise CondaEnvException("The 'base' environment cannot be renamed")
+    if context.active_prefix and source_prefix_data.prefix_path.samefile(
+        context.active_prefix
+    ):
+        raise CondaEnvException("Cannot rename the active environment")
+    source = str(source_prefix_data.prefix_path)
+
+    # Validate destination
+    if os.sep in args.destination:
+        dest_prefix_data = PrefixData(args.destination)
+        dest_prefix_data.validate_path(expand_path=True)
+    else:
+        dest_prefix_data = PrefixData.from_name(args.destination)
+    destination = str(dest_prefix_data.prefix_path)
+    if not args.yes and dest_prefix_data.exists():
+        raise CondaEnvException(
+            f"The environment '{dest_prefix_data.prefix_path}' already exists. Override with --yes."
+        )
 
     def clone_and_remove() -> None:
         actions: tuple[partial, ...] = (
diff --git a/conda/cli/main_run.py b/conda/cli/main_run.py
index b2af9173806..dc746466202 100644
--- a/conda/cli/main_run.py
+++ b/conda/cli/main_run.py
@@ -88,15 +88,17 @@ def configure_parser(sub_parsers: _SubParsersAction, **kwargs) -> ArgumentParser
 def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..base.context import context
     from ..common.compat import encode_environment
+    from ..core.prefix_data import PrefixData
     from ..gateways.disk.delete import rm_rf
     from ..gateways.subprocess import subprocess_call
     from ..utils import wrap_subprocess_call
-    from .common import validate_prefix
 
+    prefix_data = PrefixData.from_context()
+    prefix_data.assert_environment()
     # create run script
     script, command = wrap_subprocess_call(
         context.root_prefix,
-        validate_prefix(context.target_prefix),  # ensure prefix exists
+        str(prefix_data.prefix_path),
         args.dev,
         args.debug_wrapper_scripts,
         args.executable_call,
diff --git a/conda/core/envs_manager.py b/conda/core/envs_manager.py
index 5002a5a77eb..150a0b825c3 100644
--- a/conda/core/envs_manager.py
+++ b/conda/core/envs_manager.py
@@ -156,7 +156,7 @@ def list_all_known_prefixes() -> list[str]:
         for path in (
             entry.path for envs_dir in envs_dirs for entry in os.scandir(envs_dir)
         )
-        if path not in all_env_paths and is_conda_environment(path)
+        if path not in all_env_paths and PrefixData(path).is_environment()
     )
 
     all_env_paths.add(context.root_prefix)
diff --git a/conda/core/link.py b/conda/core/link.py
index c2a0d2d182c..dbdd373f8ed 100644
--- a/conda/core/link.py
+++ b/conda/core/link.py
@@ -53,7 +53,6 @@
 from ..gateways.disk.read import isfile, lexists, read_package_info
 from ..gateways.disk.test import (
     hardlink_supported,
-    is_conda_environment,
     softlink_supported,
 )
 from ..gateways.subprocess import subprocess_call
@@ -248,7 +247,7 @@ def nothing_to_do(self):
         return not any(
             (stp.unlink_precs or stp.link_precs) for stp in self.prefix_setups.values()
         ) and all(
-            is_conda_environment(stp.target_prefix)
+            PrefixData(stp.target_prefix).is_environment()
             for stp in self.prefix_setups.values()
         )
 
diff --git a/conda/core/prefix_data.py b/conda/core/prefix_data.py
index 1f4ddd8f2b8..267a8b021f7 100644
--- a/conda/core/prefix_data.py
+++ b/conda/core/prefix_data.py
@@ -17,12 +17,20 @@
     CONDA_ENV_VARS_UNSET_VAR,
     CONDA_PACKAGE_EXTENSIONS,
     PREFIX_MAGIC_FILE,
+    PREFIX_NAME_DISALLOWED_CHARS,
     PREFIX_STATE_FILE,
+    ROOT_ENV_NAME,
 )
-from ..base.context import context
+from ..base.context import context, locate_prefix_by_name
+from ..common.compat import on_win
 from ..common.constants import NULL
 from ..common.io import time_recorder
-from ..common.path import get_python_site_packages_short_path, win_path_ok
+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
@@ -30,10 +38,15 @@
 from ..exceptions import (
     BasicClobberError,
     CondaDependencyError,
+    CondaValueError,
     CorruptedEnvironmentError,
+    DirectoryNotACondaEnvironmentError,
+    EnvironmentLocationNotFound,
+    EnvironmentNameNotFound,
+    EnvironmentNotWritableError,
     maybe_raise,
 )
-from ..gateways.disk.create import write_as_json_to_file
+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
@@ -54,19 +67,35 @@ def __call__(
         cls,
         prefix_path: str | os.PathLike | Path,
         pip_interop_enabled: bool | None = None,
-    ):
+    ) -> PrefixData:
         if isinstance(prefix_path, PrefixData):
             return prefix_path
-        elif (prefix_path := Path(prefix_path)) in PrefixData._cache_:
-            return PrefixData._cache_[prefix_path]
+        prefix_path = Path(prefix_path)
+        cache_key = prefix_path, pip_interop_enabled
+        if cache_key in PrefixData._cache_:
+            return PrefixData._cache_[cache_key]
         else:
             prefix_data_instance = super().__call__(prefix_path, pip_interop_enabled)
-            PrefixData._cache_[prefix_path] = prefix_data_instance
+            PrefixData._cache_[cache_key] = prefix_data_instance
             return prefix_data_instance
 
 
 class PrefixData(metaclass=PrefixDataType):
-    _cache_: dict[Path, PrefixData] = {}
+    """
+    The PrefixData class aims to be the representation of the state
+    of a conda environment on disk. The directory where the environment
+    lives is called prefix.
+
+    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 writing environment-specific configuration (env vars, state file,
+      nonadmin markers, etc)
+    - Existence checks and validations of name, path, and magic files / markers
+    """
+
+    _cache_: dict[tuple[Path, bool | None], PrefixData] = {}
 
     def __init__(
         self,
@@ -76,6 +105,7 @@ def __init__(
         # pip_interop_enabled is a temporary parameter; DO NOT USE
         # TODO: when removing pip_interop_enabled, also remove from meta class
         self.prefix_path = Path(prefix_path)
+        self._magic_file = self.prefix_path / PREFIX_MAGIC_FILE
         self.__prefix_records = None
         self.__is_writable = NULL
         self._pip_interop_enabled = (
@@ -84,6 +114,59 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs) -> PrefixData:
+        """
+        Creates a PrefixData instance from an environment name.
+
+        The name will be validated with `PrefixData.validate_name()` if it does not exist.
+
+        :param name: The name of the environment. Must not contain path separators (/, \\).
+        :raises CondaValueError: If `name` contains a path separator.
+        """
+        if "/" in name or "\\" in name:
+            raise CondaValueError("Environment names cannot contain path separators")
+        try:
+            return cls(locate_prefix_by_name(name))
+        except EnvironmentNameNotFound:
+            cls(name).validate_name()
+            return cls(Path(first_writable_envs_dir(), name), **kwargs)
+
+    @classmethod
+    def from_context(cls, validate: bool = False) -> PrefixData:
+        """
+        Creates a PrefixData instance from the path specified by `context.target_prefix`.
+
+        The path and name will be validated with `PrefixData.validate_path()` and
+        `PrefixData.validate_name()`, respectively, if `validate` is `True`.
+
+        :param validate: Whether the path and name should be validated. Useful for environments
+            about to be created.
+        """
+        inst = cls(context.target_prefix)
+        if validate:
+            inst.validate_path()
+            inst.validate_name()
+        return inst
+
+    @property
+    def name(self) -> str:
+        """
+        Returns the name of the environment, if available.
+
+        If the environment doesn't live in one the configured `envs_dirs`, an empty
+        string is returned. The construct `prefix_data.name or prefix_data.prefix_path` can
+        be helpful in those cases.
+        """
+        if self == PrefixData(context.root_prefix):
+            return ROOT_ENV_NAME
+        for envs_dir in context.envs_dirs:
+            if paths_equal(envs_dir, self.prefix_path.parent):
+                return self.prefix_path.name
+        return ""
+
+    # region Checks
+
     def __eq__(self, other: Any) -> bool:
         if not isinstance(other, PrefixData):
             return False
@@ -97,6 +180,134 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self) -> bool:
+        """
+        Check whether the PrefixData path exists and is a directory.
+        """
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self) -> bool:
+        """
+        Check whether the PrefixData path is a valida conda environment.
+
+        This is assessed by checking if `conda-meta/history` marker file exists.
+        """
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self) -> bool:
+        """
+        Check whether the configured path refers to the `base` environment.
+        """
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self) -> bool:
+        """
+        Check whether the configured path is writable. This is assessed by checking
+        whether `conda-meta/history` is writable. It if is, it is assumed that the rest
+        of the directory tree is writable too.
+
+        Note: The value is cached in the instance. Use `.assert_writable()` for a non-
+        cached check.
+        """
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
+        """
+        Check whether the environment path exists.
+
+        :raises EnvironmentLocationNotFound: If the check returns False.
+        """
+        if not self.exists():
+            raise EnvironmentLocationNotFound(self.prefix_path)
+
+    def assert_environment(self):
+        """
+        Check whether the environment path exists and is a valid conda environment.
+
+        :raises DirectoryNotACondaEnvironmentError: If the check returns False.
+        """
+        self.assert_exists()
+        if not self.is_environment():
+            raise DirectoryNotACondaEnvironmentError(self.prefix_path)
+
+    def assert_writable(self):
+        """
+        Check whether the environment path is a valid conda environment and is writable.
+
+        :raises EnvironmentNotWritableError: If the check returns False.
+        """
+        self.assert_environment()
+        if not file_path_is_writable(self._magic_file):
+            raise EnvironmentNotWritableError(self.prefix_path)
+
+    def validate_path(self, expand_path: bool = False):
+        """
+        Validate the path of the environment.
+
+        It runs the following checks:
+
+        - Make sure the path does not contain `:` or `;` (OS-dependent).
+        - Disallow immediately nested environments (e.g. `$CONDA_ROOT` and `$CONDA_ROOT/my-env`).
+        - Warn if there are spaces in the path.
+
+        :param expand_path: Whether to process `~` and environment variables in the string.
+            The expanded value will replace `.prefix_path`.
+        :raises CondaValueError: If the environment contains `:`, `;`, or is nested.
+        """
+        prefix_str = str(self.prefix_path)
+        if expand_path:
+            prefix_str = expand(prefix_str)
+            self.prefix_path = Path(prefix_str)
+
+        if os.pathsep in prefix_str:
+            raise CondaValueError(
+                f"Environment paths cannot contain '{os.pathsep}'. Prefix: '{prefix_str}'"
+            )
+
+        if " " in prefix_str:
+            log.warning(
+                "Environment paths should not contain spaces. Prefix: '%s'",
+                prefix_str,
+            )
+        parent = self.__class__(self.prefix_path.parent)
+        if parent.is_environment():
+            raise CondaValueError(
+                "Environment paths cannot be immediately nested under another conda environment."
+            )
+
+    def validate_name(self, allow_base: bool = False):
+        """
+        Validate the name of the environment.
+
+        :param allow_base: Whether to allow `base` as a valid name.
+        :raises CondaValueError: If the name is protected, or if it contains disallowed characters
+            (`/`, ` `, `:`, `#`).
+        """
+        if not allow_base and self.name in (ROOT_ENV_NAME, "root"):
+            raise CondaValueError(f"'{self.name}' is a reserved environment name")
+
+        if PREFIX_NAME_DISALLOWED_CHARS.intersection(self.prefix_path.name):
+            raise CondaValueError(
+                "Environment names cannot contain any of these characters: "
+                f"{PREFIX_NAME_DISALLOWED_CHARS}"
+            )
+
+    # endregion
+    # region Records
+
     @time_recorder(module_name=__name__)
     def load(self):
         self.__prefix_records = {}
@@ -255,16 +466,8 @@ def _load_single_record(self, prefix_record_json_path):
 
             self.__prefix_records[prefix_record.name] = prefix_record
 
-    @property
-    def is_writable(self):
-        if self.__is_writable == NULL:
-            test_path = self.prefix_path / PREFIX_MAGIC_FILE
-            if not test_path.is_file():
-                is_writable = None
-            else:
-                is_writable = file_path_is_writable(test_path)
-            self.__is_writable = is_writable
-        return self.__is_writable
+    # endregion
+    # region Python records
 
     @property
     def _python_pkg_record(self):
@@ -380,6 +583,9 @@ def _load_site_packages(self):
 
         return new_packages
 
+    # endregion
+    # region State and environment variables
+
     def _get_environment_state_file(self):
         env_vars_file = self.prefix_path / PREFIX_STATE_FILE
         if lexists(env_vars_file):
@@ -423,6 +629,14 @@ def unset_environment_env_vars(self, env_vars):
             self._write_environment_state_file(env_state_file)
         return env_state_file.get("env_vars")
 
+    def set_nonadmin(self):
+        """Creates $PREFIX/.nonadmin if sys.prefix/.nonadmin exists (on Windows)."""
+        if on_win and Path(context.root_prefix, ".nonadmin").is_file():
+            self.prefix_path.mkdir(parents=True, exist_ok=True)
+            (self.prefix_path / ".nonadmin").touch()
+
+    # endregion
+
 
 def get_conda_anchor_files_and_records(site_packages_short_path, python_records):
     """Return the anchor files for the conda records of python packages."""
@@ -489,10 +703,12 @@ def get_python_version_for_prefix(prefix) -> 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 in sorted(PrefixData._cache_, reverse=True):
+    for prefix, pip_interop in sorted(
+        PrefixData._cache_, reverse=True, key=lambda key: key[0]
+    ):
         try:
             path.relative_to(prefix)
-            del PrefixData._cache_[prefix]
+            del PrefixData._cache_[(prefix, pip_interop)]
             return True
         except ValueError:
             # ValueError: path is not relative to prefix
diff --git a/conda/gateways/disk/create.py b/conda/gateways/disk/create.py
index d7856a5cab0..c51e4a866cb 100644
--- a/conda/gateways/disk/create.py
+++ b/conda/gateways/disk/create.py
@@ -20,7 +20,12 @@
 from ...common.constants import TRACE
 from ...common.path import ensure_pad, expand, win_path_double_escape, win_path_ok
 from ...common.serialize import json_dump
-from ...exceptions import BasicClobberError, CondaOSError, maybe_raise
+from ...exceptions import (
+    BasicClobberError,
+    CondaOSError,
+    NoWritableEnvsDirError,
+    maybe_raise,
+)
 from ...models.enums import LinkType
 from . import mkdir_p
 from .delete import path_is_clean, rm_rf
@@ -497,3 +502,29 @@ def create_envs_directory(envs_dir):
         else:
             raise
     return True
+
+
+def first_writable_envs_dir(create=True):
+    # Calling this function will *create* an envs directory if one does not already
+    # exist. Any caller should intend to *use* that directory for *writing*, not just reading.
+    for envs_dir in context.envs_dirs:
+        if envs_dir == os.devnull:
+            continue
+
+        # The magic file being used here could change in the future.  Don't write programs
+        # outside this code base that rely on the presence of this file.
+        # This value is duplicated in conda.gateways.disk.create.create_envs_directory().
+        envs_dir_magic_file = join(envs_dir, ".conda_envs_dir_test")
+
+        if isfile(envs_dir_magic_file):
+            try:
+                open(envs_dir_magic_file, "a").close()
+                return envs_dir
+            except OSError:
+                log.log(TRACE, "Tried envs_dir but not writable: %s", envs_dir)
+        elif create:
+            was_created = create_envs_directory(envs_dir)
+            if was_created:
+                return envs_dir
+
+    raise NoWritableEnvsDirError(context.envs_dirs)
diff --git a/conda/gateways/disk/test.py b/conda/gateways/disk/test.py
index 45b5799b483..66f7ab21542 100644
--- a/conda/gateways/disk/test.py
+++ b/conda/gateways/disk/test.py
@@ -11,6 +11,7 @@
 from ...base.constants import PREFIX_MAGIC_FILE
 from ...common.constants import TRACE
 from ...common.path import expand
+from ...deprecations import deprecated
 from ...models.enums import LinkType
 from .create import create_link
 from .delete import rm_rf
@@ -84,5 +85,6 @@ def softlink_supported(source_file, dest_dir):
         rm_rf(test_path)
 
 
+@deprecated("25.9", "26.3", addendum="Use PrefixData.is_environment()")
 def is_conda_environment(prefix):
     return isfile(join(prefix, PREFIX_MAGIC_FILE))
diff --git a/conda/misc.py b/conda/misc.py
index 35e0a5c7fa2..1b09baa7bcf 100644
--- a/conda/misc.py
+++ b/conda/misc.py
@@ -226,6 +226,7 @@ def untracked(prefix, exclude_self_build=False):
     }
 
 
+@deprecated("25.9", "26.3", addendum="Use PrefixData.set_nonadmin()")
 def touch_nonadmin(prefix):
     """Creates $PREFIX/.nonadmin if sys.prefix/.nonadmin exists (on Windows)."""
     if on_win and exists(join(context.root_prefix, ".nonadmin")):
diff --git a/conda/plugins/subcommands/doctor/__init__.py b/conda/plugins/subcommands/doctor/__init__.py
index a155d86a5a4..35c119349f2 100644
--- a/conda/plugins/subcommands/doctor/__init__.py
+++ b/conda/plugins/subcommands/doctor/__init__.py
@@ -15,8 +15,7 @@
     add_parser_prefix,
     add_parser_verbose,
 )
-from ....exceptions import EnvironmentLocationNotFound
-from ....gateways.disk.test import is_conda_environment
+from ....core.prefix_data import PrefixData
 from ... import CondaSubcommand, hookimpl
 
 if TYPE_CHECKING:
@@ -31,12 +30,11 @@ def configure_parser(parser: ArgumentParser):
 
 def execute(args: Namespace) -> None:
     """Run registered health_check plugins."""
-    prefix = context.target_prefix
-    if not is_conda_environment(prefix):
-        raise EnvironmentLocationNotFound(prefix)
-    else:
-        print(f"Environment Health Report for: {prefix}\n")
-        context.plugin_manager.invoke_health_checks(prefix, context.verbose)
+    prefix_data = PrefixData.from_context()
+    prefix_data.assert_environment()
+    prefix = str(prefix_data.prefix_path)
+    print(f"Environment Health Report for: {prefix}\n")
+    context.plugin_manager.invoke_health_checks(prefix, context.verbose)
 
 
 @hookimpl
diff --git a/conda/testing/integration.py b/conda/testing/integration.py
index 73650e56f27..bd08e41c007 100644
--- a/conda/testing/integration.py
+++ b/conda/testing/integration.py
@@ -74,9 +74,13 @@ class Commands:
 def package_is_installed(
     prefix: str | os.PathLike | Path,
     spec: str | MatchSpec,
+    reload_records: bool = True,
 ) -> PrefixRecord | None:
     spec = MatchSpec(spec)
-    prefix_recs = tuple(PrefixData(str(prefix), pip_interop_enabled=True).query(spec))
+    prefix_data = PrefixData(prefix, pip_interop_enabled=True)
+    if reload_records:
+        prefix_data.load()
+    prefix_recs = tuple(prefix_data.query(spec))
     if not prefix_recs:
         return None
     elif len(prefix_recs) > 1:
diff --git a/news/14750-prefixdata b/news/14750-prefixdata
new file mode 100644
index 00000000000..7556afbe095
--- /dev/null
+++ b/news/14750-prefixdata
@@ -0,0 +1,30 @@
+### Enhancements
+
+* Extend `PrefixData` API with two constructors (`from_name()`, `from_context()`), a new property (`name`), several boolean-returning methods (`exists()`, `is_environment()`, `is_base()`), and a few exception-raising methods (`assert_exists()`, `assert_environment()`, `assert_writable()`, `validate_path()`, `validate_name()`). An additional method `set_nonadmin()` allows to plant the `.nonadmin` marker on Windows. (#14747 via #14750)
+
+### Bug fixes
+
+* Fix cache key in `PrefixData`. It will now consider `pip_interop_enabled`. (#14750)
+* Do not cache `PrefixData` records across successive `conda list` invocations. (#14750)
+
+### Deprecations
+
+* Mark `conda.base.context._first_writable_envs_dir` as pending deprecation. Use `conda.gateways.disk.create.first_writable_envs_dir()` and `PrefixData.from_name()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.base.context.validate_prefix_name` as pending deprecation. Use `PrefixData.validate_name()` and `PrefixData.from_name()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.cli.common.validate_prefix_is_writable` as pending deprecation. Use `PrefixData.assert_writable()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.cli.common.validate_prefix` as pending deprecation. Use `PrefixData.assert_environment()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.cli.install.check_prefix` as pending deprecation. Use `PrefixData.exists()`, `PrefixData.validate_path()`, `PrefixData.validate_name()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.cli.install.validate_new_prefix` as pending deprecation. Use `PrefixData.exists()` and `PrefixData.validate_path()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.cli.install.validate_prefix_exists` as pending deprecation. Use `PrefixData.exists()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.cli.main_rename.check_protected_dirs` as pending deprecation. Use `PrefixData.validate_path()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.cli.main_rename.validate_src` as pending deprecation. Use `PrefixData.validate_path()` and `PrefixData.validate_name()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.gateways.disk.test.is_conda_environment` as pending deprecation. Use `PrefixData.is_environment()` instead. It will be permanently removed in 26.3. (#14750)
+* Mark `conda.gateways.disk.test.touch_nonadmin` as pending deprecation. Use `PrefixData.set_nonadmin()` instead. It will be permanently removed in 26.3. (#14750)
+
+### Docs
+
+* <news item>
+
+### Other
+
+* <news item>
diff --git a/tests/base/test_context.py b/tests/base/test_context.py
index 503178d1911..e86dd848999 100644
--- a/tests/base/test_context.py
+++ b/tests/base/test_context.py
@@ -671,7 +671,7 @@ def test_validate_prefix_name(prefix, allow_base, mock_return_values, expected):
     ctx = mock.MagicMock()
 
     with (
-        mock.patch("conda.base.context._first_writable_envs_dir") as mock_one,
+        mock.patch("conda.gateways.disk.create.first_writable_envs_dir") as mock_one,
         mock.patch("conda.base.context.locate_prefix_by_name") as mock_two,
     ):
         mock_one.side_effect = [mock_return_values[0]]
diff --git a/tests/cli/test_main_rename.py b/tests/cli/test_main_rename.py
index c6d8d5ffc3f..cd209824716 100644
--- a/tests/cli/test_main_rename.py
+++ b/tests/cli/test_main_rename.py
@@ -11,16 +11,18 @@
 from conda.base.context import context, locate_prefix_by_name
 from conda.common.compat import on_win
 from conda.core.envs_manager import list_all_known_prefixes
+from conda.core.prefix_data import PrefixData
 from conda.exceptions import (
     CondaEnvException,
     CondaError,
     CondaValueError,
+    EnvironmentLocationNotFound,
     EnvironmentNameNotFound,
 )
-from conda.gateways.disk.test import is_conda_environment
+from conda.testing.fixtures import TmpEnvFixture
 
 if TYPE_CHECKING:
-    from collections.abc import Iterable
+    from collections.abc import Iterable, Iterator
 
     from pytest import MonkeyPatch
     from pytest_mock import MockerFixture
@@ -40,7 +42,7 @@ def env_rename(conda_cli: CondaCLIFixture) -> Iterable[str]:
     yield name
 
     # Teardown
-    if is_conda_environment(name):
+    if PrefixData.from_name(name).is_environment():
         conda_cli("remove", "--all", "--yes", "--name", name)
 
 
@@ -53,7 +55,7 @@ def env_one(conda_cli: CondaCLIFixture) -> Iterable[str]:
     yield name
 
     # Teardown
-    if is_conda_environment(name):
+    if PrefixData.from_name(name).is_environment():
         conda_cli("remove", "--all", "--yes", "--name", name)
 
 
@@ -66,7 +68,7 @@ def env_two(conda_cli: CondaCLIFixture) -> Iterable[str]:
     yield name
 
     # Teardown
-    if is_conda_environment(name):
+    if PrefixData.from_name(name).is_environment():
         conda_cli("remove", "--all", "--yes", "--name", name)
 
 
@@ -75,8 +77,8 @@ def test_rename_by_name_success(
     env_one: str,
     env_rename: str,
 ):
-    conda_cli("rename", "--name", env_one, env_rename)
-
+    out, err, rc = conda_cli("rename", "--name", env_one, env_rename)
+    assert not rc
     assert locate_prefix_by_name(env_rename)
     with pytest.raises(EnvironmentNameNotFound):
         locate_prefix_by_name(env_one)
@@ -104,7 +106,7 @@ def test_rename_by_name_name_already_exists_error(
     """Test to ensure that we do not rename if the name already exists"""
     with pytest.raises(
         CondaEnvException,
-        match=f"The environment '{env_one}' already exists. Override with --yes",
+        match=f"The environment '.*{env_one}' already exists. Override with --yes.",
     ):
         conda_cli("rename", "--name", env_one, env_one)
 
@@ -119,7 +121,7 @@ def test_rename_by_path_path_already_exists_error(
     """Test to ensure that we do not rename if the path already exists"""
     with pytest.raises(
         CondaEnvException,
-        match=f"The environment '{tmp_path.name}' already exists. Override with --yes",
+        match=f"The environment '.*{tmp_path.name}' already exists. Override with --yes.",
     ):
         conda_cli("rename", "--name", env_one, tmp_path)
 
@@ -176,10 +178,10 @@ def test_cannot_rename_active_env_by_name(
 
 def test_cannot_rename_nonexistent_env(conda_cli: CondaCLIFixture, env_rename: str):
     """Show a useful error message when trying to rename a non-existing env"""
-    env_dir = Path(context.root_prefix) / "foo"
+    env_dir = Path(context.root_prefix) / "envs" / "foo"
     with pytest.raises(
-        CondaEnvException,
-        match="The environment you have specified does not exist.",
+        EnvironmentLocationNotFound,
+        match="Not a conda environment",
     ):
         conda_cli("rename", "--prefix", env_dir, env_rename)
 
@@ -296,29 +298,33 @@ def test_rename_with_force_and_dry_run(
     assert not err
 
 
-def test_protected_dirs_error_for_rename(conda_cli: CondaCLIFixture, env_one: str):
-    with pytest.raises(CondaEnvException) as error:
+def test_protected_dirs_error_for_rename(
+    conda_cli: CondaCLIFixture, env_one: str, tmp_env: Iterator[TmpEnvFixture]
+):
+    with (
+        tmp_env() as source,
+        pytest.raises(
+            CondaValueError,
+            match="Environment paths cannot be immediately nested under another conda environment.",
+        ),
+    ):
         conda_cli(
             "rename",
-            f"--prefix={context.root_prefix}/envs",
-            env_one,
+            f"--prefix={source}",
+            f"{context.root_prefix}/{env_one}",
         )
 
-    assert (
-        "appears to be a top level directory within an existing conda environment"
-        in str(error.value)
-    )
-
 
 @pytest.mark.skipif(not on_win, reason="windows-specific test")
 def test_separator_chars_on_win(conda_cli: CondaCLIFixture, env_one: str):
     bad_env_name = "/" + env_one
 
-    with pytest.raises(CondaValueError) as error:
+    with pytest.raises(
+        CondaValueError,
+        match="Environment names cannot contain path separators",
+    ):
         conda_cli(
             "rename",
             f"--name={bad_env_name}",
             env_two,
         )
-
-    assert "Invalid environment name" in str(error.value)
diff --git a/tests/env/test_create.py b/tests/env/test_create.py
index ec8b38dd460..34aebbb72bb 100644
--- a/tests/env/test_create.py
+++ b/tests/env/test_create.py
@@ -11,7 +11,7 @@
 from conda.base.context import context, reset_context
 from conda.common.compat import on_win
 from conda.core.prefix_data import PrefixData
-from conda.exceptions import CondaEnvException, CondaValueError
+from conda.exceptions import CondaValueError
 from conda.testing.integration import package_is_installed
 
 from . import support_file
@@ -261,7 +261,7 @@ def test_fail_to_create_env_in_dir_with_colon(
 
     with pytest.raises(
         CondaValueError,
-        match="Cannot create a conda environment with ':' in the prefix.",
+        match="Environment paths cannot contain ':'.",
     ):
         conda_cli("create", f"--prefix={colon_dir}/tester")
 
@@ -291,7 +291,10 @@ def test_protected_dirs_error_for_env_create(
     conda_cli: CondaCLIFixture, tmp_env: TmpEnvFixture
 ):
     with tmp_env() as prefix:
-        with pytest.raises(CondaEnvException) as error:
+        with pytest.raises(
+            CondaValueError,
+            match="Environment paths cannot be immediately nested under another conda environment",
+        ):
             conda_cli(
                 "env",
                 "create",
@@ -299,8 +302,3 @@ def test_protected_dirs_error_for_env_create(
                 "--file",
                 support_file("example/environment_pinned.yml"),
             )
-
-        assert (
-            "appears to be a top level directory within an existing conda environment"
-            in str(error.value)
-        )
diff --git a/tests/test_create.py b/tests/test_create.py
index 7aca3720406..0f6587b4eeb 100644
--- a/tests/test_create.py
+++ b/tests/test_create.py
@@ -47,7 +47,6 @@
     DirectoryNotACondaEnvironmentError,
     DisallowedPackageError,
     DryRunExit,
-    EnvironmentLocationNotFound,
     EnvironmentNotWritableError,
     LinkError,
     OperationNotAllowed,
@@ -185,9 +184,8 @@ def test_create_install_update_remove_smoketest(
         assert package_is_installed(prefix, "python=3")
 
         conda_cli("install", f"--prefix={prefix}", "flask=2.0.1", "--yes")
-        PrefixData._cache_.clear()
         assert package_is_installed(prefix, "flask=2.0.1")
-        assert package_is_installed(prefix, "python=3")
+        assert package_is_installed(prefix, "python=3", reload_records=False)
 
         conda_cli(
             "install",
@@ -196,20 +194,17 @@ def test_create_install_update_remove_smoketest(
             "flask=2.0.1",
             "--yes",
         )
-        PrefixData._cache_.clear()
         assert package_is_installed(prefix, "flask=2.0.1")
-        assert package_is_installed(prefix, "python=3")
+        assert package_is_installed(prefix, "python=3", reload_records=False)
 
         conda_cli("update", f"--prefix={prefix}", "flask", "--yes")
-        PrefixData._cache_.clear()
         assert not package_is_installed(prefix, "flask=2.0.1")
-        assert package_is_installed(prefix, "flask")
-        assert package_is_installed(prefix, "python=3")
+        assert package_is_installed(prefix, "flask", reload_records=False)
+        assert package_is_installed(prefix, "python=3", reload_records=False)
 
         conda_cli("remove", f"--prefix={prefix}", "flask", "--yes")
-        PrefixData._cache_.clear()
         assert not package_is_installed(prefix, "flask")
-        assert package_is_installed(prefix, "python=3")
+        assert package_is_installed(prefix, "python=3", reload_records=False)
 
         stdout, stderr, code = conda_cli("list", f"--prefix={prefix}", "--revisions")
         assert not stderr
@@ -217,9 +212,8 @@ def test_create_install_update_remove_smoketest(
         assert " (rev 5)\n" not in stdout
 
         conda_cli("install", f"--prefix={prefix}", "--revision", "0", "--yes")
-        PrefixData._cache_.clear()
         assert not package_is_installed(prefix, "flask")
-        assert package_is_installed(prefix, "python=3")
+        assert package_is_installed(prefix, "python=3", reload_records=False)
 
 
 def test_install_broken_post_install_keeps_existing_folders(
@@ -840,7 +834,8 @@ def test_list_with_pip_no_binary(tmp_env: TmpEnvFixture, conda_cli: CondaCLIFixt
 
         # regression test for #5847
         #   when using rm_rf on a directory
-        assert prefix in PrefixData._cache_
+        # cache key is prefix, pip_interop_enabled, 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_
 
@@ -882,21 +877,21 @@ def test_rm_rf(clear_package_cache: None, tmp_env: TmpEnvFixture):
     with tmp_env(f"python={py_ver}") as prefix:
         # regression test for #5847
         #   when using rm_rf on a file
-        assert prefix in PrefixData._cache_
+        assert any(prefix in key for key in PrefixData._cache_)
         _rm_rf(prefix / get_python_site_packages_short_path(py_ver), "os.py")
         assert prefix not in PrefixData._cache_
 
     with tmp_env() as prefix:
         assert isdir(prefix)
-        assert prefix in PrefixData._cache_
+        assert any(prefix in key for key in PrefixData._cache_)
 
         rmtree(prefix)
         assert not isdir(prefix)
-        assert prefix in PrefixData._cache_
+        assert any(prefix in key for key in PrefixData._cache_)
 
         _rm_rf(prefix)
         assert not isdir(prefix)
-        assert prefix not in PrefixData._cache_
+        assert all(prefix not in key for key in PrefixData._cache_)
 
 
 def test_install_tarball_from_file_based_channel(
@@ -2634,7 +2629,7 @@ def test_remove_ignore_nonenv(tmp_path: Path, conda_cli: CondaCLIFixture):
     filename = tmp_path / "file.dat"
     filename.touch()
 
-    with pytest.raises(EnvironmentLocationNotFound):
+    with pytest.raises(DirectoryNotACondaEnvironmentError):
         conda_cli("remove", f"--prefix={tmp_path}", "--all", "--yes")
 
     assert filename.exists()


Reviews:
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T15:20:01Z
user: login: peytondmurray
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T20:25:15Z
user: login: travishathaway
body: Looking good so far, and I haven't reviewed all of it yet, but I wanted to cut my review short so you could focus on the changes I requested so far. Please ping me to discuss or when you are ready for me to take another look!
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T13:21:30Z
user: login: peytondmurray
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:03:44Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T09:49:26Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T09:50:39Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T09:51:48Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T09:54:19Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T09:56:04Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T10:02:37Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T10:03:08Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T10:05:27Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T10:08:40Z
user: login: travishathaway
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:05:34Z
user: login: travishathaway
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:13:51Z
user: login: travishathaway
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:14:31Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T14:21:57Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T14:26:27Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T14:36:26Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T13:00:29Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T13:00:54Z
user: login: travishathaway
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T13:50:34Z

Review comments:
diff_hunk: @@ -2196,10 +2199,12 @@ def determine_target_prefix(ctx, args=None):
     elif prefix_path is not None:
         return expand(prefix_path)
     else:
-        return validate_prefix_name(prefix_name, ctx=ctx)
+        from ..core.prefix_data import PrefixData
+
+        return str(PrefixData.from_name(prefix_name).prefix_path)
path: conda/base/context.py
user: login: jaimergp
body: I'm not a fan of this clause at all.

We need the inline import because we have `PrefixData.from_context()` now (which is super convenient, check the `conda.cli.main_*` modules), which depends on this `determine_target_prefix` function via `context.target_prefix`. `PrefixData.from_name()` does some validation and expansion via `PrefixData.validate_name()` and others.
created_at: 2025-04-10T15:20:01Z
author_association: MEMBER
diff_hunk: @@ -2161,7 +2164,7 @@ def validate_prefix_name(prefix_name: str, ctx: Context, allow_base=True) -> str
             return join(_first_writable_envs_dir(), prefix_name)
 
 
-def determine_target_prefix(ctx, args=None):
+def determine_target_prefix(ctx, args=None) -> str:
path: conda/base/context.py
user: login: peytondmurray
body: I think `os.PathLike` can be used here, if we want to be more specific.
created_at: 2025-04-14T19:24:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -240,18 +250,15 @@ def install(args, parser, command="install"):
                 "Creating new environment for a non-native platform %s",
                 context.subdir,
             )
-    elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
-            if paths_equal(prefix, context.conda_prefix):
-                raise NoBaseEnvironmentError()
-            else:
-                if not path_is_clean(prefix):
-                    raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
-        raise EnvironmentLocationNotFound(prefix)
+        try:
+            prefix_data.assert_writable()
+        except DirectoryNotACondaEnvironmentError as exc:
+            if prefix_data == PrefixData(context.conda_prefix):
+                raise NoBaseEnvironmentError() from exc
+            delete_trash(prefix)
+            if not path_is_clean(prefix):
+                raise
path: conda/cli/install.py
user: login: peytondmurray
body: What's the intended behavior here? I gather that if I target a "prefix" which isn't actually a prefix with e.g. `conda create -p <prefix> python` I'll hit this code path, but it looks like it cleans out some temporary conda files, conda trash, and any empty parent directories too. If I encountered this as a user I might not expect it, unless I also passed some flag like `--force-overwrite` or something like that?

It would be helpful to have a note about the intention here, and probably up in the docstring too.
created_at: 2025-04-14T19:48:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,11 +70,13 @@ def __call__(
     ):
         if isinstance(prefix_path, PrefixData):
             return prefix_path
-        elif (prefix_path := Path(prefix_path)) in PrefixData._cache_:
-            return PrefixData._cache_[prefix_path]
+        prefix_path = Path(prefix_path)
+        cache_key = prefix_path, pip_interop_enabled
+        if cache_key in PrefixData._cache_:
path: conda/core/prefix_data.py
user: login: peytondmurray
body: I think the type of `_cache_` needs to be updated from `dict[Path, PrefixData]` -> `dict[tuple[Path, bool | None], PrefixData]` to handle this new `cache_key`.

Can the return type for this function also be updated?
created_at: 2025-04-14T20:06:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
path: conda/core/prefix_data.py
user: login: peytondmurray
body: ```suggestion
    def from_name(cls, name: str, **kwargs) -> PrefixData:
```
created_at: 2025-04-14T20:09:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
+        if "/" in name or "\\" in name:
+            raise CondaValueError("Environment names cannot contain path separators")
+        try:
+            return cls(locate_prefix_by_name(name))
+        except EnvironmentNameNotFound:
+            cls(name).validate_name()
+            return cls(Path(_first_writable_envs_dir(), name), **kwargs)
+
+    @classmethod
+    def from_context(cls, validate: bool = False):
path: conda/core/prefix_data.py
user: login: peytondmurray
body: ```suggestion
    def from_context(cls, validate: bool = False) -> PrefixData:
```
created_at: 2025-04-14T20:09:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
path: conda/core/prefix_data.py
user: login: peytondmurray
body: ```suggestion
    def exists(self) -> bool:
```
created_at: 2025-04-14T20:09:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
path: conda/core/prefix_data.py
user: login: peytondmurray
body: ```suggestion
    def is_environment(self) -> bool:
```
created_at: 2025-04-14T20:10:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
path: conda/core/prefix_data.py
user: login: peytondmurray
body: ```suggestion
    def is_base(self) -> bool:
```
created_at: 2025-04-14T20:10:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
path: conda/core/prefix_data.py
user: login: peytondmurray
body: ```suggestion
    def is_writable(self) -> bool:
```
created_at: 2025-04-14T20:10:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
+        if not self.exists():
+            raise EnvironmentLocationNotFound(self.prefix_path)
+
+    def assert_environment(self):
+        self.assert_exists()
+        if not self.is_environment():
+            raise DirectoryNotACondaEnvironmentError(self.prefix_path)
+
+    def assert_writable(self):
+        self.assert_environment()
+        if not file_path_is_writable(self._magic_file):
+            raise EnvironmentNotWritableError(self.prefix_path)
+
+    def validate_path(self, expand_path: bool = False) -> Path:
path: conda/core/prefix_data.py
user: login: peytondmurray
body: ```suggestion
    def validate_path(self, expand_path: bool = False):
```
created_at: 2025-04-14T20:14:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
+        if not self.exists():
+            raise EnvironmentLocationNotFound(self.prefix_path)
+
+    def assert_environment(self):
+        self.assert_exists()
+        if not self.is_environment():
+            raise DirectoryNotACondaEnvironmentError(self.prefix_path)
+
+    def assert_writable(self):
+        self.assert_environment()
+        if not file_path_is_writable(self._magic_file):
+            raise EnvironmentNotWritableError(self.prefix_path)
+
+    def validate_path(self, expand_path: bool = False) -> Path:
+        prefix_str = str(self.prefix_path)
+        if expand_path:
+            prefix_str = expand(prefix_str)
+            self.prefix_path = Path(prefix_str)
+
+        if os.pathsep in prefix_str:
+            raise CondaValueError(
+                f"Environment paths cannot contain '{os.pathsep}'. Prefix: '{prefix_str}'"
+            )
+
+        if " " in prefix_str:
+            log.warning(
+                "Environment paths should not contain spaces. Prefix: '%s'",
+                prefix_str,
+            )
+        parent = self.__class__(self.prefix_path.parent)
+        if parent.is_environment():
+            raise CondaValueError(
path: conda/core/prefix_data.py
user: login: peytondmurray
body: Should environments ever be able to be nested? I can't tell if there's a legitimate use case for an environment to contain another environment at any level at all.
created_at: 2025-04-14T20:15:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -380,6 +491,9 @@ def _load_site_packages(self):
 
         return new_packages
 
+    # endregion
path: conda/core/prefix_data.py
user: login: peytondmurray
body: Leftover from debugging?
created_at: 2025-04-14T20:17:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,8 +77,12 @@ def test_rename_by_name_success(
     env_one: str,
     env_rename: str,
 ):
-    conda_cli("rename", "--name", env_one, env_rename)
+    import sys
 
+    out, err, rc = conda_cli("rename", "--name", env_one, env_rename)
+    assert not rc
+    print(out)
+    print(err, file=sys.stderr)
path: tests/cli/test_main_rename.py
user: login: peytondmurray
body: ```suggestion
```
created_at: 2025-04-14T20:24:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2196,10 +2199,12 @@ def determine_target_prefix(ctx, args=None):
     elif prefix_path is not None:
         return expand(prefix_path)
     else:
-        return validate_prefix_name(prefix_name, ctx=ctx)
+        from ..core.prefix_data import PrefixData
+
+        return str(PrefixData.from_name(prefix_name).prefix_path)
 
 
-def _first_writable_envs_dir():
+def _first_writable_envs_dir(create=True):
path: conda/base/context.py
user: login: travishathaway
body: A couple questions here:

- Why did we add the keyword argument? (e.g. it doesn't look like any of the callers are setting it to `False`)
- If we plan on using this outside the module, why not change it so the name reflects it should be done (e.g. `first_writable_envs_dir`). If we're really worried about breaking code, we could also wrap it in a "public" function.
created_at: 2025-04-16T12:37:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
path: conda/core/prefix_data.py
user: login: travishathaway
body: I'm not super excited about the names for these three methods. Asserts have a pretty specific meaning in Python, and I wouldn't want to confuse the caller by having them think that they are getting some sort of sub-class of an assertion error here or that the method itself is running a series of `assert` statements.

How about we take a lead from the beloved `requests` API, specifically the `raise_for_status` method on the response? We could call these methods:

- `exists_or_raise`
- `is_environment_or_raise`
- `is_writable_or_raise`

That way it's pretty clear what's happening within the method itself and the caller can guess that they will have to handle some sort of exception.
created_at: 2025-04-16T13:10:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
path: conda/core/prefix_data.py
user: login: travishathaway
body: If we expect these to be used from outside of this project (which they should!), we need to make sure to add a nice doc string to the method clearly stating why this method should be used and what it returns.
created_at: 2025-04-16T13:16:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
+        if "/" in name or "\\" in name:
+            raise CondaValueError("Environment names cannot contain path separators")
+        try:
+            return cls(locate_prefix_by_name(name))
+        except EnvironmentNameNotFound:
+            cls(name).validate_name()
+            return cls(Path(_first_writable_envs_dir(), name), **kwargs)
+
+    @classmethod
+    def from_context(cls, validate: bool = False):
path: conda/core/prefix_data.py
user: login: travishathaway
body: This should also get a nice doc string. When I first saw this method in the code itself, I was left wondering, "okay, but which context are you talking about?" Let's make sure the doc string clarifies that so developers can easily find it.
created_at: 2025-04-16T13:17:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
+        if "/" in name or "\\" in name:
+            raise CondaValueError("Environment names cannot contain path separators")
+        try:
+            return cls(locate_prefix_by_name(name))
+        except EnvironmentNameNotFound:
+            cls(name).validate_name()
+            return cls(Path(_first_writable_envs_dir(), name), **kwargs)
+
+    @classmethod
+    def from_context(cls, validate: bool = False):
+        inst = cls(context.target_prefix)
+        if validate:
+            inst.validate_path()
+            inst.validate_name()
+        return inst
+
+    @property
+    def name(self) -> str:
+        if self == PrefixData(context.root_prefix):
+            return ROOT_ENV_NAME
+        for envs_dir in context.envs_dirs:
+            if paths_equal(envs_dir, self.prefix_path.parent):
+                return self.prefix_path.name
+        return ""
+
+    # region Checks
path: conda/core/prefix_data.py
user: login: travishathaway
body: ```suggestion
```
🧹 ?
created_at: 2025-04-16T13:17:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
+        if "/" in name or "\\" in name:
+            raise CondaValueError("Environment names cannot contain path separators")
+        try:
+            return cls(locate_prefix_by_name(name))
+        except EnvironmentNameNotFound:
+            cls(name).validate_name()
+            return cls(Path(_first_writable_envs_dir(), name), **kwargs)
+
+    @classmethod
+    def from_context(cls, validate: bool = False):
+        inst = cls(context.target_prefix)
+        if validate:
+            inst.validate_path()
+            inst.validate_name()
+        return inst
+
+    @property
+    def name(self) -> str:
path: conda/core/prefix_data.py
user: login: travishathaway
body: It would be good to document this too if we expect others to be using it.

Another thing that would be nice would be explaining what an empty string means. I'm pretty sure it means it's not a named environment, but totally not clear by reading the code itself.
created_at: 2025-04-16T13:20:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
path: conda/core/prefix_data.py
user: login: peytondmurray
body: I'm a fan of docstrings everywhere too, big +1 on this from me!
created_at: 2025-04-16T17:03:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2196,10 +2199,12 @@ def determine_target_prefix(ctx, args=None):
     elif prefix_path is not None:
         return expand(prefix_path)
     else:
-        return validate_prefix_name(prefix_name, ctx=ctx)
+        from ..core.prefix_data import PrefixData
+
+        return str(PrefixData.from_name(prefix_name).prefix_path)
 
 
-def _first_writable_envs_dir():
+def _first_writable_envs_dir(create=True):
path: conda/base/context.py
user: login: jaimergp
body: I was experimenting with using this function without side effects in `PrefixData.from_name()`. I may use it there with `create=False` but I haven't decided yet.
created_at: 2025-04-17T09:49:25Z
author_association: MEMBER
diff_hunk: @@ -240,18 +250,15 @@ def install(args, parser, command="install"):
                 "Creating new environment for a non-native platform %s",
                 context.subdir,
             )
-    elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
-            if paths_equal(prefix, context.conda_prefix):
-                raise NoBaseEnvironmentError()
-            else:
-                if not path_is_clean(prefix):
-                    raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
-        raise EnvironmentLocationNotFound(prefix)
+        try:
+            prefix_data.assert_writable()
+        except DirectoryNotACondaEnvironmentError as exc:
+            if prefix_data == PrefixData(context.conda_prefix):
+                raise NoBaseEnvironmentError() from exc
+            delete_trash(prefix)
+            if not path_is_clean(prefix):
+                raise
path: conda/cli/install.py
user: login: jaimergp
body: It's not really clear from the previous code either. I simply reimplemented it with the new API while maintaining the previous behaviour. If we want to change this, a new issue should be opened.
created_at: 2025-04-17T09:50:38Z
author_association: MEMBER
diff_hunk: @@ -57,11 +70,13 @@ def __call__(
     ):
         if isinstance(prefix_path, PrefixData):
             return prefix_path
-        elif (prefix_path := Path(prefix_path)) in PrefixData._cache_:
-            return PrefixData._cache_[prefix_path]
+        prefix_path = Path(prefix_path)
+        cache_key = prefix_path, pip_interop_enabled
+        if cache_key in PrefixData._cache_:
path: conda/core/prefix_data.py
user: login: jaimergp
body: Good catch, thanks.
created_at: 2025-04-17T09:51:47Z
author_association: MEMBER
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
+        if "/" in name or "\\" in name:
+            raise CondaValueError("Environment names cannot contain path separators")
+        try:
+            return cls(locate_prefix_by_name(name))
+        except EnvironmentNameNotFound:
+            cls(name).validate_name()
+            return cls(Path(_first_writable_envs_dir(), name), **kwargs)
+
+    @classmethod
+    def from_context(cls, validate: bool = False):
+        inst = cls(context.target_prefix)
+        if validate:
+            inst.validate_path()
+            inst.validate_name()
+        return inst
+
+    @property
+    def name(self) -> str:
+        if self == PrefixData(context.root_prefix):
+            return ROOT_ENV_NAME
+        for envs_dir in context.envs_dirs:
+            if paths_equal(envs_dir, self.prefix_path.parent):
+                return self.prefix_path.name
+        return ""
+
+    # region Checks
path: conda/core/prefix_data.py
user: login: jaimergp
body: No, this is a standard IDE feature to navigate the minimap and fold related code blocks. See https://www.codu.co/articles/using-regions-in-vs-code-to-organize-your-code-womhkang
created_at: 2025-04-17T09:54:17Z
author_association: MEMBER
diff_hunk: @@ -380,6 +491,9 @@ def _load_site_packages(self):
 
         return new_packages
 
+    # endregion
path: conda/core/prefix_data.py
user: login: jaimergp
body: No, it's a standard (but maybe not very popular) IDE feature: https://www.codu.co/articles/using-regions-in-vs-code-to-organize-your-code-womhkang
created_at: 2025-04-17T09:56:03Z
author_association: MEMBER
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
path: conda/core/prefix_data.py
user: login: jaimergp
body: I do like `assert_*`. That very specific meaning is exactly what I mean: raise if the following clause is not true. The `*_or_raise` form sounds a bit too Rust-y for my taste.

My intent here was to have booleans returned by the `is_*` family, and exceptions raised by the `assert_*` family. I also thought of `check_*` for the latter, but I felt that `assert` was clearer.

I'll let other folks chime in here to resolve the "tie", but I do like `assert_*` a lot.
created_at: 2025-04-17T10:02:37Z
author_association: MEMBER
diff_hunk: @@ -84,6 +100,35 @@ def __init__(
             else context.pip_interop_enabled
         )
 
+    @classmethod
+    def from_name(cls, name: str, **kwargs):
path: conda/core/prefix_data.py
user: login: jaimergp
body: Yep, didn't put the time on these yet because I didn't know how many of these new API endpoints would survive a review :P Looks like we are keeping them so I'll write them down.
created_at: 2025-04-17T10:03:08Z
author_association: MEMBER
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
+        if not self.exists():
+            raise EnvironmentLocationNotFound(self.prefix_path)
+
+    def assert_environment(self):
+        self.assert_exists()
+        if not self.is_environment():
+            raise DirectoryNotACondaEnvironmentError(self.prefix_path)
+
+    def assert_writable(self):
+        self.assert_environment()
+        if not file_path_is_writable(self._magic_file):
+            raise EnvironmentNotWritableError(self.prefix_path)
+
+    def validate_path(self, expand_path: bool = False) -> Path:
+        prefix_str = str(self.prefix_path)
+        if expand_path:
+            prefix_str = expand(prefix_str)
+            self.prefix_path = Path(prefix_str)
+
+        if os.pathsep in prefix_str:
+            raise CondaValueError(
+                f"Environment paths cannot contain '{os.pathsep}'. Prefix: '{prefix_str}'"
+            )
+
+        if " " in prefix_str:
+            log.warning(
+                "Environment paths should not contain spaces. Prefix: '%s'",
+                prefix_str,
+            )
+        parent = self.__class__(self.prefix_path.parent)
+        if parent.is_environment():
+            raise CondaValueError(
path: conda/core/prefix_data.py
user: login: jaimergp
body: Environments can be nested in this way:

```
$CONDA_ROOT
$CONDA_ROOT/envs/new-conda-env
$CONDA_ROOT/envs/new-conda-env/envs/another-conda-subenv
```

Not that we _should_ do that, but it's perfectly legal. We use this in the local dev setup, not sure if intentionally, but it does happen there.

What we are avoiding here is having this situation:

```
$CONDA_ROOT
$CONDA_ROOT/my-new-env
$CONDA_ROOT/envs/new-conda-env/another-conda-subenv
```
created_at: 2025-04-17T10:05:27Z
author_association: MEMBER
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
+        if not self.exists():
+            raise EnvironmentLocationNotFound(self.prefix_path)
+
+    def assert_environment(self):
+        self.assert_exists()
+        if not self.is_environment():
+            raise DirectoryNotACondaEnvironmentError(self.prefix_path)
+
+    def assert_writable(self):
+        self.assert_environment()
+        if not file_path_is_writable(self._magic_file):
+            raise EnvironmentNotWritableError(self.prefix_path)
+
+    def validate_path(self, expand_path: bool = False) -> Path:
+        prefix_str = str(self.prefix_path)
+        if expand_path:
+            prefix_str = expand(prefix_str)
+            self.prefix_path = Path(prefix_str)
+
+        if os.pathsep in prefix_str:
+            raise CondaValueError(
+                f"Environment paths cannot contain '{os.pathsep}'. Prefix: '{prefix_str}'"
+            )
+
+        if " " in prefix_str:
+            log.warning(
+                "Environment paths should not contain spaces. Prefix: '%s'",
+                prefix_str,
+            )
+        parent = self.__class__(self.prefix_path.parent)
+        if parent.is_environment():
+            raise CondaValueError(
path: conda/core/prefix_data.py
user: login: jaimergp
body: This type of nesting, btw, is because `envs_dirs` includes the `base`-like (it has `conda` in it) environment path as one of the possible locations, as you have seen in your PRs. This is one of the reasons I want to remove that coupled behaviour.
created_at: 2025-04-17T10:08:40Z
author_association: MEMBER
diff_hunk: @@ -2196,10 +2199,12 @@ def determine_target_prefix(ctx, args=None):
     elif prefix_path is not None:
         return expand(prefix_path)
     else:
-        return validate_prefix_name(prefix_name, ctx=ctx)
+        from ..core.prefix_data import PrefixData
+
+        return str(PrefixData.from_name(prefix_name).prefix_path)
 
 
-def _first_writable_envs_dir():
+def _first_writable_envs_dir(create=True):
path: conda/base/context.py
user: login: travishathaway
body: Fair enough. What about making this a proper public function rather than having it be protected with the underscore in front of the name?
created_at: 2025-04-17T14:05:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
path: conda/core/prefix_data.py
user: login: travishathaway
body: Thanks for the clarification! I raised an eyebrow at this because I don't believe I've seen this before in any Python APIs I've ever used, but I do understand your reasoning about giving consumers of this class the ability to either return booleans or exceptions.

I am okay with the naming scheme for now, and I will be curious to see how it's used in the future.
created_at: 2025-04-17T14:13:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
path: conda/core/prefix_data.py
user: login: travishathaway
body: I'll leave this thread open in case anyone else wants to chime in on it.
created_at: 2025-04-17T14:14:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,6 +142,80 @@ def __eq__(self, other: Any) -> bool:
             # neither prefix exists, raw comparison
             return self.prefix_path.resolve() == other.prefix_path.resolve()
 
+    def exists(self):
+        try:
+            return self.prefix_path.is_dir()
+        except OSError:
+            return False
+
+    def is_environment(self):
+        try:
+            return self._magic_file.is_file()
+        except OSError:
+            return False
+
+    def is_base(self):
+        return paths_equal(str(self.prefix_path), context.root_prefix)
+
+    @property
+    def is_writable(self):
+        if self.__is_writable == NULL:
+            if not self.is_environment():
+                is_writable = None
+            else:
+                is_writable = file_path_is_writable(self._magic_file)
+            self.__is_writable = is_writable
+        return self.__is_writable
+
+    def assert_exists(self):
path: conda/core/prefix_data.py
user: login: jaimergp
body: FWIW, a couple Python function definition searches that are relevant to the discussion:

- [`assert_*`](https://github.com/search?q=%2Fdef+assert_%5CS%2B%2F+language%3APython+NOT+is%3Afork&ref=opensearch&type=code): 134k files
- [`*_or_raise`](https://github.com/search?q=%2Fdef+%5CS%2B_or_raise%2F+language%3APython+NOT+is%3Afork&ref=opensearch&type=code): 5.3k files
created_at: 2025-04-17T14:21:57Z
author_association: MEMBER
diff_hunk: @@ -2196,10 +2199,12 @@ def determine_target_prefix(ctx, args=None):
     elif prefix_path is not None:
         return expand(prefix_path)
     else:
-        return validate_prefix_name(prefix_name, ctx=ctx)
+        from ..core.prefix_data import PrefixData
+
+        return str(PrefixData.from_name(prefix_name).prefix_path)
 
 
-def _first_writable_envs_dir():
+def _first_writable_envs_dir(create=True):
path: conda/base/context.py
user: login: jaimergp
body: Its only user in `context.py` has been deprecated, so we might just as well move it somewhere else where it makes more sense. I'll take a look.
created_at: 2025-04-17T14:26:27Z
author_association: MEMBER
diff_hunk: @@ -2196,10 +2199,12 @@ def determine_target_prefix(ctx, args=None):
     elif prefix_path is not None:
         return expand(prefix_path)
     else:
-        return validate_prefix_name(prefix_name, ctx=ctx)
+        from ..core.prefix_data import PrefixData
+
+        return str(PrefixData.from_name(prefix_name).prefix_path)
 
 
-def _first_writable_envs_dir():
+def _first_writable_envs_dir(create=True):
path: conda/base/context.py
user: login: jaimergp
body: Done.
created_at: 2025-04-17T14:36:26Z
author_association: MEMBER
diff_hunk: @@ -337,7 +333,7 @@ def install_clone(args, parser):
 
 
 def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+    """Logic for `conda install`, `conda update`, `conda remove`, and `conda create`."""
path: conda/cli/install.py
user: login: jaimergp
body: ```suggestion
    """Logic for `conda install`, `conda update`, and `conda create`."""
```
created_at: 2025-04-19T13:00:29Z
author_association: MEMBER
diff_hunk: @@ -346,24 +342,10 @@ def install(args, parser, command="install"):
     if context.use_only_tar_bz2:
         args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-
-    if isupdate or isinstall or isremove:
-        if isdir(prefix):
-            delete_trash(prefix)
-            if not isfile(join(prefix, PREFIX_MAGIC_FILE)):
-                if paths_equal(prefix, context.conda_prefix):
-                    raise NoBaseEnvironmentError()
-                else:
-                    if not path_is_clean(prefix):
-                        raise DirectoryNotACondaEnvironmentError(prefix)
-            else:
-                validate_prefix_is_writable(prefix)
-        else:
-            raise EnvironmentLocationNotFound(prefix)
+    newenv = command == "create"
+    isupdate = command == "update"
+    isinstall = command == "install"
+    isremove = command == "remove"
path: conda/cli/install.py
user: login: jaimergp
body: This function is never invoked for `conda remove` so I think we should clean this up. Future PR.
created_at: 2025-04-19T13:00:54Z
author_association: MEMBER

PR comments:
user: login: codspeed-hq[bot]
created_at: 2025-04-09T20:54:22Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

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

### Merging #14750 will **not alter performance**

<sub>Comparing <code>jaimergp:prefix-data-checks</code> (d0fad23) with <code>main</code> (257698a)</sub>



### Summary

`✅ 21` untouched benchmarks  




author_association: NONE
user: login: jaimergp
created_at: 2025-04-10T18:00:49Z
body: I'm finding some inconsistencies in the current design, where we are coupling existing and non-existing environments in the same class. Thinking about it, I think it'd be better to decouple these two aspects in two separate classes:

- The existing `PrefixData`, which would only concern _existing_ environments and their records.
- A new `PrefixState`, that would take care of everything else, like variables, configuration, nonadmin and frozen markers, etc. Maybe it's also a good place for name and path validators, but I need to think about that. If not, maybe these validators should be free functions.

To be continued.
author_association: MEMBER
user: login: jaimergp
created_at: 2025-04-14T07:13:09Z
body: I took another look and the previously existing methods in `PrefixData` do not necessarily assumed existence of the environment. Splitting the new methods to a new class didn't clean it up too much either, and created more questions to the developer, like "is this method in State or in Data?", so I think I'll leave it as is for now.

@conda/conda-maintainers, I think this is ready for review.
author_association: MEMBER
user: login: jaimergp
created_at: 2025-04-14T09:39:21Z
body: pre-commit.ci autofix
author_association: MEMBER
user: login: peytondmurray
created_at: 2025-04-14T20:26:09Z
body: Thanks for this - mostly minor changes here, otherwise looks good! :rocket: 
author_association: CONTRIBUTOR
user: login: jaimergp
created_at: 2025-04-19T09:36:46Z
body: pre-commit.ci autofix
author_association: MEMBER