Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
1a671bb
exclude test_data/samples/* from black, isort, mypy
Apr 25, 2023
dbce624
remove black, isort, mypy config from tox.ini
Apr 25, 2023
056f6e2
remove black, isort, mypy config from github ci
Apr 25, 2023
ac2840c
remove testing of setup.py in tox.ini
Apr 25, 2023
5b8776c
remove setup.py
Apr 25, 2023
d50431a
move config from setup.cfg to pyproject.toml
Apr 25, 2023
0c8cf68
remove mention of setup.py in readme
Apr 25, 2023
556e674
update tox.ini for PEP517 compatibility
Apr 25, 2023
f4c9aae
Merge branch 'master' into pyproject-toml
Apr 26, 2023
8c4190a
update release ci to not use setup.py
Apr 26, 2023
aa7f40b
remove setup.py from readme
Apr 26, 2023
e1088e0
Merge branch 'master' into fix-72
Apr 26, 2023
b5456a8
introduce Config class, that parses pyproject.toml and iterates over …
Apr 26, 2023
f6439b9
remove redundant find_python_files code, add find_project_root
Apr 26, 2023
fa2b99f
use new config to iterate over python files
Apr 26, 2023
193af08
remove unnecessary error handling, both are handled by config.files()
Apr 26, 2023
5080f51
add config for ssort
Apr 26, 2023
3955ad8
update tox.ini for PEP517 compatibility
Apr 25, 2023
ce73311
Merge branch 'pyproject-toml' into fix-72
Apr 26, 2023
3d55df1
update tests for _files.py
Apr 26, 2023
3b91f35
fix bug find_project_root
Apr 26, 2023
6a659df
use tomli for python < 3.11, tomlib for python >= 3.11
Apr 26, 2023
797424b
add tomli dependency
Apr 26, 2023
cafa5bb
remove pathspec mypy block since pathspec is not longer needed
Apr 26, 2023
674ef12
remove test to check for not existent file, this cannot happen anymore
Apr 26, 2023
91d8317
mock find_project_root so that pytest does not search for pyproject.t…
Apr 26, 2023
192d9ec
ssort only sorts the inteded files, not all files under root
Apr 26, 2023
be890a4
Merge branch 'master' into fix-72
Apr 27, 2023
f18cee6
Merge branch 'master' into fix-72
Apr 30, 2023
27fee83
Revert "remove unnecessary error handling, both are handled by config…
Apr 30, 2023
7a44e8a
Revert "remove test to check for not existent file, this cannot happe…
Apr 30, 2023
64ae5b6
non existent and no py files are now handled by the config class and …
Apr 30, 2023
7d57482
FileNotFoundError case handled by config
Apr 30, 2023
7e41274
path is directory is handled by dir iterator of config
Apr 30, 2023
31381d9
add tests for config
Apr 30, 2023
de951ec
update tox.ini to use new config
Apr 30, 2023
be59549
Merge branch 'master' into fix-72
Apr 30, 2023
9415c06
define pytest test path in pyproject.toml
Apr 30, 2023
f8e49de
sync tox.ini and ci.yaml
Apr 30, 2023
d65d304
export only get_config_from_root using __all__
Apr 30, 2023
e9689b5
fix import statement to mirror the others
Apr 30, 2023
08cb4d7
add current working dir to find_project_root to find the expected pyp…
May 1, 2023
6bbf597
add skip_glob configuration key to filter files with glob pattern
May 1, 2023
ad3ff91
add tests for Config.is_invalid
May 1, 2023
8c049d1
fix a bug where glob pattern was incorrectly applied if path was not …
May 1, 2023
ee33e99
create current_working_dir to make testing easier, add more tests
May 1, 2023
77e6705
remove __pycache__ and .pytest_cache to match blacks defaults
May 1, 2023
4440ec6
ignore __pycache__ and .pytest_cache folders
May 1, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install pytest pytest-mock
pip install pyyaml==6.0
pip install -e .[test]
- name: Run tests
run: |
pytest -vv tests/
pytest -vv

coverage:
name: "Coverage"
Expand All @@ -41,12 +41,12 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov coveralls
pip install pytest pytest-cov pytest-mock coveralls
pip install pyyaml==6.0
pip install -e .[test]
- name: Run tests
run: |
pytest --cov=ssort -v tests/
pytest --cov=ssort -v
- name: Upload coverage report to coveralls
run: |
coveralls --service=github
Expand Down Expand Up @@ -102,7 +102,7 @@ jobs:
pip install -e .
- name: Run ssort
run: |
ssort --check --diff src/ tests/
ssort --check --diff .

pyflakes:
name: "PyFlakes"
Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ exclude = "test_data/samples/*"
ignore_missing_imports = true
module = "pathspec"

[tool.pytest.ini_options]
testpaths = [
"tests"
]

[tool.setuptools]
include-package-data = false
license-files = [
Expand All @@ -75,3 +80,10 @@ attr = "ssort.__version__"

[tool.setuptools.packages.find]
where = ["src"]

[tool.ssort]
extend_skip = [
".pytest_cache",
"__pycache__",
"test_data"
]
100 changes: 100 additions & 0 deletions src/ssort/_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

import sys
from dataclasses import dataclass, field
from pathlib import Path

if sys.version_info >= (3, 11):
from tomllib import load
else:
from tomli import load


__all__ = ["get_config_from_root"]


DEFAULT_SKIP = frozenset(
{
".bzr",
".direnv",
".eggs",
".git",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
}
)


def iter_valid_python_files_recursive(folder, *, is_invalid):
for child in folder.iterdir():
if is_invalid(child):
continue

elif child.is_file() and child.suffix == ".py":
yield child

elif child.is_dir():
yield from iter_valid_python_files_recursive(
child, is_invalid=is_invalid
)


@dataclass(frozen=True)
class Config:
skip: frozenset | list = DEFAULT_SKIP
skip_glob: list = field(default_factory=list)
extend_skip: list = field(default_factory=list)

def is_invalid(self, path):
if path.name in (set(self.skip) | set(self.extend_skip)):
return True

for pat in self.skip_glob:
if path.is_file() and path.match(pat):
return True

return False

def iterate_files_matching_patterns(self, pattern):
for pat in pattern:
path = Path(pat).resolve()

if path.is_file() and path.suffix == ".py":
yield path

elif path.is_dir():
yield from iter_valid_python_files_recursive(
path, is_invalid=self.is_invalid
)


def parse_pyproject_toml(path):
with open(path, "rb") as fh:
pyproject_toml = load(fh)

return pyproject_toml.get("tool", {}).get("ssort", {})


def get_config_from_root(root):
path_pyproject_toml = root / "pyproject.toml"

if path_pyproject_toml.exists():
config_dict = parse_pyproject_toml(path_pyproject_toml)
else:
config_dict = {}

return Config(**config_dict)
85 changes: 23 additions & 62 deletions src/ssort/_files.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,33 @@
from __future__ import annotations
from pathlib import Path

import os
import pathlib
from typing import Iterable
__all__ = ["find_project_root"]

import pathspec

from ssort._utils import memoize
def current_working_dir():
return Path(".").resolve()

_EMPTY_PATH_SPEC = pathspec.PathSpec([])

def find_project_root(patterns):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to track the .git root and the pyproject.toml root separately.

We want .gitigore files outside the pyproject.toml root to be respected but inside the .git root, but we want project.toml files outside the .git root to be ignored.

all_patterns = [current_working_dir()]

@memoize
def _is_project_root(path: pathlib.Path) -> bool:
if path == path.root or path == path.parent:
return True
if patterns:
all_patterns.extend(patterns)

if (path / ".git").is_dir():
return True
paths = [Path(p).resolve() for p in all_patterns]
parents_and_self = [
list(reversed(p.parents)) + ([p] if p.is_dir() else []) for p in paths
]

return False
*_, (common_base, *_) = (
common_parent
for same_lvl_parent in zip(*parents_and_self)
if len(common_parent := set(same_lvl_parent)) == 1
)

for directory in (common_base, *common_base.parents):
if (directory / ".git").exists() or (
directory / "pyproject.toml"
).is_file():
return directory

@memoize
def _get_ignore_patterns(path: pathlib.Path) -> pathspec.PathSpec:
git_ignore = path / ".gitignore"
if git_ignore.is_file():
with git_ignore.open() as f:
return pathspec.PathSpec.from_lines("gitwildmatch", f)

return _EMPTY_PATH_SPEC


def is_ignored(path: str | os.PathLike) -> bool:
# Can't use pathlib.Path.resolve() here because we want to maintain
# symbolic links.
path = pathlib.Path(os.path.abspath(path))

for part in (path, *path.parents):
patterns = _get_ignore_patterns(part)
if patterns.match_file(path.relative_to(part)):
return True

if _is_project_root(part):
return False

return False


def find_python_files(
patterns: Iterable[str | os.PathLike[str]],
) -> Iterable[pathlib.Path]:
if not patterns:
patterns = ["."]

paths_set = set()
for pattern in patterns:
path = pathlib.Path(pattern)
if not path.is_dir():
subpaths = [path]
else:
subpaths = [
subpath
for subpath in path.glob("**/*.py")
if not is_ignored(subpath) and subpath.is_file()
]

for subpath in sorted(subpaths):
if subpath not in paths_set:
paths_set.add(subpath)
yield subpath
return common_base

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's important for the git root to be per-path. If it isn't then the results of running ssort ./repo1/ ./repo2/ won't match the results of running ssort ./repo1; ssort ./repo2/. Having a single git root will also break with git submodules.

16 changes: 6 additions & 10 deletions src/ssort/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import re
import sys

from ssort._config import get_config_from_root
from ssort._exceptions import UnknownEncodingError
from ssort._files import find_python_files
from ssort._files import find_project_root
from ssort._ssort import ssort
from ssort._utils import (
detect_encoding,
Expand Down Expand Up @@ -42,19 +43,14 @@ def main():
unsortable = 0
unchanged = 0

for path in find_python_files(args.files):
root = find_project_root(args.files)
config = get_config_from_root(root)

for path in config.iterate_files_matching_patterns(args.files):
errors = False

try:
original_bytes = path.read_bytes()
except FileNotFoundError:
sys.stderr.write(f"ERROR: {escape_path(path)} does not exist\n")
unsortable += 1
continue
except IsADirectoryError:
sys.stderr.write(f"ERROR: {escape_path(path)} is a directory\n")
unsortable += 1
continue
except PermissionError:
sys.stderr.write(f"ERROR: {escape_path(path)} is not readable\n")
unsortable += 1
Expand Down
Loading