Skip to content

Commit

Permalink
Enable ruff (#143)
Browse files Browse the repository at this point in the history
  • Loading branch information
ssbarnea authored Dec 5, 2024
1 parent 6d1b13c commit 3e0fa69
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 68 deletions.
23 changes: 14 additions & 9 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,6 @@ exclude: |
^docs/conf.py$
)
repos:
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks.git
rev: v5.0.0
hooks:
Expand Down Expand Up @@ -45,6 +36,20 @@ repos:
rev: v0.24.2
hooks:
- id: toml-sort-fix
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
hooks:
- id: ruff
args:
- --fix
- --exit-non-zero-on-fix
types_or: [python, pyi]
# - id: ruff-format # must be after ruff
# types_or: [python, pyi]
- repo: https://github.com/psf/black # must be after ruff
rev: 24.10.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
Expand Down
44 changes: 41 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@ documentation = "https://subprocess-tee.readthedocs.io"
homepage = "https://github.com/pycontribs/subprocess-tee"
repository = "https://github.com/pycontribs/subprocess-tee"

[tool.isort]
known_first_party = "subprocess_tee"
profile = "black"
[tool.black]
target-version = ["py39"]

[tool.mypy]
color_output = true
Expand All @@ -89,6 +88,45 @@ warn_redundant_casts = true
warn_return_any = true
warn_unused_configs = true

[tool.pyright]
pythonVersion = "3.9"

[tool.ruff]
cache-dir = "./.cache/.ruff"
fix = true
# Same as Black.
line-length = 88
preview = true
target-version = "py39"

[tool.ruff.lint]
ignore = [
"COM812", # conflicts with ISC001 on format
"CPY001", # missing-copyright-notice
"D203", # incompatible with D211
"D213", # incompatible with D212
"E501", # we use black
"ERA001", # auto-removal of commented out code affects development and vscode integration
"INP001", # "is part of an implicit namespace package", all false positives
"ISC001", # conflicts with COM812 on format
"PLW2901", # PLW2901: Redefined loop variable
"RET504", # Unnecessary variable assignment before `return` statement
# temporary disabled until we fix them:
"ANN",
"PLR",
"ASYNC230"
]
select = ["ALL"]

[tool.ruff.lint.isort]
known-first-party = ["src"]

[tool.ruff.lint.per-file-ignores]
"test/*.py" = ["S", "T201"]

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.setuptools_scm]
local_scheme = "no-local-version"

Expand Down
65 changes: 38 additions & 27 deletions src/subprocess_tee/__init__.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
"""tee like run implementation."""

from __future__ import annotations

import asyncio
import os
import platform
import subprocess
import subprocess # noqa: S404
import sys
from asyncio import StreamReader
from importlib.metadata import PackageNotFoundError, version # type: ignore
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from shlex import join
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
from typing import TYPE_CHECKING, Any

try:
__version__ = version("subprocess-tee")
except PackageNotFoundError: # pragma: no branch
__version__ = "0.1.dev1"

__all__ = ["run", "CompletedProcess", "__version__"]
__all__ = ["CompletedProcess", "__version__", "run"]

if TYPE_CHECKING:
CompletedProcess = subprocess.CompletedProcess[Any] # pylint: disable=E1136
from collections.abc import Callable
else:
CompletedProcess = subprocess.CompletedProcess


STREAM_LIMIT = 2**23 # 8MB instead of default 64kb, override it if you need


Expand All @@ -35,18 +38,19 @@ async def _read_stream(stream: StreamReader, callback: Callable[..., Any]) -> No
break


async def _stream_subprocess(
args: Union[str, List[str]], **kwargs: Any
async def _stream_subprocess( # noqa: C901
args: str | list[str],
**kwargs: Any,
) -> CompletedProcess:
platform_settings: Dict[str, Any] = {}
platform_settings: dict[str, Any] = {}
if platform.system() == "Windows":
platform_settings["env"] = os.environ

# this part keeps behavior backwards compatible with subprocess.run
tee = kwargs.get("tee", True)
stdout = kwargs.get("stdout", sys.stdout)

with open(os.devnull, "w", encoding="UTF-8") as devnull:
with Path(os.devnull).open("w", encoding="UTF-8") as devnull:
if stdout == subprocess.DEVNULL or not tee:
stdout = devnull
stderr = kwargs.get("stderr", sys.stderr)
Expand Down Expand Up @@ -85,31 +89,31 @@ async def _stream_subprocess(
stderr=asyncio.subprocess.PIPE,
**platform_settings,
)
out: List[str] = []
err: List[str] = []
out: list[str] = []
err: list[str] = []

def tee_func(line: bytes, sink: List[str], pipe: Optional[Any]) -> None:
def tee_func(line: bytes, sink: list[str], pipe: Any | None) -> None:
line_str = line.decode("utf-8").rstrip()
sink.append(line_str)
if not kwargs.get("quiet", False):
if pipe and hasattr(pipe, "write"):
print(line_str, file=pipe)
else:
print(line_str)
print(line_str) # noqa: T201

loop = asyncio.get_running_loop()
tasks = []
if process.stdout:
tasks.append(
loop.create_task(
_read_stream(process.stdout, lambda x: tee_func(x, out, stdout))
)
_read_stream(process.stdout, lambda x: tee_func(x, out, stdout)),
),
)
if process.stderr:
tasks.append(
loop.create_task(
_read_stream(process.stderr, lambda x: tee_func(x, err, stderr))
)
_read_stream(process.stderr, lambda x: tee_func(x, err, stderr)),
),
)

await asyncio.wait(set(tasks))
Expand All @@ -132,32 +136,39 @@ def tee_func(line: bytes, sink: List[str], pipe: Optional[Any]) -> None:
)


def run(args: Union[str, List[str]], **kwargs: Any) -> CompletedProcess:
def run(args: str | list[str], **kwargs: Any) -> CompletedProcess:
"""Drop-in replacement for subprocess.run that behaves like tee.
Extra arguments added by our version:
echo: False - Prints command before executing it.
quiet: False - Avoid printing output
Returns:
CompletedProcess: ...
Raises:
CalledProcessError: ...
"""
if isinstance(args, str):
cmd = args
else:
# run was called with a list instead of a single item but asyncio
# create_subprocess_shell requires command as a single string, so
# we need to convert it to string
cmd = join(args)
# run was called with a list instead of a single item but asyncio
# create_subprocess_shell requires command as a single string, so
# we need to convert it to string
cmd = args if isinstance(args, str) else join(args)

check = kwargs.get("check", False)

if kwargs.get("echo", False):
print(f"COMMAND: {cmd}")
print(f"COMMAND: {cmd}") # noqa: T201

result = asyncio.run(_stream_subprocess(args, **kwargs))
# we restore original args to mimic subproces.run()
result.args = args

if check and result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode, args, output=result.stdout, stderr=result.stderr
result.returncode,
args,
output=result.stdout,
stderr=result.stderr,
)
return result
13 changes: 3 additions & 10 deletions test/test_func.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
"""Functional tests for subprocess-tee library."""

import subprocess
import sys

import pytest


@pytest.mark.skipif(
sys.version_info < (3, 9), reason="molecule test requires python 3.9+"
)
def test_molecule() -> None:
"""Ensures molecule does display output of its subprocesses."""
result = subprocess.run(
["molecule", "test"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
capture_output=True,
text=True,
check=False,
) # type: ignore
)
assert result.returncode == 0
assert "Past glories are poor feeding." in result.stdout
2 changes: 1 addition & 1 deletion test/test_rich.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_rich_console_ex() -> None:
# While not supposed to happen we want to be sure that this will not raise
# an exception. Some libraries may still sometimes send bytes to the
# streams, notable example being click.
# sys.stdout.write(b"epsilon\n") # type: ignore
# sys.stdout.write(b"epsilon\n")
proc = run("echo 123")
assert proc.stdout == "123\n"
text = console.export_text()
Expand Down
31 changes: 13 additions & 18 deletions test/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import subprocess
import sys
from typing import Dict

import pytest
from _pytest.capture import CaptureFixture
Expand All @@ -16,9 +15,8 @@ def test_run_string() -> None:
old_result = subprocess.run(
cmd,
shell=True,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
capture_output=True,
check=False,
)
result = run(cmd)
Expand All @@ -36,9 +34,8 @@ def test_run_list() -> None:
old_result = subprocess.run(
cmd,
# shell=True,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
capture_output=True,
check=False,
)
result = run(cmd)
Expand All @@ -53,9 +50,8 @@ def test_run_echo(capsys: CaptureFixture[str]) -> None:
old_result = subprocess.run(
cmd,
# shell=True,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
capture_output=True,
check=False,
)
result = run(cmd, echo=True)
Expand All @@ -64,15 +60,15 @@ def test_run_echo(capsys: CaptureFixture[str]) -> None:
assert result.stderr == old_result.stderr
out, err = capsys.readouterr()
assert out.startswith("COMMAND:")
assert err == ""
assert not err


@pytest.mark.parametrize(
"env",
[{}, {"SHELL": "/bin/sh"}, {"SHELL": "/bin/bash"}, {"SHELL": "/bin/zsh"}],
ids=["auto", "sh", "bash", "zsh"],
)
def test_run_with_env(env: Dict[str, str]) -> None:
def test_run_with_env(env: dict[str, str]) -> None:
"""Validate that passing custom env to run() works."""
env["FOO"] = "BAR"
result = run("echo $FOO", env=env, echo=True)
Expand Down Expand Up @@ -110,7 +106,7 @@ def test_run_with_check_raise() -> None:
with pytest.raises(subprocess.CalledProcessError) as ours:
run("false", check=True)
with pytest.raises(subprocess.CalledProcessError) as original:
subprocess.run("false", check=True, universal_newlines=True)
subprocess.run("false", check=True, text=True)
assert ours.value.returncode == original.value.returncode
assert ours.value.cmd == original.value.cmd
assert ours.value.output == original.value.output
Expand All @@ -121,7 +117,7 @@ def test_run_with_check_raise() -> None:
def test_run_with_check_pass() -> None:
"""Asure compatibility with subprocess.run when using check (return 0)."""
ours = run("true", check=True)
original = subprocess.run("true", check=True, universal_newlines=True)
original = subprocess.run("true", check=True, text=True)
assert ours.returncode == original.returncode
assert ours.args == original.args
assert ours.stdout == original.stdout
Expand All @@ -134,9 +130,8 @@ def test_run_compat() -> None:
ours = run(cmd)
original = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
capture_output=True,
text=True,
check=False,
)
assert ours.returncode == original.returncode
Expand All @@ -148,5 +143,5 @@ def test_run_compat() -> None:
def test_run_waits_for_completion(tmp_path):
"""run() should always wait for the process to complete."""
tmpfile = tmp_path / "output.txt"
run(f"sleep 0.1 && echo 42 > {str(tmpfile)}")
run(f"sleep 0.1 && echo 42 > {tmpfile!s}")
assert tmpfile.read_text() == "42\n"

0 comments on commit 3e0fa69

Please sign in to comment.