8000 fix(python): report dependency declarations deptry cannot read instead of skipping silently by tuyentran4992 · Pull Request #200 · habit-hooks/habit-hooks · GitHub
[go: up one dir, main page]

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
36 changes: 36 additions & 0 deletions plugins/python/src/habit_hooks_python/sensors/deptry_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import tempfile
from pathlib import Path

UNREADABLE_DECLARATION_NAMES = ("setup.py", "setup.cfg", "Pipfile")


def run_deptry(deptry: str, report: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
Expand All @@ -25,6 +27,36 @@ def deptry_found_no_declaration(result: subprocess.CompletedProcess[str]) -> boo
return "DependencySpecificationNotFoundError" in result.stderr


def unreadable_declarations() -> list[Path]:
return [
Path(name) for name in UNREADABLE_DECLARATION_NAMES if Path(name).is_file()
]


def unchecked_dependencies(declarations: list[Path]) -> list[dict]:
return [
{
"smell": "parse-error",
"details": {},
"issues": [
{
"key": str(declaration),
"details": {
"file": str(declaration),
"content": (
f"dependencies declared in {declaration} are never "
"checked — deptry reads pyproject.toml or requirements "
"files only"
),
"source": "deptry:no-declaration",
},
}
for declaration in declarations
],
}
]


def unused_dependencies(report: Path) -> list[dict]:
entries = json.loads(report.read_text(encoding="utf-8"))
return [entry for entry in entries if entry["error"]["code"] == "DEP002"]
Expand Down Expand Up @@ -61,6 +93,10 @@ def main() -> int:
result = run_deptry(deptry, report)
if deptry_crashed(result, report):
if deptry_found_no_declaration(result):
declarations = unreadable_declarations()
if declarations:
print(json.dumps(unchecked_dependencies(declarations)))
return 0
print(json.dumps([]))
return 0
sys.stderr.write(result.stderr)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

import pytest

SENSOR = (
Path(__file__).resolve().parents[1]
/ "src/habit_hooks_python/sensors/deptry_sensor.py"
)

DECLARATION_CONTENTS = {
"setup.py": "from setuptools import setup\n\nsetup(name='demo', version='0.0.0')\n",
"setup.cfg": "[metadata]\nname = demo\nversion = 0.0.0\n\n"
"[options]\ninstall_requires =\n attrs\n",
"Pipfile": "[packages]\nattrs = '*'\n",
}


def _run_sensor(tmp_path: Path, deptry: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SENSOR), deptry],
cwd=tmp_path,
capture_output=True,
encoding="utf-8",
errors="replace",
)


@pytest.mark.parametrize(
"declaration", DECLARATION_CONTENTS, ids=list(DECLARATION_CONTENTS)
)
def test_dependencies_deptry_cannot_read_are_not_silently_skipped(
tmp_path: Path, deptry: str, declaration: str
) -> None:
Comment on lines +34 to +39
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

CI's ruff check step fails on this PR: the parametrized test takes four arguments where the repo caps at three (max-args = 3 in the root pyproject). The two data arguments are one thing — a declaration file — and its contents are fixture data like any other, so a named constant keyed by filename keeps each argument meaningful and passes:

DECLARATION_CONTENTS = {
    "setup.py": "from setuptools import setup\n\nsetup(name='demo', version='0.0.0')\n",
    "setup.cfg": "[metadata]\nname = demo\nversion = 0.0.0\n\n"
    "[options]\ninstall_requires =\n    attrs\n",
    "Pipfile": "[packages]\nattrs = '*'\n",
}

@pytest.mark.parametrize(
    "declaration", DECLARATION_CONTENTS, ids=list(DECLARATION_CONTENTS)
)
def test_dependencies_deptry_cannot_read_are_not_silently_skipped(
    tmp_path: Path, deptry: str, declaration: str
) -> None:
    (tmp_path / declaration).write_text(
        DECLARATION_CONTENTS[declaration], encoding="utf-8"
    )

(tmp_path / declaration).write_text(
DECLARATION_CONTENTS[declaration], encoding="utf-8"
)
(tmp_path / "app.py").write_text("import rich\n", encoding="utf-8")

result = _run_sensor(tmp_path, deptry)

assert result.returncode == 0
findings = json.loads(result.stdout)
assert [finding["smell"] for finding in findings] == ["parse-error"]
assert [issue["details"]["file"] for issue in findings[0]["issues"]] == [
declaration
]
assert findings[0]["issues"][0]["details"]["source"] == "deptry:no-declaration"


def test_every_unreadable_declaration_is_named(tmp_path: Path, deptry: str) -> None:
(tmp_path / "setup.py").write_text(
"from setuptools import setup\n\nsetup(name='demo', version='0.0.0')\n",
encoding="utf-8",
)
(tmp_path / "Pipfile").write_text("[packages]\nattrs = '*'\n", encoding="utf-8")
(tmp_path / "app.py").write_text("import rich\n", encoding="utf-8")

result = _run_sensor(tmp_path, deptry)

assert result.returncode == 0
findings = json.loads(result.stdout)
assert [finding["smell"] for finding in findings] == ["parse-error"]
assert [issue["details"]["file"] for issue in findings[0]["issues"]] == [
"setup.py",
"Pipfile",
]
0