Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,32 +69,43 @@ $ pip install usort
To format one or more files or directories in-place:

```shell-session
$ usort format <path> [<path> ...]
$ usort format [--config config/usort.toml] <path> [<path> ...]
```

To generate a diff of changes without modifying files:

```shell-session
$ usort diff <path>
$ usort diff [--config config/usort.toml] <path>
```

To just validate that files are formatted correctly, like during CI:

```shell-session
$ usort check <path>
$ usort check [--config config/usort.toml] <path>
```

### Explicit configuration files

All CLI commands accept `--config` to point at an alternate TOML configuration file.
If omitted µsort falls back to the discovery of the nearest `pyproject.toml`.

```shell-session
$ usort format --config config/usort.toml <path>
```

### pre-commit

µsort provides a [pre-commit](https://pre-commit.com/) hook. To enforce sorted
imports before every commit, add the following to your `.pre-commit-config.yaml`
file:
file. If you keep µsort settings in a custom TOML file, pass the same
`--config` flag via `args`:

```yaml
- repo: https://github.com/facebook/usort
rev: v1.0.7
hooks:
- id: usort
args: ["--config", "config/usort.toml"]
```

## License
Expand Down
17 changes: 14 additions & 3 deletions docs/guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@ To format one or more files or directories in-place:

.. code-block:: shell-session

$ usort format <path> [<path> ...]
$ usort format [--config config/usort.toml] <path> [<path> ...]

To generate a diff of changes without modifying files:

.. code-block:: shell-session

$ usort diff <path> [<path> ...]
$ usort diff [--config config/usort.toml] <path> [<path> ...]

µsort can also be used to validate formatting as part of CI:

.. code-block:: shell-session

$ usort check <path> [<path> ...]
$ usort check [--config config/usort.toml] <path> [<path> ...]


Sorting
Expand Down Expand Up @@ -462,6 +462,17 @@ When sorting each file, µsort will look for the "nearest" :file:`pyproject.toml
to the file being sorted, looking upwards until the project root is found, or
until the root of the filesystem is reached.

Explicit config files
%%%%%%%%%%%%%%%%%%%%%

You can specify a configuration file using the ``--config`` CLI option to point
commands like ``format``, ``check``, ``diff`` and ``list-imports`` at a specific
TOML file:

.. code-block:: shell-session

$ usort format --config config/usort.toml src/

``[tool.usort]``
%%%%%%%%%%%%%%%%

Expand Down
40 changes: 30 additions & 10 deletions usort/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,23 @@ def usort_string(data: str, config: Config, path: Optional[Path] = None) -> str:
return result.output.decode()


def usort_file(path: Path, *, write: bool = False) -> Result:
def usort_file(
path: Path, *, write: bool = False, config: Optional[Config] = None
) -> Result:
"""
Format a single file and return a Result object.

Ignores any configured :py:attr:`excludes` patterns.
"""

try:
config = Config.find(path.parent)
if config is None:
config = Config.find(path.parent)

# Apply first-party detection when enabled
if config.first_party_detection:
config = config.with_first_party(Path.cwd() / path.parent)

data = path.read_bytes()
result = usort(data, config, path)

Expand All @@ -117,7 +125,9 @@ def usort_file(path: Path, *, write: bool = False) -> Result:


def usort_path(
paths: Union[Path, Iterable[Path]], write: bool = False
paths: Union[Path, Iterable[Path]],
write: bool = False,
config: Optional[Config] = None,
) -> Iterable[Result]:
"""
For a given path, format it, or any python files in it, and yield :class:`Result` s.
Expand All @@ -134,12 +144,19 @@ def usort_path(
with timed("total"):
materialized_paths: Set[Path] = set()

for path in source_paths:
with timed(f"walking {path}"):
config = Config.find(path)
materialized_paths.update(walk(path, excludes=config.excludes))
if config is None:
# discover config per-root
for path in source_paths:
with timed(f"walking {path}"):
discovered = Config.find(path)
materialized_paths.update(walk(path, excludes=discovered.excludes))
else:
# use global config
for path in source_paths:
with timed(f"walking {path}"):
materialized_paths.update(walk(path, excludes=config.excludes))

fn = partial(usort_file, write=write)
fn = partial(usort_file, write=write, config=config)
if len(materialized_paths) == 1:
# shave off multiprocessing overhead
results = [fn(materialized_paths.pop())]
Expand All @@ -148,7 +165,7 @@ def usort_path(
return results


def usort_stdin() -> bool:
def usort_stdin(config: Optional[Config] = None) -> bool:
"""
Read file contents from stdin, format it, and write the resulting file to stdout

Expand All @@ -161,7 +178,10 @@ def usort_stdin() -> bool:
print("Warning: stdin is a tty", file=sys.stderr)

try:
config = Config.find()
if config is None:
config = Config.find()
if config.first_party_detection:
config = config.with_first_party(Path.cwd())
data = sys.stdin.read()
result = usort_string(data, config, Path("<stdin>"))
sys.stdout.write(result)
Expand Down
67 changes: 56 additions & 11 deletions usort/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
import sys
from functools import wraps
from pathlib import Path
from typing import Any, Callable, List, Sequence
from typing import Any, Callable, List, Optional, Sequence

import click
from moreorless.click import echo_color_unified_diff

from usort.translate import render_node

from . import __version__
from .api import usort_path, usort_stdin
from .config import Config
Expand Down Expand Up @@ -43,6 +43,15 @@ def wrapper(*args: Any, **kwargs: Any) -> None:
return wrapper


def _load_config(config_path: Optional[Path]) -> Optional[Config]:
if config_path is None:
return None
try:
return Config.load(config_path)
except Exception as e:
raise click.ClickException(str(e))


@click.group()
@click.pass_context
@click.version_option(__version__, "--version", "-V")
Expand Down Expand Up @@ -70,17 +79,32 @@ def main(ctx: click.Context, benchmark: bool, debug: bool, native: bool) -> None
@main.command()
@click.option("--multiples", is_flag=True, help="Only show files with multiple blocks")
@click.option("--debug", is_flag=True, help="Show internal information")
@click.option(
"--config",
"config_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Explicit TOML config file path",
)
@click.argument("paths", nargs=-1, type=click.Path(dir_okay=False, path_type=Path))
@usort_command
def list_imports(multiples: bool, debug: bool, paths: List[Path]) -> int:
def list_imports(
multiples: bool, debug: bool, config_path: Optional[Path], paths: List[Path]
) -> int:
"""
Troubleshoot sorting behavior and show import blocks
"""
# This is used to debug the sort keys on the various lines, and understand
# where the barriers are that produce different blocks.

explicit_config = _load_config(config_path)

for path in paths:
config = Config.find(path)
config = explicit_config or Config.find(path)

# Apply first-party detection when enabled
if config.first_party_detection:
config = config.with_first_party(Path.cwd() / path)

mod = try_parse(path)
try:
sorter = ImportSorter(module=mod, path=path, config=config)
Expand Down Expand Up @@ -114,17 +138,24 @@ def list_imports(multiples: bool, debug: bool, paths: List[Path]) -> int:


@main.command()
@click.option(
"--config",
"config_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Explicit TOML config file path",
)
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
@usort_command
def check(paths: List[Path]) -> int:
def check(paths: List[Path], config_path: Optional[Path]) -> int:
"""
Check imports for one or more path
"""
if not paths:
raise click.ClickException("Provide some filenames")

return_code = 0
for result in usort_path(paths, write=False):
config = _load_config(config_path)
for result in usort_path(paths, write=False, config=config):
if result.error:
click.echo(f"Error sorting {result.path}: {result.error}")
return_code |= 1
Expand All @@ -142,18 +173,25 @@ def check(paths: List[Path]) -> int:


@main.command()
@click.option(
"--config",
"config_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Explicit TOML config file path",
)
@click.pass_context
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
@usort_command
def diff(ctx: click.Context, paths: List[Path]) -> int:
def diff(ctx: click.Context, config_path: Optional[Path], paths: List[Path]) -> int:
"""
Output diff of changes for one or more path
"""
if not paths:
raise click.ClickException("Provide some filenames")

return_code = 0
for result in usort_path(paths, write=False):
config = _load_config(config_path)
for result in usort_path(paths, write=False, config=config):
if result.error:
click.echo(f"Error sorting {result.path}: {result.error}")
if ctx.obj.debug:
Expand All @@ -178,9 +216,15 @@ def diff(ctx: click.Context, paths: List[Path]) -> int:


@main.command()
@click.option(
"--config",
"config_path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Explicit TOML config file path",
)
@click.argument("paths", nargs=-1, type=click.Path(allow_dash=True, path_type=Path))
@usort_command
def format(paths: List[Path]) -> int:
def format(paths: List[Path], config_path: Optional[Path]) -> int:
"""
Format one or more paths

Expand All @@ -191,12 +235,13 @@ def format(paths: List[Path]) -> int:
if not paths:
raise click.ClickException("Provide some filenames")

config = _load_config(config_path)
if paths[0] == Path("-"):
success = usort_stdin()
success = usort_stdin(config=config)
return 0 if success else 1

return_code = 0
for result in usort_path(paths, write=True):
for result in usort_path(paths, write=True, config=config):
if result.error:
click.echo(f"Error sorting {result.path}: {result.error}")
return_code |= 1
Expand Down
39 changes: 26 additions & 13 deletions usort/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, NewType, Optional, Pattern, Sequence, Set
from warnings import warn

if sys.version_info < (3, 11):
import tomli as tomllib
Expand Down Expand Up @@ -56,7 +57,7 @@ class Config:
side_effect_modules: List[str] = field(default_factory=list)
side_effect_re: Pattern[str] = field(default=re.compile(""))

# Whether to perform the first-party heuristic during find()
# Whether to perform the first-party heuristic
first_party_detection: bool = True

# Whether to follow black-style for magic trailing commas
Expand Down Expand Up @@ -85,9 +86,10 @@ def __post_init__(self) -> None:
)

@classmethod
def find(
cls, filename: Optional[Path] = None, with_first_party: bool = True
) -> "Config":
def find(cls, filename: Optional[Path] = None) -> "Config":
"""
Find and load configuration by walking up from the given path.
"""
rv = cls()

# TODO This logic should be split out to a separate project, as it's
Expand All @@ -114,15 +116,6 @@ def find(

p = p.parent

# Either param or config can force off.
if with_first_party and rv.first_party_detection:
if filename is None:
# directory
rv = rv.with_first_party(Path.cwd())
else:
# filename, ideally
rv = rv.with_first_party(Path.cwd() / filename)

return rv

def with_first_party(self, filename: Path) -> "Config":
Expand Down Expand Up @@ -212,6 +205,26 @@ def update_from_config(self, toml_path: Path) -> None:
# make sure generated regexes get updated
self.__post_init__()

@classmethod
def load(cls, path: Path) -> "Config":
"""Load a config from an explicit TOML file path.

Warns if the file does not contain a [tool.usort] or [tool.black] table.
"""
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")

text = path.read_text()
conf = tomllib.loads(text)
tool = conf.get("tool", {})
if "tool" not in conf or ("usort" not in tool and "black" not in tool):
warn(f"Config file {path} is missing [tool.usort] or [tool.black] table")

rv = cls()
# Re-parse via update_from_config to leverage existing logic
rv.update_from_config(path)
return rv

def category(self, dotted_import: str) -> Category:
"""
Given a piece of an import string, return its category for this config.
Expand Down
Loading