nk-python-style 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nk_python_style-0.1.0/PKG-INFO +60 -0
- nk_python_style-0.1.0/README.md +50 -0
- nk_python_style-0.1.0/nk_python/__init__.py +1 -0
- nk_python_style-0.1.0/nk_python/__main__.py +3 -0
- nk_python_style-0.1.0/nk_python/ast_checks.py +79 -0
- nk_python_style-0.1.0/nk_python/cli.py +100 -0
- nk_python_style-0.1.0/nk_python/comment_checks.py +82 -0
- nk_python_style-0.1.0/nk_python/model.py +14 -0
- nk_python_style-0.1.0/nk_python/ruff.toml +13 -0
- nk_python_style-0.1.0/nk_python/ruff_runner.py +34 -0
- nk_python_style-0.1.0/nk_python/structure_checks.py +53 -0
- nk_python_style-0.1.0/nk_python_style.egg-info/PKG-INFO +60 -0
- nk_python_style-0.1.0/nk_python_style.egg-info/SOURCES.txt +20 -0
- nk_python_style-0.1.0/nk_python_style.egg-info/dependency_links.txt +1 -0
- nk_python_style-0.1.0/nk_python_style.egg-info/entry_points.txt +2 -0
- nk_python_style-0.1.0/nk_python_style.egg-info/requires.txt +4 -0
- nk_python_style-0.1.0/nk_python_style.egg-info/top_level.txt +1 -0
- nk_python_style-0.1.0/pyproject.toml +20 -0
- nk_python_style-0.1.0/setup.cfg +4 -0
- nk_python_style-0.1.0/tests/test_cli.py +103 -0
- nk_python_style-0.1.0/tests/test_rules.py +92 -0
- nk_python_style-0.1.0/tests/test_summary.py +47 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nk-python-style
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python formatter and linter following the repository's conventions
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: ruff<1,>=0.16
|
|
8
|
+
Provides-Extra: comments
|
|
9
|
+
Requires-Dist: lingua-language-detector<3,>=2.2; extra == "comments"
|
|
10
|
+
|
|
11
|
+
# Python : linter et formatter
|
|
12
|
+
|
|
13
|
+
Cette version garde les limites générales du projet C++ et confie les règles Python courantes à Ruff. Elle utilise l'AST et `tokenize` pour distinguer code, chaînes, docstrings et commentaires.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Depuis ce dépôt :
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install -e ./python
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Pour activer la détection de la langue des commentaires, installer l'extra optionnel (Lingua est volumineux) :
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m pip install -e "./python[comments]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Depuis un autre projet, une installation non éditable fonctionne aussi : `python -m pip install <chemin-vers-ce-dépôt>/python`. La commande `nk-python` est ensuite disponible dans le même environnement Python. On peut également utiliser `python -m nk_python`.
|
|
30
|
+
|
|
31
|
+
## Utilisation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
nk-python check Src Tests # Ruff et règles propres, sans écriture
|
|
35
|
+
nk-python format Src Tests # trie les imports et formate en place
|
|
36
|
+
nk-python check fichier.py # fichier précis
|
|
37
|
+
nk-python check . --max-files 10 # seuil de dossier personnalisé
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Sans chemin, la commande traite le dossier courant. Les sous-dossiers de cache, d'environnement virtuel et de construction sont ignorés. Cela inclut tous les dossiers dont le nom commence par `.venv`, comme `.venv-cardd` et `.venv-vision`. `check` contrôle **aussi** le formatage Ruff et retourne un code non nul si une règle échoue. `format` applique le tri des imports Ruff et `ruff format`; relancer `check` pour les diagnostics qui ne se corrigent pas automatiquement.
|
|
41
|
+
|
|
42
|
+
Les diagnostics s'affichent sans extraits de code, avec des chemins relatifs au dossier courant.
|
|
43
|
+
La sortie de `check` commence par un résumé : fichiers analysés, nombre de warnings du linter (Ruff et règles `PY…`), problèmes d'ordre des imports et fichiers à reformater. Elle présente ensuite les diagnostics du linter, puis une section formatter à la fin : ordre des imports et fichiers à reformater. Cette dernière correspond aux corrections appliquées par `format`. Les autres corrections automatiques proposées par Ruff ne sont pas appliquées par cette commande.
|
|
44
|
+
|
|
45
|
+
## Règles
|
|
46
|
+
|
|
47
|
+
| Code | Contrôle |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `PY001` | Ligne de 120 caractères maximum, commentaires compris. |
|
|
50
|
+
| `PY002` | Fichier de 120 lignes maximum. |
|
|
51
|
+
| `PY003` | Fonction ou méthode de 40 lignes maximum, signature comprise. |
|
|
52
|
+
| `PY004` | Cinq paramètres maximum ; `self` ou `cls` est exclu pour les méthodes. |
|
|
53
|
+
| `PY005` | Commentaire de fin de ligne interdit, sauf directive d'outil. |
|
|
54
|
+
| `PY006` | Commentaire dans une fonction interdit, sauf directive d'outil ; utiliser une docstring ou clarifier le code. |
|
|
55
|
+
| `PY007` | Commentaire qui semble non anglais ; actif si l'extra `comments` est installé. Les textes trop courts ou ambigus sont ignorés. |
|
|
56
|
+
| `PY008` | Huit fichiers `.py`/`.pyi` directs maximum dans un dossier parcouru. |
|
|
57
|
+
|
|
58
|
+
Ruff vérifie en plus les erreurs Python (`E4`, `E7`, `E9`, `F`), l'ordre des imports (`I`), les erreurs fréquentes (`B`), certaines modernisations sûres (`UP`) et la variable créée uniquement pour être retournée (`RET504`). Sa configuration est fournie par l'outil, donc les mêmes règles s'appliquent depuis un autre projet. Le formatter impose une indentation de quatre espaces, des guillemets doubles et des fins de ligne LF. Les règles C++ propres aux accolades, `#include`, modules et initialiseurs désignés ne s'appliquent pas à Python.
|
|
59
|
+
|
|
60
|
+
Les directives usuelles (`# noqa`, `# type: ignore`, `# pylint:`, `# fmt:`, `# ruff:`, `# isort:` et `# pragma: no cover`) restent permises. Les docstrings ne sont pas des commentaires `#` et restent autorisées dans les fonctions.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Python : linter et formatter
|
|
2
|
+
|
|
3
|
+
Cette version garde les limites générales du projet C++ et confie les règles Python courantes à Ruff. Elle utilise l'AST et `tokenize` pour distinguer code, chaînes, docstrings et commentaires.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Depuis ce dépôt :
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m pip install -e ./python
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Pour activer la détection de la langue des commentaires, installer l'extra optionnel (Lingua est volumineux) :
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m pip install -e "./python[comments]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Depuis un autre projet, une installation non éditable fonctionne aussi : `python -m pip install <chemin-vers-ce-dépôt>/python`. La commande `nk-python` est ensuite disponible dans le même environnement Python. On peut également utiliser `python -m nk_python`.
|
|
20
|
+
|
|
21
|
+
## Utilisation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
nk-python check Src Tests # Ruff et règles propres, sans écriture
|
|
25
|
+
nk-python format Src Tests # trie les imports et formate en place
|
|
26
|
+
nk-python check fichier.py # fichier précis
|
|
27
|
+
nk-python check . --max-files 10 # seuil de dossier personnalisé
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Sans chemin, la commande traite le dossier courant. Les sous-dossiers de cache, d'environnement virtuel et de construction sont ignorés. Cela inclut tous les dossiers dont le nom commence par `.venv`, comme `.venv-cardd` et `.venv-vision`. `check` contrôle **aussi** le formatage Ruff et retourne un code non nul si une règle échoue. `format` applique le tri des imports Ruff et `ruff format`; relancer `check` pour les diagnostics qui ne se corrigent pas automatiquement.
|
|
31
|
+
|
|
32
|
+
Les diagnostics s'affichent sans extraits de code, avec des chemins relatifs au dossier courant.
|
|
33
|
+
La sortie de `check` commence par un résumé : fichiers analysés, nombre de warnings du linter (Ruff et règles `PY…`), problèmes d'ordre des imports et fichiers à reformater. Elle présente ensuite les diagnostics du linter, puis une section formatter à la fin : ordre des imports et fichiers à reformater. Cette dernière correspond aux corrections appliquées par `format`. Les autres corrections automatiques proposées par Ruff ne sont pas appliquées par cette commande.
|
|
34
|
+
|
|
35
|
+
## Règles
|
|
36
|
+
|
|
37
|
+
| Code | Contrôle |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `PY001` | Ligne de 120 caractères maximum, commentaires compris. |
|
|
40
|
+
| `PY002` | Fichier de 120 lignes maximum. |
|
|
41
|
+
| `PY003` | Fonction ou méthode de 40 lignes maximum, signature comprise. |
|
|
42
|
+
| `PY004` | Cinq paramètres maximum ; `self` ou `cls` est exclu pour les méthodes. |
|
|
43
|
+
| `PY005` | Commentaire de fin de ligne interdit, sauf directive d'outil. |
|
|
44
|
+
| `PY006` | Commentaire dans une fonction interdit, sauf directive d'outil ; utiliser une docstring ou clarifier le code. |
|
|
45
|
+
| `PY007` | Commentaire qui semble non anglais ; actif si l'extra `comments` est installé. Les textes trop courts ou ambigus sont ignorés. |
|
|
46
|
+
| `PY008` | Huit fichiers `.py`/`.pyi` directs maximum dans un dossier parcouru. |
|
|
47
|
+
|
|
48
|
+
Ruff vérifie en plus les erreurs Python (`E4`, `E7`, `E9`, `F`), l'ordre des imports (`I`), les erreurs fréquentes (`B`), certaines modernisations sûres (`UP`) et la variable créée uniquement pour être retournée (`RET504`). Sa configuration est fournie par l'outil, donc les mêmes règles s'appliquent depuis un autre projet. Le formatter impose une indentation de quatre espaces, des guillemets doubles et des fins de ligne LF. Les règles C++ propres aux accolades, `#include`, modules et initialiseurs désignés ne s'appliquent pas à Python.
|
|
49
|
+
|
|
50
|
+
Les directives usuelles (`# noqa`, `# type: ignore`, `# pylint:`, `# fmt:`, `# ruff:`, `# isort:` et `# pragma: no cover`) restent permises. Les docstrings ne sont pas des commentaires `#` et restent autorisées dans les fonctions.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python style checks inspired by the C++ tooling in this repository."""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Checks that need Python syntax rather than regular expressions."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .model import Issue
|
|
7
|
+
|
|
8
|
+
MAX_LINE_LENGTH = 120
|
|
9
|
+
MAX_FILE_LINES = 120
|
|
10
|
+
MAX_FUNCTION_LINES = 40
|
|
11
|
+
MAX_PARAMETERS = 5
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def function_ranges(tree: ast.AST, source: str) -> list[tuple[int, int]]:
|
|
15
|
+
lines = source.splitlines()
|
|
16
|
+
ranges = []
|
|
17
|
+
for node in ast.walk(tree):
|
|
18
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
19
|
+
continue
|
|
20
|
+
end = node.end_lineno or node.lineno
|
|
21
|
+
for index in range(end, len(lines)):
|
|
22
|
+
stripped = lines[index].lstrip(" \t")
|
|
23
|
+
if not stripped:
|
|
24
|
+
continue
|
|
25
|
+
indentation = len(lines[index]) - len(stripped)
|
|
26
|
+
if stripped.startswith("#") and indentation > node.col_offset:
|
|
27
|
+
end = index + 1
|
|
28
|
+
continue
|
|
29
|
+
break
|
|
30
|
+
ranges.append((node.lineno, end))
|
|
31
|
+
return ranges
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_source(path: Path, source: str) -> tuple[list[Issue], list[tuple[int, int]]]:
|
|
35
|
+
issues: list[Issue] = []
|
|
36
|
+
lines = source.splitlines()
|
|
37
|
+
for number, line in enumerate(lines, 1):
|
|
38
|
+
if len(line) > MAX_LINE_LENGTH:
|
|
39
|
+
issues.append(Issue(path, number, "PY001", f"Line has {len(line)} characters (max {MAX_LINE_LENGTH})."))
|
|
40
|
+
if len(lines) > MAX_FILE_LINES:
|
|
41
|
+
issues.append(Issue(path, MAX_FILE_LINES + 1, "PY002", f"File has {len(lines)} lines (max {MAX_FILE_LINES})."))
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
tree = ast.parse(source, filename=str(path))
|
|
45
|
+
except SyntaxError as error:
|
|
46
|
+
issues.append(Issue(path, error.lineno or 1, "PY000", f"Invalid Python syntax: {error.msg}."))
|
|
47
|
+
return issues, []
|
|
48
|
+
|
|
49
|
+
parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)}
|
|
50
|
+
for node in ast.walk(tree):
|
|
51
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
52
|
+
continue
|
|
53
|
+
length = (node.end_lineno or node.lineno) - node.lineno + 1
|
|
54
|
+
if length > MAX_FUNCTION_LINES:
|
|
55
|
+
issues.append(
|
|
56
|
+
Issue(
|
|
57
|
+
path, node.lineno, "PY003", f"Function '{node.name}' has {length} lines (max {MAX_FUNCTION_LINES})."
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
count = _parameter_count(node, parents.get(node))
|
|
61
|
+
if count > MAX_PARAMETERS:
|
|
62
|
+
issues.append(
|
|
63
|
+
Issue(
|
|
64
|
+
path, node.lineno, "PY004", f"Function '{node.name}' has {count} parameters (max {MAX_PARAMETERS})."
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return issues, function_ranges(tree, source)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parameter_count(node: ast.FunctionDef | ast.AsyncFunctionDef, parent: ast.AST | None) -> int:
|
|
71
|
+
positional = node.args.posonlyargs + node.args.args
|
|
72
|
+
count = len(positional) + len(node.args.kwonlyargs)
|
|
73
|
+
count += int(node.args.vararg is not None) + int(node.args.kwarg is not None)
|
|
74
|
+
is_static = any(
|
|
75
|
+
isinstance(decorator, ast.Name) and decorator.id == "staticmethod" for decorator in node.decorator_list
|
|
76
|
+
)
|
|
77
|
+
if isinstance(parent, ast.ClassDef) and not is_static and positional and positional[0].arg in {"self", "cls"}:
|
|
78
|
+
count -= 1
|
|
79
|
+
return count
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Command line entry point for formatting and checking Python projects."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .ast_checks import check_source
|
|
10
|
+
from .comment_checks import LANGUAGE_AVAILABLE, check_comments
|
|
11
|
+
from .model import Issue
|
|
12
|
+
from .ruff_runner import run_ruff
|
|
13
|
+
from .structure_checks import discover
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(prog="nk-python")
|
|
18
|
+
parser.add_argument("command", choices=("check", "format"), help="Check without writing, or format in place.")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"paths", nargs="*", default=["."], help="Python files or directories (default: current directory)."
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--max-files", type=_positive_int, default=8, help="Maximum direct Python files per directory.")
|
|
23
|
+
arguments = parser.parse_args(argv)
|
|
24
|
+
|
|
25
|
+
if importlib.util.find_spec("ruff") is None:
|
|
26
|
+
print("Ruff is required. Install this tool with: pip install -e ./python", file=sys.stderr)
|
|
27
|
+
return 2
|
|
28
|
+
try:
|
|
29
|
+
files, structure_issues = discover([Path(value) for value in arguments.paths], arguments.max_files)
|
|
30
|
+
except (FileNotFoundError, ValueError) as error:
|
|
31
|
+
print(error, file=sys.stderr)
|
|
32
|
+
return 2
|
|
33
|
+
if not files:
|
|
34
|
+
print("No Python files found.", file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
if arguments.command == "format":
|
|
38
|
+
import_status = run_ruff("check", files, "--select", "I", "--fix")
|
|
39
|
+
format_status = run_ruff("format", files)
|
|
40
|
+
return max(import_status, format_status)
|
|
41
|
+
|
|
42
|
+
return _check(files, structure_issues)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check(files: list[Path], structure_issues: list[Issue]) -> int:
|
|
46
|
+
lint_output: list[str] = []
|
|
47
|
+
import_output: list[str] = []
|
|
48
|
+
format_output: list[str] = []
|
|
49
|
+
lint_status = run_ruff("check", files, "--ignore", "I", "--unfixable", "ALL", "--quiet", output=lint_output)
|
|
50
|
+
issues = structure_issues + _check_files(files)
|
|
51
|
+
import_status = run_ruff("check", files, "--select", "I", "--quiet", output=import_output)
|
|
52
|
+
format_status = run_ruff("format", files, "--check", output=format_output)
|
|
53
|
+
lint_text, import_text, format_text = ("".join(output) for output in (lint_output, import_output, format_output))
|
|
54
|
+
diagnostic_pattern = r"^.+:\d+:\d+: "
|
|
55
|
+
lint_count = len(re.findall(diagnostic_pattern, lint_text, re.MULTILINE)) + len(issues)
|
|
56
|
+
import_count = len(re.findall(diagnostic_pattern, import_text, re.MULTILINE))
|
|
57
|
+
format_count = sum(
|
|
58
|
+
int(count) for count in re.findall(r"^(\d+) files? would be reformatted", format_text, re.MULTILINE)
|
|
59
|
+
)
|
|
60
|
+
print("=== Check summary ===")
|
|
61
|
+
print(f"Files checked: {len(files)}")
|
|
62
|
+
print(f"Linter: {lint_count} warnings")
|
|
63
|
+
print(f"Formatter: {import_count} import diagnostics; {format_count} files to reformat")
|
|
64
|
+
if 2 in (lint_status, import_status, format_status):
|
|
65
|
+
print("Ruff could not complete the check; counts may be incomplete.")
|
|
66
|
+
elif not (lint_status or import_status or format_status or issues):
|
|
67
|
+
print("All checks passed!")
|
|
68
|
+
|
|
69
|
+
print("\n=== Linter diagnostics ===")
|
|
70
|
+
print(lint_text, end="")
|
|
71
|
+
for issue in sorted(issues):
|
|
72
|
+
print(issue)
|
|
73
|
+
if not LANGUAGE_AVAILABLE:
|
|
74
|
+
print("Comment language check skipped (install nk-python-style[comments] to enable it).")
|
|
75
|
+
|
|
76
|
+
print("\n=== Formatter: corrections available via 'format' ===")
|
|
77
|
+
print(import_text, end="")
|
|
78
|
+
print(format_text, end="")
|
|
79
|
+
return int(bool(lint_status or import_status or format_status or issues))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _positive_int(value: str) -> int:
|
|
83
|
+
result = int(value)
|
|
84
|
+
if result < 1:
|
|
85
|
+
raise argparse.ArgumentTypeError("--max-files must be positive")
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_files(files: list[Path]) -> list[Issue]:
|
|
90
|
+
issues: list[Issue] = []
|
|
91
|
+
for path in files:
|
|
92
|
+
try:
|
|
93
|
+
source = path.read_text(encoding="utf-8-sig")
|
|
94
|
+
except (OSError, UnicodeError) as error:
|
|
95
|
+
issues.append(Issue(path, 1, "PY000", f"Cannot read UTF-8 source: {error}."))
|
|
96
|
+
continue
|
|
97
|
+
source_issues, functions = check_source(path, source)
|
|
98
|
+
issues.extend(source_issues)
|
|
99
|
+
issues.extend(check_comments(path, source, functions))
|
|
100
|
+
return issues
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Token-based comment placement and optional language checks."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import re
|
|
5
|
+
import tokenize
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .model import Issue
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from lingua import Language, LanguageDetectorBuilder
|
|
12
|
+
except ImportError:
|
|
13
|
+
Language = None
|
|
14
|
+
LanguageDetectorBuilder = None
|
|
15
|
+
|
|
16
|
+
LANGUAGE_AVAILABLE = LanguageDetectorBuilder is not None
|
|
17
|
+
_detector = None
|
|
18
|
+
_DIRECTIVE = re.compile(
|
|
19
|
+
r"^(?:!|coding[:=]|noqa\b|type:\s*ignore\b|pyright:\s*ignore\b|"
|
|
20
|
+
r"pylint:|fmt:|ruff:|isort:|pragma:\s*no cover\b)",
|
|
21
|
+
re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
_WORD = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ]+")
|
|
24
|
+
_META_WORD = re.compile(r"^(?:TODO|FIXME|XXX|HACK|NOTE|WIP)$", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_comments(path: Path, source: str, functions: list[tuple[int, int]]) -> list[Issue]:
|
|
28
|
+
issues: list[Issue] = []
|
|
29
|
+
source_lines = source.splitlines()
|
|
30
|
+
try:
|
|
31
|
+
tokens = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
32
|
+
for token in tokens:
|
|
33
|
+
if token.type != tokenize.COMMENT:
|
|
34
|
+
continue
|
|
35
|
+
line_number, column = token.start
|
|
36
|
+
comment = token.string[1:].strip()
|
|
37
|
+
if _DIRECTIVE.match(comment):
|
|
38
|
+
continue
|
|
39
|
+
inside = any(start <= line_number <= end for start, end in functions)
|
|
40
|
+
if inside:
|
|
41
|
+
issues.append(
|
|
42
|
+
Issue(path, line_number, "PY006", "Use a docstring or clear code instead of a function comment.")
|
|
43
|
+
)
|
|
44
|
+
elif source_lines[line_number - 1][:column].strip():
|
|
45
|
+
issues.append(Issue(path, line_number, "PY005", "Place the comment on its own line."))
|
|
46
|
+
language = _comment_language(comment)
|
|
47
|
+
if language:
|
|
48
|
+
issues.append(Issue(path, line_number, "PY007", f"Comment appears to be in {language}; use English."))
|
|
49
|
+
except (tokenize.TokenError, IndentationError):
|
|
50
|
+
pass
|
|
51
|
+
return issues
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _comment_language(comment: str) -> str | None:
|
|
55
|
+
if not LANGUAGE_AVAILABLE:
|
|
56
|
+
return None
|
|
57
|
+
words = [word for word in _WORD.findall(comment) if _natural_word(word)]
|
|
58
|
+
if len(words) < 3 or len(" ".join(words)) < 20:
|
|
59
|
+
return None
|
|
60
|
+
confidences = _get_detector().compute_language_confidence_values(" ".join(words))
|
|
61
|
+
if not confidences or confidences[0].language == Language.ENGLISH:
|
|
62
|
+
return None
|
|
63
|
+
english = next((item.value for item in confidences if item.language == Language.ENGLISH), 0.0)
|
|
64
|
+
margin = 0.35 if len(words) <= 5 else 0.15
|
|
65
|
+
if confidences[0].value - english < margin:
|
|
66
|
+
return None
|
|
67
|
+
return confidences[0].language.name.title()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _natural_word(word: str) -> bool:
|
|
71
|
+
if _META_WORD.fullmatch(word) or word.isupper():
|
|
72
|
+
return False
|
|
73
|
+
return not any(left.islower() and right.isupper() for left, right in zip(word, word[1:], strict=False))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_detector():
|
|
77
|
+
global _detector
|
|
78
|
+
if _detector is None:
|
|
79
|
+
_detector = LanguageDetectorBuilder.from_languages(
|
|
80
|
+
Language.ENGLISH, Language.FRENCH, Language.SPANISH, Language.GERMAN
|
|
81
|
+
).build()
|
|
82
|
+
return _detector
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, order=True)
|
|
7
|
+
class Issue:
|
|
8
|
+
path: Path
|
|
9
|
+
line: int
|
|
10
|
+
code: str
|
|
11
|
+
message: str
|
|
12
|
+
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
return f"{os.path.relpath(self.path)}:{self.line}: {self.code} {self.message}"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
line-length = 120
|
|
2
|
+
indent-width = 4
|
|
3
|
+
target-version = "py310"
|
|
4
|
+
output-format = "concise"
|
|
5
|
+
extend-exclude = [".git", ".venv*", "venv", "__pycache__", "build", "dist", "node_modules"]
|
|
6
|
+
|
|
7
|
+
[lint]
|
|
8
|
+
select = ["E4", "E7", "E9", "F", "I", "B", "UP", "RET504"]
|
|
9
|
+
|
|
10
|
+
[format]
|
|
11
|
+
indent-style = "space"
|
|
12
|
+
quote-style = "double"
|
|
13
|
+
line-ending = "lf"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Run Ruff in batches, optionally collecting its output for a check summary."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
RUFF_CONFIG = Path(__file__).with_name("ruff.toml")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_ruff(command: str, files: list[Path], *options: str, output: list[str] | None = None) -> int:
|
|
12
|
+
status = 0
|
|
13
|
+
for offset in range(0, len(files), 50):
|
|
14
|
+
batch = files[offset : offset + 50]
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
[
|
|
17
|
+
sys.executable,
|
|
18
|
+
"-m",
|
|
19
|
+
"ruff",
|
|
20
|
+
command,
|
|
21
|
+
"--config",
|
|
22
|
+
str(RUFF_CONFIG),
|
|
23
|
+
*options,
|
|
24
|
+
*(os.path.relpath(path) for path in batch),
|
|
25
|
+
],
|
|
26
|
+
stdout=subprocess.PIPE if output is not None else None,
|
|
27
|
+
stderr=subprocess.STDOUT if output is not None else None,
|
|
28
|
+
text=True,
|
|
29
|
+
check=False,
|
|
30
|
+
)
|
|
31
|
+
if output is not None:
|
|
32
|
+
output.append(result.stdout)
|
|
33
|
+
status = max(status, result.returncode)
|
|
34
|
+
return status
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Collect Python files and check direct file counts in each directory."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from fnmatch import fnmatchcase
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .model import Issue
|
|
8
|
+
|
|
9
|
+
IGNORED_DIRECTORIES = {
|
|
10
|
+
".git",
|
|
11
|
+
".hg",
|
|
12
|
+
".mypy_cache",
|
|
13
|
+
".pytest_cache",
|
|
14
|
+
".ruff_cache",
|
|
15
|
+
".tox",
|
|
16
|
+
".venv*",
|
|
17
|
+
"venv",
|
|
18
|
+
"__pycache__",
|
|
19
|
+
"build",
|
|
20
|
+
"dist",
|
|
21
|
+
"node_modules",
|
|
22
|
+
}
|
|
23
|
+
SUFFIXES = {".py", ".pyi"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def discover(paths: list[Path], max_files: int = 8) -> tuple[list[Path], list[Issue]]:
|
|
27
|
+
files: set[Path] = set()
|
|
28
|
+
issues: list[Issue] = []
|
|
29
|
+
seen_directories: set[Path] = set()
|
|
30
|
+
for target in paths:
|
|
31
|
+
target = target.resolve()
|
|
32
|
+
if not target.exists():
|
|
33
|
+
raise FileNotFoundError(target)
|
|
34
|
+
if target.is_file():
|
|
35
|
+
if target.suffix not in SUFFIXES:
|
|
36
|
+
raise ValueError(f"Unsupported source file: {target}")
|
|
37
|
+
files.add(target)
|
|
38
|
+
continue
|
|
39
|
+
for root, directory_names, file_names in os.walk(target):
|
|
40
|
+
directory_names[:] = sorted(
|
|
41
|
+
name
|
|
42
|
+
for name in directory_names
|
|
43
|
+
if not any(fnmatchcase(name, pattern) for pattern in IGNORED_DIRECTORIES)
|
|
44
|
+
)
|
|
45
|
+
directory = Path(root).resolve()
|
|
46
|
+
source_names = sorted(name for name in file_names if Path(name).suffix in SUFFIXES)
|
|
47
|
+
files.update(directory / name for name in source_names)
|
|
48
|
+
if directory not in seen_directories and len(source_names) > max_files:
|
|
49
|
+
issues.append(
|
|
50
|
+
Issue(directory, 1, "PY008", f"Directory has {len(source_names)} Python files (max {max_files}).")
|
|
51
|
+
)
|
|
52
|
+
seen_directories.add(directory)
|
|
53
|
+
return sorted(files), sorted(issues)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nk-python-style
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python formatter and linter following the repository's conventions
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: ruff<1,>=0.16
|
|
8
|
+
Provides-Extra: comments
|
|
9
|
+
Requires-Dist: lingua-language-detector<3,>=2.2; extra == "comments"
|
|
10
|
+
|
|
11
|
+
# Python : linter et formatter
|
|
12
|
+
|
|
13
|
+
Cette version garde les limites générales du projet C++ et confie les règles Python courantes à Ruff. Elle utilise l'AST et `tokenize` pour distinguer code, chaînes, docstrings et commentaires.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Depuis ce dépôt :
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install -e ./python
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Pour activer la détection de la langue des commentaires, installer l'extra optionnel (Lingua est volumineux) :
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m pip install -e "./python[comments]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Depuis un autre projet, une installation non éditable fonctionne aussi : `python -m pip install <chemin-vers-ce-dépôt>/python`. La commande `nk-python` est ensuite disponible dans le même environnement Python. On peut également utiliser `python -m nk_python`.
|
|
30
|
+
|
|
31
|
+
## Utilisation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
nk-python check Src Tests # Ruff et règles propres, sans écriture
|
|
35
|
+
nk-python format Src Tests # trie les imports et formate en place
|
|
36
|
+
nk-python check fichier.py # fichier précis
|
|
37
|
+
nk-python check . --max-files 10 # seuil de dossier personnalisé
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Sans chemin, la commande traite le dossier courant. Les sous-dossiers de cache, d'environnement virtuel et de construction sont ignorés. Cela inclut tous les dossiers dont le nom commence par `.venv`, comme `.venv-cardd` et `.venv-vision`. `check` contrôle **aussi** le formatage Ruff et retourne un code non nul si une règle échoue. `format` applique le tri des imports Ruff et `ruff format`; relancer `check` pour les diagnostics qui ne se corrigent pas automatiquement.
|
|
41
|
+
|
|
42
|
+
Les diagnostics s'affichent sans extraits de code, avec des chemins relatifs au dossier courant.
|
|
43
|
+
La sortie de `check` commence par un résumé : fichiers analysés, nombre de warnings du linter (Ruff et règles `PY…`), problèmes d'ordre des imports et fichiers à reformater. Elle présente ensuite les diagnostics du linter, puis une section formatter à la fin : ordre des imports et fichiers à reformater. Cette dernière correspond aux corrections appliquées par `format`. Les autres corrections automatiques proposées par Ruff ne sont pas appliquées par cette commande.
|
|
44
|
+
|
|
45
|
+
## Règles
|
|
46
|
+
|
|
47
|
+
| Code | Contrôle |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `PY001` | Ligne de 120 caractères maximum, commentaires compris. |
|
|
50
|
+
| `PY002` | Fichier de 120 lignes maximum. |
|
|
51
|
+
| `PY003` | Fonction ou méthode de 40 lignes maximum, signature comprise. |
|
|
52
|
+
| `PY004` | Cinq paramètres maximum ; `self` ou `cls` est exclu pour les méthodes. |
|
|
53
|
+
| `PY005` | Commentaire de fin de ligne interdit, sauf directive d'outil. |
|
|
54
|
+
| `PY006` | Commentaire dans une fonction interdit, sauf directive d'outil ; utiliser une docstring ou clarifier le code. |
|
|
55
|
+
| `PY007` | Commentaire qui semble non anglais ; actif si l'extra `comments` est installé. Les textes trop courts ou ambigus sont ignorés. |
|
|
56
|
+
| `PY008` | Huit fichiers `.py`/`.pyi` directs maximum dans un dossier parcouru. |
|
|
57
|
+
|
|
58
|
+
Ruff vérifie en plus les erreurs Python (`E4`, `E7`, `E9`, `F`), l'ordre des imports (`I`), les erreurs fréquentes (`B`), certaines modernisations sûres (`UP`) et la variable créée uniquement pour être retournée (`RET504`). Sa configuration est fournie par l'outil, donc les mêmes règles s'appliquent depuis un autre projet. Le formatter impose une indentation de quatre espaces, des guillemets doubles et des fins de ligne LF. Les règles C++ propres aux accolades, `#include`, modules et initialiseurs désignés ne s'appliquent pas à Python.
|
|
59
|
+
|
|
60
|
+
Les directives usuelles (`# noqa`, `# type: ignore`, `# pylint:`, `# fmt:`, `# ruff:`, `# isort:` et `# pragma: no cover`) restent permises. Les docstrings ne sont pas des commentaires `#` et restent autorisées dans les fonctions.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
nk_python/__init__.py
|
|
4
|
+
nk_python/__main__.py
|
|
5
|
+
nk_python/ast_checks.py
|
|
6
|
+
nk_python/cli.py
|
|
7
|
+
nk_python/comment_checks.py
|
|
8
|
+
nk_python/model.py
|
|
9
|
+
nk_python/ruff.toml
|
|
10
|
+
nk_python/ruff_runner.py
|
|
11
|
+
nk_python/structure_checks.py
|
|
12
|
+
nk_python_style.egg-info/PKG-INFO
|
|
13
|
+
nk_python_style.egg-info/SOURCES.txt
|
|
14
|
+
nk_python_style.egg-info/dependency_links.txt
|
|
15
|
+
nk_python_style.egg-info/entry_points.txt
|
|
16
|
+
nk_python_style.egg-info/requires.txt
|
|
17
|
+
nk_python_style.egg-info/top_level.txt
|
|
18
|
+
tests/test_cli.py
|
|
19
|
+
tests/test_rules.py
|
|
20
|
+
tests/test_summary.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nk_python
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nk-python-style"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python formatter and linter following the repository's conventions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["ruff>=0.16,<1"]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
comments = ["lingua-language-detector>=2.2,<3"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
nk-python = "nk_python.cli:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.package-data]
|
|
20
|
+
nk_python = ["ruff.toml"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CliTests(unittest.TestCase):
|
|
9
|
+
def test_check_separates_linter_diagnostics_and_formatter_corrections(self):
|
|
10
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
11
|
+
path = Path(temp) / "sample.py"
|
|
12
|
+
source = "import sys\nimport os\n\nvalue=1 # inline comment\n"
|
|
13
|
+
path.write_text(source, encoding="utf-8")
|
|
14
|
+
command = [sys.executable, "-m", "nk_python"]
|
|
15
|
+
checked = subprocess.run(command + ["check", str(path)], capture_output=True, text=True, check=False)
|
|
16
|
+
output = checked.stdout
|
|
17
|
+
self.assertEqual(checked.returncode, 1, checked.stderr + output)
|
|
18
|
+
linter, formatter = output.split("=== Formatter: corrections available via 'format' ===")
|
|
19
|
+
self.assertTrue(linter.startswith("=== Check summary ==="))
|
|
20
|
+
self.assertIn(
|
|
21
|
+
"Files checked: 1\nLinter: 3 warnings\nFormatter: 1 import diagnostics; 1 files to reformat", linter
|
|
22
|
+
)
|
|
23
|
+
self.assertIn("=== Linter diagnostics ===", linter)
|
|
24
|
+
self.assertIn("F401", linter)
|
|
25
|
+
self.assertIn("PY005", linter)
|
|
26
|
+
self.assertNotIn("[*]", linter)
|
|
27
|
+
self.assertNotIn("I001", linter)
|
|
28
|
+
self.assertNotIn("would be reformatted", linter)
|
|
29
|
+
self.assertIn("I001", formatter)
|
|
30
|
+
self.assertIn("would be reformatted", formatter)
|
|
31
|
+
self.assertNotIn("F401", formatter)
|
|
32
|
+
self.assertNotIn("PY005", formatter)
|
|
33
|
+
self.assertEqual(path.read_text(encoding="utf-8"), source)
|
|
34
|
+
formatted = subprocess.run(command + ["format", str(path)], capture_output=True, text=True, check=False)
|
|
35
|
+
self.assertEqual(formatted.returncode, 0, formatted.stderr + formatted.stdout)
|
|
36
|
+
checked = subprocess.run(command + ["check", str(path)], capture_output=True, text=True, check=False)
|
|
37
|
+
self.assertEqual(checked.returncode, 1, checked.stderr + checked.stdout)
|
|
38
|
+
self.assertIn("F401", checked.stdout)
|
|
39
|
+
self.assertIn("PY005", checked.stdout)
|
|
40
|
+
self.assertNotIn("I001", checked.stdout)
|
|
41
|
+
self.assertNotIn("would be reformatted", checked.stdout)
|
|
42
|
+
|
|
43
|
+
def test_check_reports_relative_paths_without_source_excerpts(self):
|
|
44
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
45
|
+
root = Path(temp)
|
|
46
|
+
folder = root / "src"
|
|
47
|
+
folder.mkdir()
|
|
48
|
+
path = folder / "sample.py"
|
|
49
|
+
source = "import os\n\nvalue=1 # inline comment\n"
|
|
50
|
+
path.write_text(source, encoding="utf-8")
|
|
51
|
+
check = subprocess.run(
|
|
52
|
+
[sys.executable, "-m", "nk_python", "check", str(folder)],
|
|
53
|
+
cwd=root,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
output = check.stdout + check.stderr
|
|
59
|
+
self.assertEqual(check.returncode, 1, output)
|
|
60
|
+
self.assertIn(f"{Path('src') / 'sample.py'}:1:8: F401", output)
|
|
61
|
+
self.assertIn(f"{Path('src') / 'sample.py'}:3: PY005", output)
|
|
62
|
+
self.assertIn("sample.py", output)
|
|
63
|
+
self.assertNotIn(str(root), output)
|
|
64
|
+
self.assertNotIn("import os", output)
|
|
65
|
+
self.assertNotIn("inline comment", output)
|
|
66
|
+
self.assertEqual(path.read_text(encoding="utf-8"), source)
|
|
67
|
+
|
|
68
|
+
def test_check_is_read_only_and_format_fixes_layout(self):
|
|
69
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
70
|
+
path = Path(temp) / "sample.py"
|
|
71
|
+
path.write_text("value=1\n", encoding="utf-8")
|
|
72
|
+
before = path.read_bytes()
|
|
73
|
+
check = subprocess.run(
|
|
74
|
+
[sys.executable, "-m", "nk_python", "check", str(path)],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
self.assertEqual(check.returncode, 1)
|
|
80
|
+
self.assertNotIn("All checks passed!", check.stdout)
|
|
81
|
+
self.assertEqual(path.read_bytes(), before)
|
|
82
|
+
formatted = subprocess.run(
|
|
83
|
+
[sys.executable, "-m", "nk_python", "format", str(path)],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
check=False,
|
|
87
|
+
)
|
|
88
|
+
self.assertEqual(formatted.returncode, 0, formatted.stderr)
|
|
89
|
+
self.assertEqual(path.read_text(encoding="utf-8"), "value = 1\n")
|
|
90
|
+
checked = subprocess.run(
|
|
91
|
+
[sys.executable, "-m", "nk_python", "check", str(path)],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
check=False,
|
|
95
|
+
)
|
|
96
|
+
self.assertEqual(checked.returncode, 0, checked.stderr + checked.stdout)
|
|
97
|
+
self.assertEqual(checked.stdout.count("All checks passed!"), 1)
|
|
98
|
+
self.assertTrue(checked.stdout.startswith("=== Check summary ==="))
|
|
99
|
+
self.assertIn("Linter: 0 warnings\nFormatter: 0 import diagnostics; 0 files to reformat", checked.stdout)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
unittest.main()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from nk_python.ast_checks import check_source
|
|
6
|
+
from nk_python.comment_checks import LANGUAGE_AVAILABLE, check_comments
|
|
7
|
+
from nk_python.structure_checks import discover
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def codes(source: str) -> set[str]:
|
|
11
|
+
path = Path("sample.py")
|
|
12
|
+
issues, functions = check_source(path, source)
|
|
13
|
+
issues.extend(check_comments(path, source, functions))
|
|
14
|
+
return {issue.code for issue in issues}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RuleTests(unittest.TestCase):
|
|
18
|
+
def test_function_and_parameter_limits_exclude_method_receiver(self):
|
|
19
|
+
source = "class Box:\n def read(self, a, b, c, d, e):\n return a\n"
|
|
20
|
+
self.assertNotIn("PY004", codes(source))
|
|
21
|
+
source += "\n async def write(self, a, b, c, d, e, f):\n return a\n"
|
|
22
|
+
self.assertIn("PY004", codes(source))
|
|
23
|
+
|
|
24
|
+
def test_function_length_and_file_length(self):
|
|
25
|
+
source = "def large():\n" + " pass\n" * 40
|
|
26
|
+
self.assertIn("PY003", codes(source))
|
|
27
|
+
self.assertNotIn("PY002", codes(source))
|
|
28
|
+
self.assertIn("PY002", codes("x = 1\n" * 121))
|
|
29
|
+
self.assertIn("PY001", codes("x = '" + "x" * 121 + "'\n"))
|
|
30
|
+
|
|
31
|
+
def test_comments_do_not_confuse_strings_docstrings_or_directives(self):
|
|
32
|
+
source = (
|
|
33
|
+
"def item():\n"
|
|
34
|
+
' """A # symbol in a docstring."""\n'
|
|
35
|
+
" text = '# not a comment'\n"
|
|
36
|
+
" value = 1 # type: ignore[assignment]\n"
|
|
37
|
+
" # explain the unusual branch\n"
|
|
38
|
+
" return value\n"
|
|
39
|
+
)
|
|
40
|
+
self.assertIn("PY006", codes(source))
|
|
41
|
+
self.assertNotIn("PY005", codes(source))
|
|
42
|
+
self.assertNotIn("PY007", codes(source.replace("# explain the unusual branch", "# a short note")))
|
|
43
|
+
|
|
44
|
+
def test_inline_comment_and_language(self):
|
|
45
|
+
self.assertIn("PY005", codes("value = 1 # a comment next to code\n"))
|
|
46
|
+
if LANGUAGE_AVAILABLE:
|
|
47
|
+
self.assertIn("PY007", codes("# Cette fonction calcule la valeur finale pour chaque entrée.\nvalue = 1\n"))
|
|
48
|
+
self.assertNotIn("PY007", codes("# This function computes the final value for every input.\nvalue = 1\n"))
|
|
49
|
+
|
|
50
|
+
def test_trailing_comment_stays_in_function_scope(self):
|
|
51
|
+
source = "def item():\n return 1\n # trailing inside function\n# outside function\n"
|
|
52
|
+
path = Path("sample.py")
|
|
53
|
+
_, functions = check_source(path, source)
|
|
54
|
+
comments = check_comments(path, source, functions)
|
|
55
|
+
self.assertEqual([(issue.line, issue.code) for issue in comments if issue.code == "PY006"], [(3, "PY006")])
|
|
56
|
+
|
|
57
|
+
def test_structure_counts_direct_sources_and_ignores_cache(self):
|
|
58
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
59
|
+
root = Path(temp)
|
|
60
|
+
for number in range(9):
|
|
61
|
+
(root / f"file_{number}.py").write_text("x = 1\n", encoding="utf-8")
|
|
62
|
+
(root / "__pycache__").mkdir()
|
|
63
|
+
(root / "__pycache__" / "ignored.py").write_text("", encoding="utf-8")
|
|
64
|
+
files, issues = discover([root], max_files=8)
|
|
65
|
+
self.assertEqual(len(files), 9)
|
|
66
|
+
self.assertEqual([issue.code for issue in issues], ["PY008"])
|
|
67
|
+
|
|
68
|
+
def test_structure_ignores_virtual_environment_variants_at_every_depth(self):
|
|
69
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
70
|
+
root = Path(temp)
|
|
71
|
+
source = root / "src"
|
|
72
|
+
source.mkdir()
|
|
73
|
+
expected = []
|
|
74
|
+
for name in ("venv_helpers", "app.venv"):
|
|
75
|
+
directory = source / name
|
|
76
|
+
directory.mkdir()
|
|
77
|
+
path = directory / "visible.py"
|
|
78
|
+
path.write_text("value = 1\n", encoding="utf-8")
|
|
79
|
+
expected.append(path.resolve())
|
|
80
|
+
for parent in (root, source):
|
|
81
|
+
for name in (".venv", ".venv-cardd", ".venv-vision", ".venv312"):
|
|
82
|
+
directory = parent / name / "Lib" / "site-packages"
|
|
83
|
+
directory.mkdir(parents=True)
|
|
84
|
+
for number in range(9):
|
|
85
|
+
(directory / f"ignored_{number}.py").write_text("invalid python\n", encoding="utf-8")
|
|
86
|
+
files, issues = discover([root], max_files=8)
|
|
87
|
+
self.assertEqual(files, sorted(expected))
|
|
88
|
+
self.assertEqual(issues, [])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
unittest.main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SummaryTests(unittest.TestCase):
|
|
9
|
+
def test_success_is_reported_once_only_when_all_checks_pass(self):
|
|
10
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
11
|
+
root = Path(temp)
|
|
12
|
+
for number in range(51):
|
|
13
|
+
(root / f"sample_{number}.py").write_text("value = 1\n", encoding="utf-8", newline="\n")
|
|
14
|
+
command = [sys.executable, "-m", "nk_python", "check", str(root)]
|
|
15
|
+
checked = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
16
|
+
self.assertEqual(checked.returncode, 1, checked.stderr + checked.stdout)
|
|
17
|
+
self.assertIn("Linter: 1 warnings", checked.stdout)
|
|
18
|
+
self.assertIn("PY008", checked.stdout)
|
|
19
|
+
self.assertNotIn("All checks passed!", checked.stdout)
|
|
20
|
+
checked = subprocess.run(command + ["--max-files", "51"], capture_output=True, text=True, check=False)
|
|
21
|
+
self.assertEqual(checked.returncode, 0, checked.stderr + checked.stdout)
|
|
22
|
+
self.assertEqual(checked.stdout.count("All checks passed!"), 1)
|
|
23
|
+
summary, _ = checked.stdout.split("=== Linter diagnostics ===")
|
|
24
|
+
self.assertIn("All checks passed!", summary)
|
|
25
|
+
|
|
26
|
+
def test_summary_counts_all_batches_and_structure_diagnostics(self):
|
|
27
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
28
|
+
root = Path(temp)
|
|
29
|
+
source = "import sys\nimport os\n\nvalue=1\n"
|
|
30
|
+
for number in range(51):
|
|
31
|
+
(root / f"sample_{number}.py").write_text(source, encoding="utf-8")
|
|
32
|
+
checked = subprocess.run(
|
|
33
|
+
[sys.executable, "-m", "nk_python", "check", str(root)],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
self.assertEqual(checked.returncode, 1, checked.stderr + checked.stdout)
|
|
39
|
+
self.assertNotIn("All checks passed!", checked.stdout)
|
|
40
|
+
summary, details = checked.stdout.split("=== Linter diagnostics ===")
|
|
41
|
+
self.assertTrue(summary.startswith("=== Check summary ==="))
|
|
42
|
+
self.assertIn("Files checked: 51", summary)
|
|
43
|
+
self.assertIn("Linter: 103 warnings", summary)
|
|
44
|
+
self.assertIn("Formatter: 51 import diagnostics; 51 files to reformat", summary)
|
|
45
|
+
self.assertIn("PY008", details)
|
|
46
|
+
self.assertEqual(checked.stdout.count("=== Check summary ==="), 1)
|
|
47
|
+
self.assertTrue(all(path.read_text(encoding="utf-8") == source for path in root.glob("*.py")))
|