nk-python-style 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Python style checks inspired by the C++ tooling in this repository."""
nk_python/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
nk_python/cli.py ADDED
@@ -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
nk_python/model.py ADDED
@@ -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}"
nk_python/ruff.toml ADDED
@@ -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,14 @@
1
+ nk_python/__init__.py,sha256=Bzh-uQ6uWnIE5R-1opQqMpR6NfWELL4XjX2y_nR46qc,74
2
+ nk_python/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ nk_python/ast_checks.py,sha256=auhtwbCkcdzU0FmBwI_NOAMIZ6SPtdlpvbsV4s_mmNs,3139
4
+ nk_python/cli.py,sha256=4wL23RUARVZBz8sfsJ7z-LKFLoR2G6LOHw_41giS7Mg,4217
5
+ nk_python/comment_checks.py,sha256=GDJ0QzlbGLp5ulQ6cgnM_5ixpCkbG8EFeK7DixvVnCM,3051
6
+ nk_python/model.py,sha256=qSwiwnbXA4eWGkReSe74QBq_L2YJM6yVkd-g-AjNjLg,298
7
+ nk_python/ruff.toml,sha256=dkjak3D4VS6hnGcKXzDaqQ5CpA723Xbp7lEDV7Y5cYI,320
8
+ nk_python/ruff_runner.py,sha256=v_aLQVajD56Z51exlVvmUlLXOgJOSDfBZmD93dcbcd4,1053
9
+ nk_python/structure_checks.py,sha256=HCbm_g01i0g3vzGl_cDZu0tcMsoMKtjtj-DZVx-tqAw,1748
10
+ nk_python_style-0.1.0.dist-info/METADATA,sha256=rdKc_ry8CHQcmkh7W4-r_gFDp-naS5WNFydGVBAyLhM,4056
11
+ nk_python_style-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ nk_python_style-0.1.0.dist-info/entry_points.txt,sha256=rjz1wN9VFSj7FcgYsnWZxqoIk-UpzyfMx-_t7U39Rnc,49
13
+ nk_python_style-0.1.0.dist-info/top_level.txt,sha256=pGStA10dDoyVVsTrGpFRzp_cSnrOWUDP4ZNeEfWTd8E,10
14
+ nk_python_style-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nk-python = nk_python.cli:main
@@ -0,0 +1 @@
1
+ nk_python