mirror of
https://github.com/xonsh/xonsh.git
synced 2025-03-04 08:24:40 +01:00
Perform proper case-insensitive matching in command_cache on Windows (#5477)
Closes #5476 Closes #5469
This commit is contained in:
parent
8e229a421f
commit
4aec6d1d12
5 changed files with 97 additions and 47 deletions
|
@ -241,3 +241,28 @@ You can add aliases to your ``~/.xonshrc`` to have it always
|
||||||
available when xonsh starts.
|
available when xonsh starts.
|
||||||
|
|
||||||
|
|
||||||
|
Working Directory on PATH
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Windows users, particularly those coming from the ``cmd.exe`` shell,
|
||||||
|
might be accustomed to being able to run executables from the current
|
||||||
|
directory by simply typing the program name.
|
||||||
|
|
||||||
|
Since version 0.16, ``xonsh`` follows the more secure and modern
|
||||||
|
approach of not including the current working directory in the search
|
||||||
|
path, similar to Powershell and popular Unix shells. To invoke commands
|
||||||
|
in the current directory on any platform, include the current directory
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
.. code-block:: xonshcon
|
||||||
|
|
||||||
|
>>> ./my-program
|
||||||
|
|
||||||
|
Although not recommended, to restore the behavior found in the
|
||||||
|
``cmd.exe`` shell, simply append ``.`` to the ``PATH``:
|
||||||
|
|
||||||
|
.. code-block:: xonshcon
|
||||||
|
|
||||||
|
>>> $PATH.append('.')
|
||||||
|
|
||||||
|
Add that to ``~/.xonshrc`` to enable that as the default behavior.
|
||||||
|
|
23
news/5477.rst
Normal file
23
news/5477.rst
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
**Added:**
|
||||||
|
|
||||||
|
* <news item>
|
||||||
|
|
||||||
|
**Changed:**
|
||||||
|
|
||||||
|
* Minor cleanup of ``commands_cache``, unifying behavior across platforms.
|
||||||
|
|
||||||
|
**Deprecated:**
|
||||||
|
|
||||||
|
* <news item>
|
||||||
|
|
||||||
|
**Removed:**
|
||||||
|
|
||||||
|
* No longer is ``.`` implied for running commands on Windows. Instead the behavior is the same across platforms. Windows users will need to prefix ``./`` or ``.\`` to run commands from the current directory (#5476).
|
||||||
|
|
||||||
|
**Fixed:**
|
||||||
|
|
||||||
|
* Commands on Windows now honor the case as they appear on the file system (#5469).
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
|
||||||
|
* <news item>
|
|
@ -15,6 +15,9 @@ authors = [{ name = "Anthony Scopatz" }, { email = "scopatz@gmail.com" }]
|
||||||
maintainers = [{ name = "Anthony Scopatz" }, { email = "scopatz@gmail.com" }]
|
maintainers = [{ name = "Anthony Scopatz" }, { email = "scopatz@gmail.com" }]
|
||||||
license = { text = "BSD 2-Clause License" }
|
license = { text = "BSD 2-Clause License" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"case-insensitive-dictionary; platform_system=='Windows'",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.setuptools.dynamic]
|
[tool.setuptools.dynamic]
|
||||||
version = {attr = "xonsh.__version__"}
|
version = {attr = "xonsh.__version__"}
|
||||||
|
|
|
@ -157,7 +157,6 @@ def test_commands_cache_predictor_default(args, xession, tmp_path):
|
||||||
|
|
||||||
|
|
||||||
class Test_is_only_functional_alias:
|
class Test_is_only_functional_alias:
|
||||||
@skip_if_on_windows
|
|
||||||
def test_cd(self, xession):
|
def test_cd(self, xession):
|
||||||
xession.aliases["cd"] = lambda args: os.chdir(args[0])
|
xession.aliases["cd"] = lambda args: os.chdir(args[0])
|
||||||
xession.env["PATH"] = []
|
xession.env["PATH"] = []
|
||||||
|
@ -204,7 +203,31 @@ def test_update_cache(xession, tmp_path):
|
||||||
assert file2.samefile(cached[basename][0])
|
assert file2.samefile(cached[basename][0])
|
||||||
|
|
||||||
|
|
||||||
@skip_if_on_windows
|
@pytest.fixture
|
||||||
|
def faux_binary(tmp_path):
|
||||||
|
"""
|
||||||
|
A fake binary in the temp path.
|
||||||
|
|
||||||
|
Uses mixed case so tests may make assertions about it.
|
||||||
|
"""
|
||||||
|
binary = tmp_path / "RunMe.exe"
|
||||||
|
binary.touch()
|
||||||
|
binary.chmod(0o755)
|
||||||
|
return binary
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_binary_retains_case(faux_binary):
|
||||||
|
cache = CommandsCache({"PATH": []})
|
||||||
|
loc = cache.locate_binary(str(faux_binary))
|
||||||
|
assert faux_binary.name in loc
|
||||||
|
|
||||||
|
|
||||||
|
def test_exes_in_cwd_are_not_matched(faux_binary, monkeypatch):
|
||||||
|
monkeypatch.chdir(faux_binary.parent)
|
||||||
|
cache = CommandsCache({"PATH": []})
|
||||||
|
assert cache.locate_binary(faux_binary.name) is None
|
||||||
|
|
||||||
|
|
||||||
def test_nixos_coreutils(tmp_path):
|
def test_nixos_coreutils(tmp_path):
|
||||||
"""On NixOS the core tools are the symlinks to one universal ``coreutils`` binary file."""
|
"""On NixOS the core tools are the symlinks to one universal ``coreutils`` binary file."""
|
||||||
path = tmp_path / "core"
|
path = tmp_path / "core"
|
||||||
|
|
|
@ -18,6 +18,11 @@ from xonsh.lazyasd import lazyobject
|
||||||
from xonsh.platform import ON_POSIX, ON_WINDOWS, pathbasename
|
from xonsh.platform import ON_POSIX, ON_WINDOWS, pathbasename
|
||||||
from xonsh.tools import executables_in
|
from xonsh.tools import executables_in
|
||||||
|
|
||||||
|
if ON_WINDOWS:
|
||||||
|
from case_insensitive_dict import CaseInsensitiveDict as CacheDict
|
||||||
|
else:
|
||||||
|
CacheDict = dict
|
||||||
|
|
||||||
|
|
||||||
class _Commands(tp.NamedTuple):
|
class _Commands(tp.NamedTuple):
|
||||||
mtime: float
|
mtime: float
|
||||||
|
@ -80,12 +85,7 @@ class CommandsCache(cabc.Mapping):
|
||||||
|
|
||||||
def iter_commands(self):
|
def iter_commands(self):
|
||||||
"""Wrapper for handling windows path behaviour"""
|
"""Wrapper for handling windows path behaviour"""
|
||||||
for cmd, (path, is_alias) in self.all_commands.items():
|
return self.all_commands.items()
|
||||||
if ON_WINDOWS and path is not None:
|
|
||||||
# All command keys are stored in uppercase on Windows.
|
|
||||||
# This ensures the original command name is returned.
|
|
||||||
cmd = pathbasename(path)
|
|
||||||
yield cmd, (path, is_alias)
|
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
return len(self.all_commands)
|
return len(self.all_commands)
|
||||||
|
@ -99,15 +99,16 @@ class CommandsCache(cabc.Mapping):
|
||||||
return len(self._cmds_cache) == 0
|
return len(self._cmds_cache) == 0
|
||||||
|
|
||||||
def get_possible_names(self, name):
|
def get_possible_names(self, name):
|
||||||
"""Generates the possible `PATHEXT` extension variants of a given executable
|
"""Expand name to all possible variants based on `PATHEXT`.
|
||||||
name on Windows as a list, conserving the ordering in `PATHEXT`.
|
|
||||||
Returns a list as `name` being the only item in it on other platforms."""
|
PATHEXT is a Windows convention containing extensions to be
|
||||||
if ON_WINDOWS:
|
considered when searching for an executable file.
|
||||||
pathext = [""] + self.env.get("PATHEXT", [])
|
|
||||||
name = name.upper()
|
Conserves order of any extensions found and gives precedence
|
||||||
return [name + ext for ext in pathext]
|
to the bare name.
|
||||||
else:
|
"""
|
||||||
return [name]
|
extensions = [""] + self.env.get("PATHEXT", [])
|
||||||
|
return [name + ext for ext in extensions]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def remove_dups(paths):
|
def remove_dups(paths):
|
||||||
|
@ -159,11 +160,10 @@ class CommandsCache(cabc.Mapping):
|
||||||
# entries at the back.
|
# entries at the back.
|
||||||
paths = tuple(reversed(tuple(self.remove_dups(env.get("PATH") or []))))
|
paths = tuple(reversed(tuple(self.remove_dups(env.get("PATH") or []))))
|
||||||
if any(self._check_changes(paths)):
|
if any(self._check_changes(paths)):
|
||||||
all_cmds = {}
|
all_cmds = CacheDict()
|
||||||
for cmd, path in self._iter_binaries(paths):
|
for cmd, path in self._iter_binaries(paths):
|
||||||
key = cmd.upper() if ON_WINDOWS else cmd
|
|
||||||
# None -> not in aliases
|
# None -> not in aliases
|
||||||
all_cmds[key] = (path, None)
|
all_cmds[cmd] = (path, None)
|
||||||
|
|
||||||
# aliases override cmds
|
# aliases override cmds
|
||||||
for cmd in self.aliases:
|
for cmd in self.aliases:
|
||||||
|
@ -178,9 +178,8 @@ class CommandsCache(cabc.Mapping):
|
||||||
# (path, False) -> has same named alias
|
# (path, False) -> has same named alias
|
||||||
all_cmds[override_key] = (all_cmds[override_key][0], False)
|
all_cmds[override_key] = (all_cmds[override_key][0], False)
|
||||||
else:
|
else:
|
||||||
key = cmd.upper() if ON_WINDOWS else cmd
|
|
||||||
# True -> pure alias
|
# True -> pure alias
|
||||||
all_cmds[key] = (cmd, True)
|
all_cmds[cmd] = (cmd, True)
|
||||||
self._cmds_cache = all_cmds
|
self._cmds_cache = all_cmds
|
||||||
return self._cmds_cache
|
return self._cmds_cache
|
||||||
|
|
||||||
|
@ -218,13 +217,9 @@ class CommandsCache(cabc.Mapping):
|
||||||
|
|
||||||
def cached_name(self, name):
|
def cached_name(self, name):
|
||||||
"""Returns the name that would appear in the cache, if it exists."""
|
"""Returns the name that would appear in the cache, if it exists."""
|
||||||
if name is None:
|
|
||||||
return None
|
|
||||||
cached = pathbasename(name) if os.pathsep in name else name
|
cached = pathbasename(name) if os.pathsep in name else name
|
||||||
if ON_WINDOWS:
|
keys = self.get_possible_names(cached)
|
||||||
keys = self.get_possible_names(cached)
|
return next((k for k in keys if k in self._cmds_cache), name)
|
||||||
cached = next((k for k in keys if k in self._cmds_cache), None)
|
|
||||||
return cached
|
|
||||||
|
|
||||||
def lazyin(self, key):
|
def lazyin(self, key):
|
||||||
"""Checks if the value is in the current cache without the potential to
|
"""Checks if the value is in the current cache without the potential to
|
||||||
|
@ -262,7 +257,6 @@ class CommandsCache(cabc.Mapping):
|
||||||
Force return of binary path even if alias of ``name`` exists
|
Force return of binary path even if alias of ``name`` exists
|
||||||
(default ``False``)
|
(default ``False``)
|
||||||
"""
|
"""
|
||||||
# make sure the cache is up to date by accessing the property
|
|
||||||
self.update_cache()
|
self.update_cache()
|
||||||
return self.lazy_locate_binary(name, ignore_alias)
|
return self.lazy_locate_binary(name, ignore_alias)
|
||||||
|
|
||||||
|
@ -278,12 +272,6 @@ class CommandsCache(cabc.Mapping):
|
||||||
(default ``False``)
|
(default ``False``)
|
||||||
"""
|
"""
|
||||||
possibilities = self.get_possible_names(name)
|
possibilities = self.get_possible_names(name)
|
||||||
if ON_WINDOWS:
|
|
||||||
# Windows users expect to be able to execute files in the same
|
|
||||||
# directory without `./`
|
|
||||||
local_bin = next((fn for fn in possibilities if os.path.isfile(fn)), None)
|
|
||||||
if local_bin:
|
|
||||||
return os.path.abspath(local_bin)
|
|
||||||
cached = next((cmd for cmd in possibilities if cmd in self._cmds_cache), None)
|
cached = next((cmd for cmd in possibilities if cmd in self._cmds_cache), None)
|
||||||
if cached:
|
if cached:
|
||||||
(path, alias) = self._cmds_cache[cached]
|
(path, alias) = self._cmds_cache[cached]
|
||||||
|
@ -329,18 +317,6 @@ class CommandsCache(cabc.Mapping):
|
||||||
"""
|
"""
|
||||||
name = self.cached_name(cmd0)
|
name = self.cached_name(cmd0)
|
||||||
predictors = self.threadable_predictors
|
predictors = self.threadable_predictors
|
||||||
if ON_WINDOWS:
|
|
||||||
# On all names (keys) are stored in upper case so instead
|
|
||||||
# we get the original cmd or alias name
|
|
||||||
path, _ = self.lazyget(name, (None, None))
|
|
||||||
if path is None:
|
|
||||||
return predict_true
|
|
||||||
else:
|
|
||||||
name = pathbasename(path)
|
|
||||||
if name not in predictors:
|
|
||||||
pre, ext = os.path.splitext(name)
|
|
||||||
if pre in predictors:
|
|
||||||
predictors[name] = predictors[pre]
|
|
||||||
if name not in predictors:
|
if name not in predictors:
|
||||||
predictors[name] = self.default_predictor(name, cmd0)
|
predictors[name] = self.default_predictor(name, cmd0)
|
||||||
predictor = predictors[name]
|
predictor = predictors[name]
|
||||||
|
|
Loading…
Add table
Reference in a new issue