LS_COLORS add mh, change rs to fi; support ln=target

This commit is contained in:
Bob Hyman 2020-06-20 17:46:37 -04:00
parent c0089a4cef
commit 129ce9e44a
7 changed files with 267 additions and 96 deletions

View file

@ -23,5 +23,5 @@ jobs:
- shell: bash -l {0}
run: |
python -m pip install . --no-deps
python -m xonsh run-tests.xsh --timeout=90
python -m xonsh run-tests.xsh --timeout=240

View file

@ -0,0 +1,29 @@
**Added:**
* $LS_COLORS code 'mh' now recognized for (multi) hard-linked files.
* $LS_COLORS code 'ca' now recognized for files with security capabilities (linux only).
**Changed:**
* <news item>
**Deprecated:**
* <news item>
**Removed:**
* <news item>
**Fixed:**
* $LS_COLORS code 'fi' now used for "regular files", as it should have been all along. (was 'rs')
See (#3608)[https://github.com/xonsh/xonsh/issues/3608].
* pyghooks.color_files now follows implememntation of ls --color closely. Thanks @qwenger!
However, a few documented differences remain due to use in Xonsh.
* $LS_COLORS['ln'] = 'target' now works. Also fixes #3578.
**Security:**
* <news item>

View file

@ -171,6 +171,7 @@ def test_event_on_envvar_change(xonsh_builtins):
xonsh_builtins.__xonsh__.env = env
share = []
# register
@xonsh_builtins.events.on_envvar_change
def handler(name, oldvalue, newvalue, **kwargs):
share.extend((name, oldvalue, newvalue))
@ -186,6 +187,7 @@ def test_event_on_envvar_new(xonsh_builtins):
xonsh_builtins.__xonsh__.env = env
share = []
# register
@xonsh_builtins.events.on_envvar_new
def handler(name, value, **kwargs):
share.extend((name, value))
@ -201,6 +203,7 @@ def test_event_on_envvar_change_from_none_value(xonsh_builtins):
xonsh_builtins.__xonsh__.env = env
share = []
# register
@xonsh_builtins.events.on_envvar_change
def handler(name, oldvalue, newvalue, **kwargs):
share.extend((name, oldvalue, newvalue))
@ -217,6 +220,7 @@ def test_event_on_envvar_change_no_fire_when_value_is_same(val, xonsh_builtins):
xonsh_builtins.__xonsh__.env = env
share = []
# register
@xonsh_builtins.events.on_envvar_change
def handler(name, oldvalue, newvalue, **kwargs):
share.extend((name, oldvalue, newvalue))
@ -232,6 +236,7 @@ def test_events_on_envvar_called_in_right_order(xonsh_builtins):
xonsh_builtins.__xonsh__.env = env
share = []
# register
@xonsh_builtins.events.on_envvar_new
def handler(name, value, **kwargs):
share[:] = ["new"]
@ -303,21 +308,23 @@ def test_delitem_default():
def test_lscolors_target():
lsc = LsColors.fromstring("ln=target")
assert lsc["ln"] == ("TARGET",)
assert lsc["ln"] == ("NO_COLOR",)
assert lsc.is_target("ln")
assert lsc.detype() == "ln=target"
assert not (lsc.is_target("mi"))
@pytest.mark.parametrize(
"key_in,old_in,new_in,test",
[
("rs", ("NO_COLOR",), ("BLUE",), "existing key, change value"),
("rs", ("NO_COLOR",), ("NO_COLOR",), "existing key, no change in value"),
("fi", ("NO_COLOR",), ("BLUE",), "existing key, change value"),
("fi", ("NO_COLOR",), ("NO_COLOR",), "existing key, no change in value"),
("tw", None, ("NO_COLOR",), "create new key"),
("pi", ("BACKGROUND_BLACK", "YELLOW"), None, "delete existing key"),
],
)
def test_lscolors_events(key_in, old_in, new_in, test, xonsh_builtins):
lsc = LsColors.fromstring("rs=0:di=01;34:pi=40;33")
lsc = LsColors.fromstring("fi=0:di=01;34:pi=40;33")
# corresponding colors: [('NO_COLOR',), ('BOLD_CYAN',), ('BOLD_CYAN',), ('BACKGROUND_BLACK', 'YELLOW')]
event_fired = False

View file

@ -2,8 +2,10 @@
import pytest
import os
import stat
import pathlib
from tempfile import TemporaryDirectory
from xonsh.platform import ON_WINDOWS
from xonsh.pyghooks import (
XonshStyle,
@ -15,7 +17,6 @@ from xonsh.pyghooks import (
)
from xonsh.environ import LsColors
from tools import skip_if_on_windows
@pytest.fixture
@ -25,11 +26,10 @@ def xonsh_builtins_LS_COLORS(xonsh_builtins):
lsc = LsColors(LsColors.default_settings)
xonsh_builtins.__xonsh__.env["LS_COLORS"] = lsc
xonsh_builtins.__xonsh__.shell.shell_type = "prompt_toolkit"
# styler = XonshStyle() # default style
# xonsh_builtins.__xonsh__.shell.shell.styler = styler
# can't really instantiate XonshStyle separate from a shell??
xonsh_builtins.__xonsh__.shell.shell.styler = XonshStyle() # default style
yield xonsh_builtins
xonsh_builtins.__xonsh__.env = e
@ -157,29 +157,62 @@ def test_XonshStyle_init_file_color_tokens(xonsh_builtins_LS_COLORS):
)
_cf = {
"rs": "regular",
"di": "simple_dir",
"ln": "simple_link",
"mh": None,
"pi": "pipe",
"so": None,
"do": None,
# bug ci failures: 'bd': '/dev/sda',
# bug ci failures:'cd': '/dev/tty',
"or": "orphan_link",
"mi": None,
"su": "set_uid",
"sg": "set_gid",
"ca": None,
"tw": "sticky_ow_dir",
"ow": "other_writable_dir",
"st": "sticky_dir",
"ex": "executable",
"*.emf": "foo.emf",
"*.zip": "foo.zip",
"*.ogg": "foo.ogg",
}
# parameterized tests for file colorization
# note 'ca' is checked by standalone test.
# requires privilege to create a file with capabilities
if ON_WINDOWS:
# file coloring support is very limited on Windows, only test the cases we can easily make work
# If you care about file colors, use Windows Subsystem for Linux, or another OS.
_cf = {
"fi": "regular",
"di": "simple_dir",
"ln": "sym_link",
"pi": None,
"so": None,
"do": None,
# bug ci failures: 'bd': '/dev/sda',
# bug ci failures:'cd': '/dev/tty',
"or": "orphan",
"mi": None, # never used
"su": None,
"sg": None,
"ca": None, # Separate special case test,
"tw": None,
"ow": None,
"st": None,
"ex": None, # executable is a filetype test on Windows.
"*.emf": "foo.emf",
"*.zip": "foo.zip",
"*.ogg": "foo.ogg",
"mh": "hard_link",
}
else:
# full-fledged, VT100 based infrastructure
_cf = {
"fi": "regular",
"di": "simple_dir",
"ln": "sym_link",
"pi": "pipe",
"so": None,
"do": None,
# bug ci failures: 'bd': '/dev/sda',
# bug ci failures:'cd': '/dev/tty',
"or": "orphan",
"mi": None, # never used
"su": "set_uid",
"sg": "set_gid",
"ca": None, # Separate special case test,
"tw": "sticky_ow_dir",
"ow": "other_writable_dir",
"st": "sticky_dir",
"ex": "executable",
"*.emf": "foo.emf",
"*.zip": "foo.zip",
"*.ogg": "foo.ogg",
"mh": "hard_link",
}
@pytest.fixture(scope="module")
@ -203,18 +236,19 @@ def colorizable_files():
os.mkdir(file_path)
else:
open(file_path, "a").close()
if k in ("di", "rg"):
if k in ("di", "fi"):
pass
elif k == "ex":
os.chmod(file_path, stat.S_IXUSR)
elif k == "ln":
os.chmod(file_path, stat.S_IRWXU) # tmpdir on windows need u+w
elif k == "ln": # cook ln test case.
os.chmod(file_path, stat.S_IRWXU) # link to *executable* file
os.rename(file_path, file_path + "_target")
os.symlink(file_path + "_target", file_path)
elif k == "or":
os.rename(file_path, file_path + "_target")
os.symlink(file_path + "_target", file_path)
os.remove(file_path + "_target")
elif k == "pi":
elif k == "pi": # not on Windows
os.remove(file_path)
os.mkfifo(file_path)
elif k == "su":
@ -232,24 +266,72 @@ def colorizable_files():
)
elif k == "ow":
os.chmod(file_path, stat.S_IWOTH | stat.S_IRUSR | stat.S_IWUSR)
elif k == "mh":
os.rename(file_path, file_path + "_target")
os.link(file_path + "_target", file_path)
else:
pass # cauterize those elseless ifs!
os.symlink(file_path, file_path + "_symlink")
yield tempdir
pass # tempdir get cleaned up here.
@pytest.mark.parametrize(
"key,file_path", [(key, file_path) for key, file_path in _cf.items() if file_path]
"key,file_path", [(key, file_path) for key, file_path in _cf.items() if file_path],
)
@skip_if_on_windows
def test_colorize_file(key, file_path, colorizable_files, xonsh_builtins_LS_COLORS):
xonsh_builtins_LS_COLORS.__xonsh__.shell.shell.styler = (
XonshStyle()
) # default style
"""test proper file codes with symlinks colored normally"""
ffp = colorizable_files + "/" + file_path
mode = (os.lstat(ffp)).st_mode
color_token, color_key = color_file(ffp, mode)
stat_result = os.lstat(ffp)
color_token, color_key = color_file(ffp, stat_result)
assert color_key == key, "File classified as expected kind"
assert color_token == file_color_tokens[key], "Color token is as expected"
@pytest.mark.parametrize(
"key,file_path", [(key, file_path) for key, file_path in _cf.items() if file_path],
)
def test_colorize_file_symlink(
key, file_path, colorizable_files, xonsh_builtins_LS_COLORS
):
"""test proper file codes with symlinks colored target."""
xonsh_builtins_LS_COLORS.__xonsh__.env["LS_COLORS"]["ln"] = "target"
ffp = colorizable_files + "/" + file_path + "_symlink"
stat_result = os.lstat(ffp)
assert stat.S_ISLNK(stat_result.st_mode)
_, color_key = color_file(ffp, stat_result)
try:
tar_stat_result = os.stat(ffp) # stat the target of the link
tar_ffp = str(pathlib.Path(ffp).resolve())
_, tar_color_key = color_file(tar_ffp, tar_stat_result)
if tar_color_key.startswith("*"):
tar_color_key = (
"fi" # all the *.* zoo, link is colored 'fi', not target type.
)
except FileNotFoundError: # orphan symlinks always colored 'or'
tar_color_key = "or" # Fake if for missing file
assert color_key == tar_color_key, "File classified as expected kind, via symlink"
import xonsh.lazyimps
def test_colorize_file_ca(xonsh_builtins_LS_COLORS, monkeypatch):
def mock_os_listxattr(*args, **kwards):
return ["security.capability"]
monkeypatch.setattr(xonsh.pyghooks, "os_listxattr", mock_os_listxattr)
with TemporaryDirectory() as tmpdir:
file_path = tmpdir + "/cap_file"
open(file_path, "a").close()
os.chmod(file_path, stat.S_IXUSR) # ca overrides ex
color_token, color_key = color_file(file_path, os.lstat(file_path))
assert color_key == "ca"

View file

@ -198,8 +198,8 @@ def to_debug(x):
class LsColors(cabc.MutableMapping):
"""Helps convert to/from $LS_COLORS format, respecting the xonsh color style.
This accepts the same inputs as dict(). The link ``target`` is represented
by the special ``"TARGET"`` color.
This accepts the same inputs as dict(). The special value ``target`` is
replaced by no color, but sets a flag for cognizant application (see is_target()).
"""
default_settings = {
@ -321,6 +321,7 @@ class LsColors(cabc.MutableMapping):
"di": ("BOLD_BLUE",),
"do": ("BOLD_PURPLE",),
"ex": ("BOLD_GREEN",),
"fi": ("NO_COLOR",),
"ln": ("BOLD_CYAN",),
"mh": ("NO_COLOR",),
"mi": ("NO_COLOR",),
@ -335,10 +336,17 @@ class LsColors(cabc.MutableMapping):
"tw": ("BLACK", "BACKGROUND_GREEN"),
}
def __init__(self, *args, **kwargs):
self._d = dict(*args, **kwargs)
target_value = "target" # special value to set for ln=target
target_color = ("NO_COLOR",) # repres in color space
def __init__(self, ini_dict: dict = None):
self._style = self._style_name = None
self._detyped = None
self._d = dict()
self._targets = set()
if ini_dict:
for key, value in ini_dict.items():
self[key] = value # in case init includes ln=target
def __getitem__(self, key):
return self._d[key]
@ -346,13 +354,20 @@ class LsColors(cabc.MutableMapping):
def __setitem__(self, key, value):
self._detyped = None
old_value = self._d.get(key, None)
self._targets.discard(key)
if value == LsColors.target_value:
value = LsColors.target_color
self._targets.add(key)
self._d[key] = value
if old_value != value:
if (
old_value != value
): # bug won't fire if new value is 'target' and old value happened to be no color.
events.on_lscolors_change.fire(key=key, oldvalue=old_value, newvalue=value)
def __delitem__(self, key):
self._detyped = None
old_value = self._d.get(key, None)
self._targets.discard(key)
del self._d[key]
events.on_lscolors_change.fire(key=key, oldvalue=old_value, newvalue=None)
@ -377,6 +392,10 @@ class LsColors(cabc.MutableMapping):
p.break_()
p.pretty(dict(self))
def is_target(self, key) -> bool:
"Return True if key is 'target'"
return key in self._targets
def detype(self):
"""De-types the instance, allowing it to be exported to the environment."""
style = self.style
@ -387,8 +406,8 @@ class LsColors(cabc.MutableMapping):
+ "="
+ ";".join(
[
"target"
if v == "TARGET"
LsColors.target_value
if key in self._targets
else ansi_color_name_to_escape_code(v, cmap=style)
for v in val
]
@ -426,23 +445,21 @@ class LsColors(cabc.MutableMapping):
# string inputs always use default codes, so translating into
# xonsh names should be done from defaults
reversed_default = ansi_reverse_style(style="default")
data = {}
for item in s.split(":"):
key, eq, esc = item.partition("=")
if not eq:
# not a valid item
pass
elif esc == "target":
data[key] = ("TARGET",)
elif esc == LsColors.target_value: # really only for 'ln'
obj[key] = esc
else:
try:
data[key] = ansi_color_escape_code_to_name(
obj[key] = ansi_color_escape_code_to_name(
esc, "default", reversed_style=reversed_default
)
except Exception as e:
print("xonsh:warning:" + str(e), file=sys.stderr)
data[key] = ("NO_COLOR",)
obj._d = data
obj[key] = ("NO_COLOR",)
return obj
@classmethod

View file

@ -1,4 +1,5 @@
"""Lazy imports that may apply across the xonsh package."""
import os
import importlib
from xonsh.platform import ON_WINDOWS, ON_DARWIN
@ -88,3 +89,11 @@ def terminal256():
@lazyobject
def html():
return importlib.import_module("pygments.formatters.html")
@lazyobject
def os_listxattr():
def dummy_listxattr(*args, **kwargs):
return []
return getattr(os, "listxattr", dummy_listxattr)

View file

@ -8,6 +8,7 @@ import stat
from collections import ChainMap
from collections.abc import MutableMapping
from keyword import iskeyword
from xonsh.lazyimps import os_listxattr
from pygments.lexer import inherit, bygroups, include
from pygments.lexers.agile import Python3Lexer
@ -1356,15 +1357,16 @@ def on_lscolors_change(key, oldvalue, newvalue, **kwargs):
events.on_lscolors_change(on_lscolors_change)
def color_file(file_path: str, mode: int) -> (Color, str):
"""Determine color to use for file as ls -c would, given stat() results and its name.
def color_file(file_path: str, path_stat: os.stat_result) -> (Color, str):
"""Determine color to use for file *approximately* as ls --color would,
given lstat() results and its path.
Parameters
----------
file_path : string
file_path:
relative path of file (as user typed it).
mode : int
stat() results for file_path.
path_stat:
lstat() results for file_path.
Returns
-------
@ -1373,54 +1375,79 @@ def color_file(file_path: str, mode: int) -> (Color, str):
Notes
-----
* doesn't handle CA (capability)
* doesn't handle LS TARGET mapping
* implementation follows one authority:
https://github.com/coreutils/coreutils/blob/master/src/ls.c#L4879
* except:
1. does not return 'mi'. That's the color ls uses to show the (missing) *target* of a symlink
(in ls -l, not ls).
2. in dircolors, setting type code to '0 or '00' bypasses that test and proceeds to others.
In our implementation, setting code to '00' paints the file with no color.
This is arguably a bug.
"""
lsc = builtins.__xonsh__.env["LS_COLORS"]
color_key = "rs"
color_key = "fi"
if stat.S_ISLNK(mode): # must test link before S_ISREG (esp execute)
color_key = "ln"
# if symlink, get info on (final) target
if stat.S_ISLNK(path_stat.st_mode):
try:
os.stat(file_path)
except FileNotFoundError:
color_key = "or"
elif stat.S_ISREG(mode):
if stat.S_IMODE(mode) & (stat.S_IXUSR + stat.S_IXGRP + stat.S_IXOTH):
color_key = "ex"
elif (
mode & stat.S_ISUID
): # too many tests before we get to the common case -- restructure?
tar_path_stat = os.stat(file_path) # and work with its properties
if lsc.is_target("ln"): # if ln=target
path_stat = tar_path_stat
except FileNotFoundError: # bug always color broken link 'or'
color_key = "or" # early exit
ret_color_token = file_color_tokens.get(color_key, Text)
return ret_color_token, color_key
mode = path_stat.st_mode
if stat.S_ISREG(mode):
if mode & stat.S_ISUID:
color_key = "su"
elif mode & stat.S_ISGID:
color_key = "sg"
else:
match = color_file_extension_RE.match(file_path)
if match:
ext = "*" + match.group(1) # look for *.<fileExtension> coloring
if ext in lsc:
color_key = ext
else:
color_key = "rs"
cap = os_listxattr(file_path, follow_symlinks=False)
if cap and "security.capability" in cap: # protect None return on some OS?
color_key = "ca"
elif stat.S_IMODE(mode) & (stat.S_IXUSR + stat.S_IXGRP + stat.S_IXOTH):
color_key = "ex"
elif path_stat.st_nlink > 1:
color_key = "mh"
else:
color_key = "rs"
elif stat.S_ISDIR(mode): # ls -c doesn't colorize sticky or ow if not dirs...
color_key = ("di", "ow", "st", "tw")[
(mode & stat.S_ISVTX == stat.S_ISVTX) * 2
+ (mode & stat.S_IWOTH == stat.S_IWOTH)
]
elif stat.S_ISCHR(mode):
color_key = "cd"
elif stat.S_ISBLK(mode):
color_key = "bd"
color_key = "fi"
elif stat.S_ISDIR(mode): # ls --color doesn't colorize sticky or ow if not dirs...
color_key = "di"
if (mode & stat.S_ISVTX) and (mode & stat.S_IWOTH):
color_key = "tw"
elif mode & stat.S_IWOTH:
color_key = "ow"
elif mode & stat.S_ISVTX:
color_key = "st"
elif stat.S_ISLNK(mode):
color_key = "ln"
elif stat.S_ISFIFO(mode):
color_key = "pi"
elif stat.S_ISSOCK(mode):
color_key = "so"
elif stat.S_ISBLK(mode):
color_key = "bd"
elif stat.S_ISCHR(mode):
color_key = "cd"
elif stat.S_ISDOOR(mode):
color_key = "do" # bug missing mapping for FMT based PORT and WHITEOUT ??
color_key = "do"
else:
color_key = "or" # any other type --> orphan
# if still normal file -- try color by file extension.
# note: symlink to *.<ext> will be colored 'fi' unless the symlink itself
# ends with .<ext>. `ls` does the same. Bug-for-bug compatibility!
if color_key == "fi":
match = color_file_extension_RE.match(file_path)
if match:
ext = "*" + match.group(1) # look for *.<fileExtension> coloring
if ext in lsc:
color_key = ext
ret_color_token = file_color_tokens.get(color_key, Text)
@ -1464,8 +1491,8 @@ def subproc_arg_callback(_, match):
yieldVal = Text
try:
path = os.path.expanduser(text)
mode = (os.lstat(path)).st_mode # lstat() will raise FNF if not a real file
yieldVal, _ = color_file(path, mode)
path_stat = os.lstat(path) # lstat() will raise FNF if not a real file
yieldVal, _ = color_file(path, path_stat)
except (FileNotFoundError, OSError):
pass