pyproject-lens 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.
- pyproject_lens-0.1.0/LICENSE +21 -0
- pyproject_lens-0.1.0/PKG-INFO +74 -0
- pyproject_lens-0.1.0/README.md +53 -0
- pyproject_lens-0.1.0/pyproject.toml +35 -0
- pyproject_lens-0.1.0/setup.cfg +4 -0
- pyproject_lens-0.1.0/src/pyproject_lens/__init__.py +7 -0
- pyproject_lens-0.1.0/src/pyproject_lens/analyzers.py +163 -0
- pyproject_lens-0.1.0/src/pyproject_lens/cli.py +69 -0
- pyproject_lens-0.1.0/src/pyproject_lens/models.py +74 -0
- pyproject_lens-0.1.0/src/pyproject_lens.egg-info/PKG-INFO +74 -0
- pyproject_lens-0.1.0/src/pyproject_lens.egg-info/SOURCES.txt +14 -0
- pyproject_lens-0.1.0/src/pyproject_lens.egg-info/dependency_links.txt +1 -0
- pyproject_lens-0.1.0/src/pyproject_lens.egg-info/entry_points.txt +2 -0
- pyproject_lens-0.1.0/src/pyproject_lens.egg-info/requires.txt +3 -0
- pyproject_lens-0.1.0/src/pyproject_lens.egg-info/top_level.txt +1 -0
- pyproject_lens-0.1.0/tests/test_analyze.py +23 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyproject-lens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small health scanner for Python projects.
|
|
5
|
+
Author: Sam
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Sam3360/pyproject-lens
|
|
8
|
+
Project-URL: Repository, https://github.com/Sam3360/pyproject-lens
|
|
9
|
+
Project-URL: Issues, https://github.com/Sam3360/pyproject-lens/issues
|
|
10
|
+
Keywords: python,cli,project,pyproject,health
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# pyproject-lens
|
|
23
|
+
|
|
24
|
+
A small, free health scanner for Python projects. Point it at a folder and it checks the basics: packaging metadata, imports versus declared dependencies, Python-version claims, project layout, and Git hygiene.
|
|
25
|
+
|
|
26
|
+
It is built for the first ten seconds of project review — not to replace Ruff, pytest, Bandit, or a human code review.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pyproject-lens
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Use it
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pyproject-lens .
|
|
38
|
+
pyproject-lens ./another-project --json report.json
|
|
39
|
+
pyproject-lens . --markdown report.md
|
|
40
|
+
pyproject-lens . --ci --minimum-score 75
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You can also use it in Python:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pyproject_lens import analyze
|
|
47
|
+
|
|
48
|
+
report = analyze(".")
|
|
49
|
+
print(report.score)
|
|
50
|
+
print(report.to_json())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## What the score means
|
|
54
|
+
|
|
55
|
+
Each of the five sections starts at 100. Detected issues reduce only the relevant section, and the project score is the rounded average. The rules are deliberately simple and visible in `src/pyproject_lens/analyzers.py`; it is a conversation starter, not a grade.
|
|
56
|
+
|
|
57
|
+
## Scope for version 0.1
|
|
58
|
+
|
|
59
|
+
- Packaging: `pyproject.toml`, project name, Python version, README
|
|
60
|
+
- Dependencies: direct source imports compared with `project.dependencies`
|
|
61
|
+
- Compatibility: detects `match/case` used with a Python claim below 3.10
|
|
62
|
+
- Structure: `src/`, packages, root modules, and tests directory
|
|
63
|
+
- Repository hygiene: `.gitignore` and uncommitted changes
|
|
64
|
+
|
|
65
|
+
Everything is free and open source under the MIT license.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python -m pip install -e .
|
|
71
|
+
python -m unittest discover -s tests
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Contributions are welcome. Please keep checks practical, explain what they found, and avoid claiming certainty when static analysis cannot prove something.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# pyproject-lens
|
|
2
|
+
|
|
3
|
+
A small, free health scanner for Python projects. Point it at a folder and it checks the basics: packaging metadata, imports versus declared dependencies, Python-version claims, project layout, and Git hygiene.
|
|
4
|
+
|
|
5
|
+
It is built for the first ten seconds of project review — not to replace Ruff, pytest, Bandit, or a human code review.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install pyproject-lens
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use it
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pyproject-lens .
|
|
17
|
+
pyproject-lens ./another-project --json report.json
|
|
18
|
+
pyproject-lens . --markdown report.md
|
|
19
|
+
pyproject-lens . --ci --minimum-score 75
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
You can also use it in Python:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from pyproject_lens import analyze
|
|
26
|
+
|
|
27
|
+
report = analyze(".")
|
|
28
|
+
print(report.score)
|
|
29
|
+
print(report.to_json())
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## What the score means
|
|
33
|
+
|
|
34
|
+
Each of the five sections starts at 100. Detected issues reduce only the relevant section, and the project score is the rounded average. The rules are deliberately simple and visible in `src/pyproject_lens/analyzers.py`; it is a conversation starter, not a grade.
|
|
35
|
+
|
|
36
|
+
## Scope for version 0.1
|
|
37
|
+
|
|
38
|
+
- Packaging: `pyproject.toml`, project name, Python version, README
|
|
39
|
+
- Dependencies: direct source imports compared with `project.dependencies`
|
|
40
|
+
- Compatibility: detects `match/case` used with a Python claim below 3.10
|
|
41
|
+
- Structure: `src/`, packages, root modules, and tests directory
|
|
42
|
+
- Repository hygiene: `.gitignore` and uncommitted changes
|
|
43
|
+
|
|
44
|
+
Everything is free and open source under the MIT license.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python -m pip install -e .
|
|
50
|
+
python -m unittest discover -s tests
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Contributions are welcome. Please keep checks practical, explain what they found, and avoid claiming certainty when static analysis cannot prove something.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyproject-lens"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small health scanner for Python projects."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = ["tomli; python_version < '3.11'"]
|
|
12
|
+
license = {text = "MIT"}
|
|
13
|
+
authors = [{name = "Sam"}]
|
|
14
|
+
keywords = ["python", "cli", "project", "pyproject", "health"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
pyproject-lens = "pyproject_lens.cli:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Sam3360/pyproject-lens"
|
|
28
|
+
Repository = "https://github.com/Sam3360/pyproject-lens"
|
|
29
|
+
Issues = "https://github.com/Sam3360/pyproject-lens/issues"
|
|
30
|
+
|
|
31
|
+
[tool.pyproject-lens]
|
|
32
|
+
minimum_score = 0
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The intentionally small, dependency-free project checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import tomllib
|
|
14
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.9/3.10
|
|
15
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
16
|
+
|
|
17
|
+
from .models import Report, Section
|
|
18
|
+
|
|
19
|
+
SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "build", "dist", ".tox", ".mypy_cache"}
|
|
20
|
+
STDLIB = set(getattr(sys, "stdlib_module_names", ()))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _python_files(root: Path) -> list[Path]:
|
|
24
|
+
return [path for path in root.rglob("*.py") if not any(part in SKIP_DIRS for part in path.parts)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
28
|
+
try:
|
|
29
|
+
with path.open("rb") as handle:
|
|
30
|
+
return tomllib.load(handle)
|
|
31
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _top_level_imports(files: list[Path]) -> set[str]:
|
|
36
|
+
imports: set[str] = set()
|
|
37
|
+
for file in files:
|
|
38
|
+
try:
|
|
39
|
+
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
|
40
|
+
except (OSError, UnicodeDecodeError, SyntaxError):
|
|
41
|
+
continue
|
|
42
|
+
for node in ast.walk(tree):
|
|
43
|
+
if isinstance(node, ast.Import):
|
|
44
|
+
imports.update(name.name.split(".")[0] for name in node.names)
|
|
45
|
+
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
46
|
+
imports.add(node.module.split(".")[0])
|
|
47
|
+
return imports
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _distribution_name(value: str) -> str:
|
|
51
|
+
return re.split(r"[<>=!~;[ ]", value, maxsplit=1)[0].lower().replace("_", "-")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _packaging(root: Path, config: dict[str, Any]) -> Section:
|
|
55
|
+
section = Section("Packaging")
|
|
56
|
+
pyproject = root / "pyproject.toml"
|
|
57
|
+
project = config.get("project", {})
|
|
58
|
+
if not pyproject.exists():
|
|
59
|
+
section.score = 30
|
|
60
|
+
section.add("warning", "No pyproject.toml found.", "Add one to describe how your project is built.")
|
|
61
|
+
return section
|
|
62
|
+
if not config:
|
|
63
|
+
section.score = 45
|
|
64
|
+
section.add("error", "pyproject.toml could not be read.", "Check its TOML syntax.", "pyproject.toml")
|
|
65
|
+
if not project.get("name"):
|
|
66
|
+
section.score -= 20
|
|
67
|
+
section.add("warning", "Project name is missing.", "Set project.name in pyproject.toml.", "pyproject.toml")
|
|
68
|
+
if not project.get("requires-python"):
|
|
69
|
+
section.score -= 15
|
|
70
|
+
section.add("warning", "Supported Python versions are not declared.", "Set project.requires-python.", "pyproject.toml")
|
|
71
|
+
if not (root / "README.md").exists() and not project.get("readme"):
|
|
72
|
+
section.score -= 15
|
|
73
|
+
section.add("warning", "No README was found.", "Add a short README with install and usage instructions.")
|
|
74
|
+
return section
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _dependencies(root: Path, config: dict[str, Any], files: list[Path]) -> Section:
|
|
78
|
+
section = Section("Dependencies")
|
|
79
|
+
declared = {_distribution_name(item) for item in config.get("project", {}).get("dependencies", [])}
|
|
80
|
+
imports = _top_level_imports(files)
|
|
81
|
+
local = {path.stem for path in files}
|
|
82
|
+
for file in files:
|
|
83
|
+
try:
|
|
84
|
+
relative = file.relative_to(root)
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
local.update(part for part in relative.parts[:-1] if part not in {"src", "tests"})
|
|
88
|
+
missing = sorted(name for name in imports if name not in STDLIB and name not in local and name.replace("_", "-") not in declared)
|
|
89
|
+
if missing:
|
|
90
|
+
section.score -= min(45, 10 * len(missing))
|
|
91
|
+
for name in missing[:8]:
|
|
92
|
+
section.add("warning", f"'{name}' is imported but not declared.", "Add it to project.dependencies if it is a runtime dependency.")
|
|
93
|
+
if (root / "requirements.txt").exists() and not declared:
|
|
94
|
+
section.score -= 10
|
|
95
|
+
section.add("info", "requirements.txt exists but project.dependencies is empty.", "Consider keeping runtime dependencies in pyproject.toml.")
|
|
96
|
+
return section
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _compatibility(config: dict[str, Any], files: list[Path]) -> Section:
|
|
100
|
+
section = Section("Python compatibility")
|
|
101
|
+
requires = config.get("project", {}).get("requires-python", "")
|
|
102
|
+
uses_match = False
|
|
103
|
+
for file in files:
|
|
104
|
+
try:
|
|
105
|
+
uses_match |= any(isinstance(node, ast.Match) for node in ast.walk(ast.parse(file.read_text(encoding="utf-8"))))
|
|
106
|
+
except (OSError, UnicodeDecodeError, SyntaxError):
|
|
107
|
+
continue
|
|
108
|
+
if uses_match and re.search(r">=3\.(?:[0-9]|10)\b", requires) and not re.search(r">=3\.(?:1[0-9]|[2-9][0-9])\b", requires):
|
|
109
|
+
section.score = 60
|
|
110
|
+
section.add("warning", "match/case syntax needs Python 3.10+.", "Raise requires-python to >=3.10 or avoid match/case.")
|
|
111
|
+
elif not requires:
|
|
112
|
+
section.score = 80
|
|
113
|
+
section.add("info", "Compatibility cannot be checked without requires-python.", "Declare supported Python versions in pyproject.toml.")
|
|
114
|
+
return section
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _structure(root: Path, files: list[Path]) -> Section:
|
|
118
|
+
section = Section("Project structure")
|
|
119
|
+
source_root = root / "src"
|
|
120
|
+
packages = [path for path in (source_root if source_root.is_dir() else root).iterdir() if path.is_dir() and (path / "__init__.py").exists()]
|
|
121
|
+
if source_root.is_dir() and not packages:
|
|
122
|
+
section.score -= 25
|
|
123
|
+
section.add("warning", "src/ exists but no package was detected.", "Put your package in src/ with an __init__.py file.", "src")
|
|
124
|
+
top_level = [path for path in files if path.parent == root and path.name not in {"setup.py", "conftest.py"}]
|
|
125
|
+
if len(top_level) > 6:
|
|
126
|
+
section.score -= 15
|
|
127
|
+
section.add("info", f"{len(top_level)} Python files live at the project root.", "Consider grouping application code into a package.")
|
|
128
|
+
tests = root / "tests"
|
|
129
|
+
if not tests.exists():
|
|
130
|
+
section.score -= 20
|
|
131
|
+
section.add("warning", "No tests/ directory found.", "Start with a small tests/ directory for important behavior.")
|
|
132
|
+
return section
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _git_health(root: Path) -> Section:
|
|
136
|
+
section = Section("Repository hygiene")
|
|
137
|
+
if not (root / ".git").exists():
|
|
138
|
+
section.score = 80
|
|
139
|
+
section.add("info", "This directory is not a Git repository.", "Initialize Git before sharing or releasing the project.")
|
|
140
|
+
return section
|
|
141
|
+
if not (root / ".gitignore").exists():
|
|
142
|
+
section.score -= 20
|
|
143
|
+
section.add("warning", "No .gitignore found.", "Ignore virtual environments, caches, and build output.")
|
|
144
|
+
try:
|
|
145
|
+
result = subprocess.run(["git", "status", "--porcelain"], cwd=root, text=True, capture_output=True, timeout=3, check=False)
|
|
146
|
+
changes = result.stdout.splitlines()
|
|
147
|
+
if changes:
|
|
148
|
+
section.score -= min(20, len(changes))
|
|
149
|
+
section.add("info", f"{len(changes)} uncommitted Git change(s).", "Commit or stash work when you reach a clean checkpoint.")
|
|
150
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
151
|
+
section.add("info", "Git status could not be checked.")
|
|
152
|
+
return section
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def analyze(path: str | Path = ".") -> Report:
|
|
156
|
+
"""Analyze a project directory and return a report. Never changes the project."""
|
|
157
|
+
root = Path(path).expanduser().resolve()
|
|
158
|
+
if not root.is_dir():
|
|
159
|
+
raise ValueError(f"Not a directory: {root}")
|
|
160
|
+
files = _python_files(root)
|
|
161
|
+
config = _read_toml(root / "pyproject.toml")
|
|
162
|
+
sections = [_packaging(root, config), _dependencies(root, config, files), _compatibility(config, files), _structure(root, files), _git_health(root)]
|
|
163
|
+
return Report(path=root, sections=sections, files_scanned=len(files))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Command line interface for pyproject-lens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .analyzers import analyze
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _print_report(report: object) -> None:
|
|
13
|
+
from .models import Report
|
|
14
|
+
report = report # keep the terminal-friendly type narrow below
|
|
15
|
+
assert isinstance(report, Report)
|
|
16
|
+
print("pyproject-lens — Project Health Report")
|
|
17
|
+
print(f"Project: {report.path.name} | Python files scanned: {report.files_scanned}")
|
|
18
|
+
print("=" * 58)
|
|
19
|
+
for section in report.sections:
|
|
20
|
+
print(f"\n{section.name.upper()} {section.score}/100")
|
|
21
|
+
if not section.findings:
|
|
22
|
+
print(" OK — nothing obvious found")
|
|
23
|
+
for finding in section.findings:
|
|
24
|
+
print(f" {finding.level.upper()}: {finding.message}")
|
|
25
|
+
if finding.recommendation:
|
|
26
|
+
print(f" Try: {finding.recommendation}")
|
|
27
|
+
warnings = sum(item.level in {"warning", "error"} for item in report.findings)
|
|
28
|
+
print(f"\nPROJECT HEALTH: {report.score}/100 | {warnings} warning(s)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
parser = argparse.ArgumentParser(description="A small health scanner for Python projects.")
|
|
33
|
+
parser.add_argument("path", nargs="?", default=".", help="project directory (default: current directory)")
|
|
34
|
+
parser.add_argument("--json", metavar="FILE", help="write a JSON report to FILE; use - for stdout")
|
|
35
|
+
parser.add_argument("--markdown", metavar="FILE", help="write a Markdown report to FILE; use - for stdout")
|
|
36
|
+
parser.add_argument("--ci", action="store_true", help="return an error if minimum_score is not met")
|
|
37
|
+
parser.add_argument("--minimum-score", type=int, help="score needed for --ci (overrides pyproject.toml)")
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
try:
|
|
40
|
+
report = analyze(args.path)
|
|
41
|
+
except ValueError as error:
|
|
42
|
+
parser.error(str(error))
|
|
43
|
+
if args.json:
|
|
44
|
+
text = report.to_json(None if args.json == "-" else args.json)
|
|
45
|
+
if args.json == "-":
|
|
46
|
+
print(text, end="")
|
|
47
|
+
elif args.markdown:
|
|
48
|
+
text = report.to_markdown(None if args.markdown == "-" else args.markdown)
|
|
49
|
+
if args.markdown == "-":
|
|
50
|
+
print(text, end="")
|
|
51
|
+
else:
|
|
52
|
+
_print_report(report)
|
|
53
|
+
if args.ci:
|
|
54
|
+
threshold = args.minimum_score
|
|
55
|
+
if threshold is None:
|
|
56
|
+
import tomllib
|
|
57
|
+
try:
|
|
58
|
+
with (Path(args.path) / "pyproject.toml").open("rb") as handle:
|
|
59
|
+
threshold = tomllib.load(handle).get("tool", {}).get("pyproject-lens", {}).get("minimum_score", 0)
|
|
60
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
61
|
+
threshold = 0
|
|
62
|
+
if report.score < threshold:
|
|
63
|
+
print(f"CI FAILED: score {report.score} is below minimum_score {threshold}", file=sys.stderr)
|
|
64
|
+
return 1
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Small data objects used by the scanner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Finding:
|
|
13
|
+
level: str
|
|
14
|
+
message: str
|
|
15
|
+
recommendation: str | None = None
|
|
16
|
+
path: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Section:
|
|
21
|
+
name: str
|
|
22
|
+
score: int = 100
|
|
23
|
+
findings: list[Finding] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def add(self, level: str, message: str, recommendation: str | None = None, path: str | None = None) -> None:
|
|
26
|
+
self.findings.append(Finding(level, message, recommendation, path))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Report:
|
|
31
|
+
path: Path
|
|
32
|
+
sections: list[Section]
|
|
33
|
+
files_scanned: int
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def score(self) -> int:
|
|
37
|
+
if not self.sections:
|
|
38
|
+
return 100
|
|
39
|
+
return round(sum(section.score for section in self.sections) / len(self.sections))
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def findings(self) -> list[Finding]:
|
|
43
|
+
return [finding for section in self.sections for finding in section.findings]
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"path": str(self.path),
|
|
48
|
+
"score": self.score,
|
|
49
|
+
"files_scanned": self.files_scanned,
|
|
50
|
+
"sections": [asdict(section) for section in self.sections],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def to_json(self, output: str | Path | None = None) -> str:
|
|
54
|
+
text = json.dumps(self.to_dict(), indent=2) + "\n"
|
|
55
|
+
if output:
|
|
56
|
+
Path(output).write_text(text, encoding="utf-8")
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
def to_markdown(self, output: str | Path | None = None) -> str:
|
|
60
|
+
lines = [f"# pyproject-lens report", "", f"**Project:** `{self.path.name}`", f"**Health:** {self.score}/100", ""]
|
|
61
|
+
for section in self.sections:
|
|
62
|
+
lines.extend([f"## {section.name} — {section.score}/100", ""])
|
|
63
|
+
if not section.findings:
|
|
64
|
+
lines.extend(["No issues detected.", ""])
|
|
65
|
+
continue
|
|
66
|
+
for finding in section.findings:
|
|
67
|
+
detail = f" — {finding.recommendation}" if finding.recommendation else ""
|
|
68
|
+
location = f" (`{finding.path}`)" if finding.path else ""
|
|
69
|
+
lines.append(f"- **{finding.level.upper()}**{location}: {finding.message}{detail}")
|
|
70
|
+
lines.append("")
|
|
71
|
+
text = "\n".join(lines)
|
|
72
|
+
if output:
|
|
73
|
+
Path(output).write_text(text, encoding="utf-8")
|
|
74
|
+
return text
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyproject-lens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small health scanner for Python projects.
|
|
5
|
+
Author: Sam
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Sam3360/pyproject-lens
|
|
8
|
+
Project-URL: Repository, https://github.com/Sam3360/pyproject-lens
|
|
9
|
+
Project-URL: Issues, https://github.com/Sam3360/pyproject-lens/issues
|
|
10
|
+
Keywords: python,cli,project,pyproject,health
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# pyproject-lens
|
|
23
|
+
|
|
24
|
+
A small, free health scanner for Python projects. Point it at a folder and it checks the basics: packaging metadata, imports versus declared dependencies, Python-version claims, project layout, and Git hygiene.
|
|
25
|
+
|
|
26
|
+
It is built for the first ten seconds of project review — not to replace Ruff, pytest, Bandit, or a human code review.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pyproject-lens
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Use it
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pyproject-lens .
|
|
38
|
+
pyproject-lens ./another-project --json report.json
|
|
39
|
+
pyproject-lens . --markdown report.md
|
|
40
|
+
pyproject-lens . --ci --minimum-score 75
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You can also use it in Python:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pyproject_lens import analyze
|
|
47
|
+
|
|
48
|
+
report = analyze(".")
|
|
49
|
+
print(report.score)
|
|
50
|
+
print(report.to_json())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## What the score means
|
|
54
|
+
|
|
55
|
+
Each of the five sections starts at 100. Detected issues reduce only the relevant section, and the project score is the rounded average. The rules are deliberately simple and visible in `src/pyproject_lens/analyzers.py`; it is a conversation starter, not a grade.
|
|
56
|
+
|
|
57
|
+
## Scope for version 0.1
|
|
58
|
+
|
|
59
|
+
- Packaging: `pyproject.toml`, project name, Python version, README
|
|
60
|
+
- Dependencies: direct source imports compared with `project.dependencies`
|
|
61
|
+
- Compatibility: detects `match/case` used with a Python claim below 3.10
|
|
62
|
+
- Structure: `src/`, packages, root modules, and tests directory
|
|
63
|
+
- Repository hygiene: `.gitignore` and uncommitted changes
|
|
64
|
+
|
|
65
|
+
Everything is free and open source under the MIT license.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python -m pip install -e .
|
|
71
|
+
python -m unittest discover -s tests
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Contributions are welcome. Please keep checks practical, explain what they found, and avoid claiming certainty when static analysis cannot prove something.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pyproject_lens/__init__.py
|
|
5
|
+
src/pyproject_lens/analyzers.py
|
|
6
|
+
src/pyproject_lens/cli.py
|
|
7
|
+
src/pyproject_lens/models.py
|
|
8
|
+
src/pyproject_lens.egg-info/PKG-INFO
|
|
9
|
+
src/pyproject_lens.egg-info/SOURCES.txt
|
|
10
|
+
src/pyproject_lens.egg-info/dependency_links.txt
|
|
11
|
+
src/pyproject_lens.egg-info/entry_points.txt
|
|
12
|
+
src/pyproject_lens.egg-info/requires.txt
|
|
13
|
+
src/pyproject_lens.egg-info/top_level.txt
|
|
14
|
+
tests/test_analyze.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyproject_lens
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pyproject_lens import analyze
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AnalyzeTests(unittest.TestCase):
|
|
9
|
+
def test_analyze_returns_sections(self) -> None:
|
|
10
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
11
|
+
root = Path(temporary)
|
|
12
|
+
(root / "pyproject.toml").write_text('[project]\nname = "demo"\nrequires-python = ">=3.10"\n')
|
|
13
|
+
(root / "README.md").write_text("# Demo")
|
|
14
|
+
(root / "src" / "demo").mkdir(parents=True)
|
|
15
|
+
(root / "src" / "demo" / "__init__.py").write_text("")
|
|
16
|
+
report = analyze(root)
|
|
17
|
+
self.assertLessEqual(report.score, 100)
|
|
18
|
+
self.assertEqual([section.name for section in report.sections], ["Packaging", "Dependencies", "Python compatibility", "Project structure", "Repository hygiene"])
|
|
19
|
+
|
|
20
|
+
def test_json_is_valid(self) -> None:
|
|
21
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
22
|
+
report = analyze(temporary)
|
|
23
|
+
self.assertIn('"score"', report.to_json())
|