Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
28e98a1
introduce Config class, that parses pyproject.toml and iterates over …
Apr 26, 2023
4015253
remove redundant find_python_files code, add find_project_root
Apr 26, 2023
e22fd32
use new config to iterate over python files
Apr 26, 2023
51f1b1e
remove unnecessary error handling, both are handled by config.files()
Apr 26, 2023
76f8171
add config for ssort
Apr 26, 2023
36a791b
update tests for _files.py
Apr 26, 2023
4719570
fix bug find_project_root
Apr 26, 2023
ad0c19a
use tomli for python < 3.11, tomlib for python >= 3.11
Apr 26, 2023
2f45861
add tomli dependency
Apr 26, 2023
eb478e5
remove pathspec mypy block since pathspec is not longer needed
Apr 26, 2023
60348f1
remove test to check for not existent file, this cannot happen anymore
Apr 26, 2023
6106a21
mock find_project_root so that pytest does not search for pyproject.t…
Apr 26, 2023
0ced832
ssort only sorts the inteded files, not all files under root
Apr 26, 2023
ae5727e
Revert "remove unnecessary error handling, both are handled by config…
Apr 30, 2023
ac0c4e3
Revert "remove test to check for not existent file, this cannot happe…
Apr 30, 2023
4d5301e
non existent and no py files are now handled by the config class and …
Apr 30, 2023
ad42a0b
FileNotFoundError case handled by config
Apr 30, 2023
3a56772
path is directory is handled by dir iterator of config
Apr 30, 2023
f08581d
add tests for config
Apr 30, 2023
9917510
define pytest test path in pyproject.toml
Apr 30, 2023
6b08488
sync tox.ini and ci.yaml
Apr 30, 2023
5323923
export only get_config_from_root using __all__
Apr 30, 2023
4d8e81b
fix import statement to mirror the others
Apr 30, 2023
d7c7cce
add current working dir to find_project_root to find the expected pyp…
May 1, 2023
33d455f
add skip_glob configuration key to filter files with glob pattern
May 1, 2023
4641a52
add tests for Config.is_invalid
May 1, 2023
f91b0d4
fix a bug where glob pattern was incorrectly applied if path was not …
May 1, 2023
b11811a
create current_working_dir to make testing easier, add more tests
May 1, 2023
e7dcc18
remove __pycache__ and .pytest_cache to match blacks defaults
May 1, 2023
4fab85b
ignore __pycache__ and .pytest_cache folders
May 1, 2023
5043ed1
move sort_key_from_iter to _grahps.py
Apr 30, 2023
d569448
move single_dispatch to its own file
Apr 30, 2023
a8c2da0
move cached_method to _statements.py
Apr 30, 2023
44ee98a
move remaining functions to _files.py
Apr 30, 2023
706d074
sync tox.ini and pyproject.toml
May 5, 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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
pip install -e .[test]
- name: Run tests
run: |
pytest -vv tests/
pytest -vv

coverage:
name: "Coverage"
Expand All @@ -46,7 +46,7 @@ jobs:
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
17 changes: 13 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ classifiers = [
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Quality Assurance"
]
dependencies = [
"pathspec >=0.9.0"
"tomli; python_version<'3.11'"
]
description = "The python statement sorter"
dynamic = [
Expand Down Expand Up @@ -60,9 +61,10 @@ profile = "black"
[tool.mypy]
exclude = "test_data/samples/*"

[[tool.mypy.overrides]]
ignore_missing_imports = true
module = "pathspec"
[tool.pytest.ini_options]
testpaths = [
"tests"
]

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

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

[tool.ssort]
extend_skip = [
".pytest_cache",
"__pycache__",
"test_data"
]
2 changes: 1 addition & 1 deletion src/ssort/_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
from typing import Iterable

from ssort._utils import single_dispatch
from ssort._single_dispatch import single_dispatch


@single_dispatch
Expand Down
2 changes: 1 addition & 1 deletion src/ssort/_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Iterable

from ssort._ast import iter_child_nodes
from ssort._utils import single_dispatch
from ssort._single_dispatch import single_dispatch


@single_dispatch
Expand Down
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)
163 changes: 93 additions & 70 deletions src/ssort/_files.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,95 @@
from __future__ import annotations

import os
import pathlib
from typing import Iterable

import pathspec

from ssort._utils import memoize

_EMPTY_PATH_SPEC = pathspec.PathSpec([])


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

if (path / ".git").is_dir():
return True

return False


@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
import io
import re
import shlex
import sys
import tokenize
from pathlib import Path

from ssort._exceptions import UnknownEncodingError

__all__ = [
"detect_encoding",
"detect_newline",
"escape_path",
"find_project_root",
"normalize_newlines",
]


_NEWLINE_RE = re.compile("(\r\n)|(\r)|(\n)")


def current_working_dir():
return Path(".").resolve()


def find_project_root(patterns):
all_patterns = [current_working_dir()]

if patterns:
all_patterns.extend(patterns)

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
]

*_, (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

return common_base


def escape_path(path):
"""
Takes a `pathlib.Path` object and returns a string representation that can
be safely copied into the system shell.
"""
if sys.platform == "win32":
# TODO
return str(path)
else:
return shlex.quote(str(path))


def detect_encoding(bytestring):
"""
Detect the encoding of a python source file based on "coding" comments, as
defined in [PEP 263](https://www.python.org/dev/peps/pep-0263/).
"""
try:
encoding, _ = tokenize.detect_encoding(io.BytesIO(bytestring).readline)
except SyntaxError as exc:
raise UnknownEncodingError(
exc.msg, encoding=re.match("unknown encoding: (.*)", exc.msg)[1]
) from exc
return encoding


def detect_newline(text):
"""
Detects the newline character used in a source file based on the first
occurence of '\\n', '\\r' or '\\r\\n'.
"""
match = re.search(_NEWLINE_RE, text)
if match is None:
return "\n"
return match[0]


def normalize_newlines(text):
"""
Replaces all occurrences of '\r' and '\\r\\n' with \n.
"""
return re.sub(_NEWLINE_RE, "\n", text)
7 changes: 5 additions & 2 deletions src/ssort/_graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

from typing import Callable, Generic, Hashable, TypeVar

from ssort._utils import sort_key_from_iter

_T = TypeVar("_T", bound=Hashable)


Expand Down Expand Up @@ -140,6 +138,11 @@ def is_topologically_sorted(nodes: list[_T], graph: Graph[_T]) -> bool:
return True


def sort_key_from_iter(values):
index = {statement: index for index, statement in enumerate(values)}
return lambda value: index[value]


def topological_sort(
target: Graph[_T] | list[_T], /, *, graph: Graph[_T] | None = None
) -> list[_T]:
Expand Down
Loading