Skip to content

Commit

Permalink
fixes expanduser for #21
Browse files Browse the repository at this point in the history
  • Loading branch information
epogrebnyak committed Mar 17, 2024
1 parent 432908a commit f74657e
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 87 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ of a package. You can discover more duplicate directories with `--follow-symlink
$ justpath --duplicates --follow-symlinks --includes dotnet
6 /home/codespace/.dotnet (resolves to /usr/local/dotnet/7.0.306, duplicates: 2)
32 /usr/local/dotnet/current (resolves to /usr/local/dotnet/7.0.306, duplicates: 2)

$ justpath --duplicates --follow-symlinks --includes java
10 /home/codespace/java/current/bin (resolves to /usr/local/sdkman/candidates/java/21.0.1-ms/bin, duplicates: 2)
19 /usr/local/sdkman/candidates/java/current/bin (resolves to /usr/local/sdkman/candidates/java/21.0.1-ms/bin, duplicates: 2)
```

## Installation
Expand Down
148 changes: 70 additions & 78 deletions justpath/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
from collections import Counter, UserDict
from dataclasses import dataclass
from json import dumps
from os.path import realpath
from pathlib import Path
from typing import Annotated, Type, TypedDict
from typing import Annotated, Type

from colorama import Fore, Style
from typer import Option, Typer
Expand All @@ -19,104 +18,89 @@
class Directory:
original: str
canonical: str
resolved: str
is_directory: bool
does_exist: bool

@staticmethod
def to_canonical(path: str) -> str:
fs = [
os.path.expandvars, # expands %name% or $NAME
os.path.expanduser, # expands ~
os.path.normcase, # to lowercase
]
for f in fs:
path = f(path) # type: ignore
return path

@classmethod
def from_path(cls, path: str):
visible_path = cls.to_canonical(path)
return cls(
os.path.normcase(path),
os.path.realpath(path),
os.path.isdir(path),
os.path.exists(path),
path,
visible_path,
os.path.realpath(visible_path), # resolve symlinks
os.path.isdir(visible_path),
os.path.exists(visible_path),
)

@property
def is_valid(self) -> bool:
return self.is_directory and self.does_exist

def includes(self, string):
return string.lower() in self.canonical.lower()

class PathVariable(UserDict[int, Path]):

class PathVariable(UserDict[int, Path]):
@classmethod
def populate(cls):
def from_list(cls, paths):
result = cls()
for i, path in enumerate(os.environ["PATH"].split(os.pathsep)):
for i, path in enumerate(paths):
result[i + 1] = Directory.from_path(path)
return result

@classmethod
def populate(cls):
return cls.from_list(os.environ["PATH"].split(os.pathsep))

@property
def paths(self):
return [v.orginial for v in self.values()]

def yield_rows(self, getter):
counter = Counter([getter(d) for d in self.values()])
for i, d in self.items():
yield (i, d, counter[getter(d)])

def rows_original(self):
return list(self.yield_rows(lambda d: d.original))

def rows_resolved(self):
return list(self.yield_rows(lambda d: d.canonical))
def get_rows(self, getter):
rows = []
counter = Counter([getter(directory) for directory in self.values()])
for i, directory in self.items():
row = Row(i, directory, counter[getter(directory)])
rows.append(row)
return rows

#pv = PathVariable.populate()
#print(pv.rows_resolved())


class PathVar(UserDict[int, Path]):
@classmethod
def from_list(cls, paths):
return cls([(i + 1, Path(p)) for i, p in enumerate(paths)])

@classmethod
def populate(cls):
return cls.from_list(os.environ["PATH"].split(os.pathsep))
def to_rows(self, follow_symlinks: bool):
getter = (lambda d: d.resolved) if follow_symlinks else (lambda d: d.original)
return self.get_rows(getter)

# not tested
def drop(self, i: int):
del self.data[i]

# not tested
def append(self, path: Path):
key = 1 + max(self.keys())
self.data[key] = path

@property
def max_digits(self) -> int:
"""Number of digits in a line number, usually 1 or 2."""
return len(str(max(self.keys()))) if self.keys() else 0

def offset(self, i: int):
return str(i).rjust(self.max_digits)

def to_rows(self, follow_symlinks: bool) -> list["Row"]:
getter = resolve if follow_symlinks else as_is
counter = Counter([getter(path) for path in self.values()])

def make_row(i, path):
return Row(i, path, counter[getter(path)])

return [make_row(i, path) for i, path in self.items()]


def resolve(path: Path) -> str:
return str(path.resolve()).lower()


def as_is(path: Path) -> str:
return str(path).lower()
self.data[key] = Directory.from_path(path)


@dataclass
class Row:
i: int
path: Path
directory: Directory
count: int

@property
def error(self) -> Type[FileNotFoundError] | Type[NotADirectoryError] | None:
if not os.path.exists(self.path):
if not self.directory.does_exist:
return FileNotFoundError
elif not os.path.isdir(self.path):
elif not self.directory.is_directory:
return NotADirectoryError
else:
return None
Expand All @@ -126,7 +110,17 @@ def has_error(self):
return self.error is not None

def __hash__(self):
return hash(realpath(self.path).lower())
return hash(str(self.i) + self.directory.canonical)


def make_formatter(rows):
# Number of digits in a line number, usually 1 or 2
max_digits = len(str(max(row.i for row in rows))) if rows else 1

def offset(i: int) -> str:
return str(i).rjust(max_digits)

return offset


typer_app = Typer(
Expand All @@ -141,7 +135,7 @@ def show_raw():

def show_stats(json: bool, follow_symlinks: bool):
"""Number of directories in your PATH."""
path_var = PathVar.populate()
path_var = PathVariable.populate()
t = len(path_var)
rows = path_var.to_rows(follow_symlinks)
e = sum([1 for row in rows if row.has_error])
Expand Down Expand Up @@ -193,7 +187,7 @@ def show(
if count:
show_stats(json, follow_symlinks)
sys.exit(0)
path_var = PathVar.populate()
path_var = PathVariable.populate()
rows = path_var.to_rows(follow_symlinks)
if correct:
purge_duplicates = True
Expand All @@ -209,7 +203,7 @@ def show(
excludes,
follow_symlinks,
)
paths = [str(row.path) for row in rows]
paths = [str(row.directory.original) for row in rows]
if bare:
comments = False
numbers = False
Expand All @@ -219,14 +213,14 @@ def show(
elif json:
print(dumps(paths, indent=2))
else:
offset = make_formatter(rows)
for row in rows:
items = [
get_color(row) if color else "",
path_var.offset(row.i) if numbers else "",
str(row.path),
(get_color(row) if color else "") + (offset(row.i) if numbers else ""),
str(row.directory.original),
get_comment(row) if comments else "",
]
print("".join(item for item in items if item))
print(" ".join(item for item in items if item))
if color:
print(Style.RESET_ALL, end="")

Expand All @@ -243,7 +237,7 @@ def modify_rows(
follow_symlinks,
):
if sort:
rows = sorted(rows, key=lambda r: realpath(r.path))
rows = sorted(rows, key=lambda r: r.directory.canonical)
if duplicates:
rows = [row for row in rows if row.count > 1]
if purge_duplicates:
Expand All @@ -253,11 +247,9 @@ def modify_rows(
if purge_invalid:
rows = [row for row in rows if not row.has_error]
if includes:
rows = [row for row in rows if includes.lower() in realpath(row.path).lower()]
rows = [row for row in rows if row.directory.includes(includes)]
if excludes:
rows = [
row for row in rows if excludes.lower() not in realpath(row.path).lower()
]
rows = [row for row in rows if not row.directory.includes(excludes)]
return rows


Expand All @@ -273,8 +265,8 @@ def get_color(row: Row):
def get_comment(row: Row) -> str:
"""Create a string that tells about a type of error or property encountered."""
items = []
if row.path.is_symlink():
items.append(f"resolves to {row.path.resolve()}")
if row.directory.canonical != row.directory.resolved:
items.append(f"resolves to {row.directory.resolved}")
if row.error == FileNotFoundError:
items.append("directory does not exist")
elif row.error == NotADirectoryError():
Expand All @@ -291,9 +283,9 @@ def remove_duplicates(rows, follow_symlinks):
result = []
for row in rows:
if follow_symlinks:
p = resolve(row.path)
p = row.directory.resolved
else:
p = as_is(row.path)
p = row.directory.original
if p not in seen:
seen.add(p)
row.count = 1
Expand Down
17 changes: 12 additions & 5 deletions sandbox/canonical_path.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import os


def canonical(path):
fs = [os.path.expandvars, os.path.expanduser, os.path.normcase]
fs = [
os.path.expandvars, # expands variables like %name% or $NAME, perhaps rare on PATH
os.path.expanduser, # expands ~, shell-dependent
os.path.normcase, # to lowercase, to make prettier
]
for f in fs:
path = f(path)
return path
return path


print(os.path.exists("~")) # False
print(os.path.exists(os.path.abspath("~"))) # False
print(os.path.exists(canonical("~"))) # True
print(os.path.exists("~")) # False
print(os.path.exists(os.path.abspath("~"))) # False
print(os.path.exists(canonical("~"))) # True
print(os.path.isdir("~")) # False
print(os.path.isdir(canonical("~"))) # True
13 changes: 9 additions & 4 deletions tests/test_show.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pytest
from typer.testing import CliRunner

from justpath.show import PathVar, remove_duplicates, typer_app
from justpath.show import PathVariable, remove_duplicates, typer_app

commands = [
["--help"],
Expand Down Expand Up @@ -43,8 +43,13 @@ def test_it_runs_with_subprocess(args):
assert result.returncode == 0


def test_guarantee_homedir():
pv = PathVariable.from_list(["~"])
assert pv.to_rows(follow_symlinks=False)[0].directory.does_exist


def test_from_list(tmp_path):
pv = PathVar.from_list([tmp_path / "a", tmp_path / "b"])
pv = PathVariable.from_list([tmp_path / "a", tmp_path / "b"])
assert len(pv) == 2


Expand All @@ -58,10 +63,10 @@ def test_with_simlinks(tmp_path):
print(a)
print(b)
print(b.resolve())
rows = PathVar.from_list([a, b]).to_rows(follow_symlinks=False)
rows = PathVariable.from_list([a, b]).to_rows(follow_symlinks=False)
assert rows[0].count == 1
assert rows[0].count == 1
rows = PathVar.from_list([a, b]).to_rows(follow_symlinks=True)
rows = PathVariable.from_list([a, b]).to_rows(follow_symlinks=True)
assert rows[0].count == 2
assert rows[1].count == 2
rows1 = remove_duplicates(rows, follow_symlinks=True)
Expand Down

0 comments on commit f74657e

Please sign in to comment.