py-lean-package 0.1.1__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.
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.3
2
+ Name: py-lean-package
3
+ Version: 0.1.1
4
+ Summary: Detect and fix missing source-exclude patterns in Python projects.
5
+ Author: Raphael Stolt
6
+ Author-email: Raphael Stolt <raphael.stolt@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: pydantic>=2.14.1
9
+ Requires-Dist: tomlkit>=0.15.2
10
+ Requires-Dist: typer>=0.27.3
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+
14
+ # py-lean
15
+
16
+ Detect and fix missing `source-exclude` patterns in Python projects built with `uv_build`.
17
+
18
+ `py-lean` is a Python counterpart to [cargo-lean](https://github.com/raphaelstolt/cargo-lean). It checks the source-distribution configuration in
19
+ `pyproject.toml` so repository-only files don't unnecessarily end up in published archives.
20
+
21
+ ## Why?
22
+
23
+ A Python package's source repository often contains more than consumers need to install and use it. Tests, CI workflows,
24
+ development configuration, and other repository-specific files can add unnecessary noise and size to source distributions.
25
+
26
+ `py-lean` helps make distribution contents intentional by identifying common repository artefacts that are missing from
27
+ `tool.uv.build-backend.source-exclude`. It can also add the missing patterns to `pyproject.toml`.
28
+
29
+ Your working repository remains intact: `py-lean` configures what is excluded from the built source distribution; it does
30
+ not delete those files from your project.
31
+
32
+ ## Features
33
+
34
+ - __Lint__ — Detect common repository artefacts that are not listed in `source-exclude`.
35
+ - __Create__ — Add a `source-exclude` configuration for detected artefacts.
36
+ - __Update__ — Add missing exclusion patterns to an existing configuration.
37
+ - __Preview changes__ — Use `--dry-run` and `--diff` to review proposed edits before applying them.
38
+ - __Machine-readable output__ — Emit lint results as JSON.
39
+
40
+ ## Requirements
41
+
42
+ - Python 3.11 or newer
43
+ - [uv](https://github.com/astral-sh/uv) and a project using the `uv_build` build backend
44
+
45
+ ### Required build-system configuration
46
+
47
+ `py-lean` manages `tool.uv.build-backend.source-exclude`, which is a `uv_build` configuration. The target project must
48
+ therefore use `uv_build` as its build backend for this setting to apply.
49
+
50
+ For example, the target project's `pyproject.toml` should include:
51
+
52
+ ```toml
53
+ [build-system]
54
+ requires = ["uv_build"]
55
+ build-backend = "uv_build"
56
+ ```
57
+
58
+ If the project uses a different build backend, `py-lean`'s `source-exclude` configuration will not control that backend's
59
+ source-distribution contents.
60
+
61
+ ## Installation
62
+
63
+ Install with `uv`:
64
+
65
+ ```bash
66
+ uv tool install py-lean-package
67
+ ```
68
+
69
+ Or install from source:
70
+
71
+ ```bash
72
+ git clone https://github.com/raphaelstolt/py-lean.git
73
+ cd py-lean
74
+ uv sync
75
+ uv run --project . py-lean --help
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ ### Lint
81
+
82
+ Check for missing patterns in `source-exclude`:
83
+
84
+ ```bash
85
+ py-lean lint
86
+ ```
87
+
88
+ Options:
89
+
90
+ | Flag | Description |
91
+ |------|-------------|
92
+ | `-m, --manifest-path <PATH>` | Path to `pyproject.toml` (default: `pyproject.toml`) |
93
+ | `--format <text\|json>` | Output format (default: `text`) |
94
+ | `-p, --patterns <PATTERN>` | Artefact patterns to check, such as `tests/` |
95
+ | `--dry-run` | Show what would be done without making changes |
96
+ | `--diff` | Show a diff of the proposed changes |
97
+
98
+ Examples:
99
+
100
+ ```bash
101
+ # Lint with JSON output
102
+ py-lean lint --format json
103
+
104
+ # Use a custom manifest path
105
+ py-lean lint -m path/to/pyproject.toml
106
+
107
+ # Check selected artefact patterns
108
+ py-lean lint -p "tests/" -p "benchmarks/"
109
+ ```
110
+
111
+ ### Create
112
+
113
+ Create a `tool.uv.build-backend.source-exclude` configuration containing common repository artefacts:
114
+
115
+ ```bash
116
+ py-lean create
117
+ ```
118
+
119
+ This scans the project for common artefacts, such as `tests/`, `docs/`, and `.git/`, and adds the detected patterns to
120
+ `pyproject.toml`.
121
+
122
+ Options:
123
+
124
+ | Flag | Description |
125
+ |------|-------------|
126
+ | `--dry-run` | Preview changes without modifying files |
127
+ | `--diff` | Show a unified diff of the proposed changes |
128
+
129
+ ### Update
130
+
131
+ Add missing patterns to an existing `source-exclude` configuration:
132
+
133
+ ```bash
134
+ py-lean update
135
+ ```
136
+
137
+ Options:
138
+
139
+ | Flag | Description |
140
+ |------|-------------|
141
+ | `--dry-run` | Preview changes without modifying files |
142
+ | `--diff` | Show a unified diff of the proposed changes |
143
+
144
+ Examples:
145
+
146
+ ```bash
147
+ # Preview changes
148
+ py-lean update --dry-run --diff
149
+
150
+ # Apply changes
151
+ py-lean update
152
+ ```
153
+
154
+ ## Configuration
155
+
156
+ ### Pattern precedence
157
+
158
+ `py-lean` determines which artefact patterns to check using the following precedence:
159
+
160
+ 1. __CLI `--patterns`__ — highest priority; replaces other pattern sources.
161
+ 2. __`tool.uv.build-backend.source-exclude`__ in `pyproject.toml` — patterns already configured for the build backend.
162
+ 3. __Built-in defaults__ — used when neither of the above is configured.
163
+
164
+ Use `--patterns` to override the default patterns:
165
+
166
+ ```bash
167
+ py-lean lint -p "tests/" -p "benchmarks/"
168
+ ```
169
+
170
+ By default, `py-lean` checks a built-in set of common repository artefacts.
171
+
172
+ ## Development
173
+
174
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development instructions.
175
+
176
+ ## License
177
+
178
+ This project is licensed under the MIT License. See [LICENSE.md](LICENSE.md) for details.
179
+
180
+ ## Related projects
181
+
182
+ - [lean-package-validator](https://github.com/raphaelstolt/lean-package-validator) — The `PHP` equivalent
183
+ - [cargo-lean](https://github.com/raphaelstolt/cargo-lean) — The `Rust` equivalent.
@@ -0,0 +1,170 @@
1
+ # py-lean
2
+
3
+ Detect and fix missing `source-exclude` patterns in Python projects built with `uv_build`.
4
+
5
+ `py-lean` is a Python counterpart to [cargo-lean](https://github.com/raphaelstolt/cargo-lean). It checks the source-distribution configuration in
6
+ `pyproject.toml` so repository-only files don't unnecessarily end up in published archives.
7
+
8
+ ## Why?
9
+
10
+ A Python package's source repository often contains more than consumers need to install and use it. Tests, CI workflows,
11
+ development configuration, and other repository-specific files can add unnecessary noise and size to source distributions.
12
+
13
+ `py-lean` helps make distribution contents intentional by identifying common repository artefacts that are missing from
14
+ `tool.uv.build-backend.source-exclude`. It can also add the missing patterns to `pyproject.toml`.
15
+
16
+ Your working repository remains intact: `py-lean` configures what is excluded from the built source distribution; it does
17
+ not delete those files from your project.
18
+
19
+ ## Features
20
+
21
+ - __Lint__ — Detect common repository artefacts that are not listed in `source-exclude`.
22
+ - __Create__ — Add a `source-exclude` configuration for detected artefacts.
23
+ - __Update__ — Add missing exclusion patterns to an existing configuration.
24
+ - __Preview changes__ — Use `--dry-run` and `--diff` to review proposed edits before applying them.
25
+ - __Machine-readable output__ — Emit lint results as JSON.
26
+
27
+ ## Requirements
28
+
29
+ - Python 3.11 or newer
30
+ - [uv](https://github.com/astral-sh/uv) and a project using the `uv_build` build backend
31
+
32
+ ### Required build-system configuration
33
+
34
+ `py-lean` manages `tool.uv.build-backend.source-exclude`, which is a `uv_build` configuration. The target project must
35
+ therefore use `uv_build` as its build backend for this setting to apply.
36
+
37
+ For example, the target project's `pyproject.toml` should include:
38
+
39
+ ```toml
40
+ [build-system]
41
+ requires = ["uv_build"]
42
+ build-backend = "uv_build"
43
+ ```
44
+
45
+ If the project uses a different build backend, `py-lean`'s `source-exclude` configuration will not control that backend's
46
+ source-distribution contents.
47
+
48
+ ## Installation
49
+
50
+ Install with `uv`:
51
+
52
+ ```bash
53
+ uv tool install py-lean-package
54
+ ```
55
+
56
+ Or install from source:
57
+
58
+ ```bash
59
+ git clone https://github.com/raphaelstolt/py-lean.git
60
+ cd py-lean
61
+ uv sync
62
+ uv run --project . py-lean --help
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ### Lint
68
+
69
+ Check for missing patterns in `source-exclude`:
70
+
71
+ ```bash
72
+ py-lean lint
73
+ ```
74
+
75
+ Options:
76
+
77
+ | Flag | Description |
78
+ |------|-------------|
79
+ | `-m, --manifest-path <PATH>` | Path to `pyproject.toml` (default: `pyproject.toml`) |
80
+ | `--format <text\|json>` | Output format (default: `text`) |
81
+ | `-p, --patterns <PATTERN>` | Artefact patterns to check, such as `tests/` |
82
+ | `--dry-run` | Show what would be done without making changes |
83
+ | `--diff` | Show a diff of the proposed changes |
84
+
85
+ Examples:
86
+
87
+ ```bash
88
+ # Lint with JSON output
89
+ py-lean lint --format json
90
+
91
+ # Use a custom manifest path
92
+ py-lean lint -m path/to/pyproject.toml
93
+
94
+ # Check selected artefact patterns
95
+ py-lean lint -p "tests/" -p "benchmarks/"
96
+ ```
97
+
98
+ ### Create
99
+
100
+ Create a `tool.uv.build-backend.source-exclude` configuration containing common repository artefacts:
101
+
102
+ ```bash
103
+ py-lean create
104
+ ```
105
+
106
+ This scans the project for common artefacts, such as `tests/`, `docs/`, and `.git/`, and adds the detected patterns to
107
+ `pyproject.toml`.
108
+
109
+ Options:
110
+
111
+ | Flag | Description |
112
+ |------|-------------|
113
+ | `--dry-run` | Preview changes without modifying files |
114
+ | `--diff` | Show a unified diff of the proposed changes |
115
+
116
+ ### Update
117
+
118
+ Add missing patterns to an existing `source-exclude` configuration:
119
+
120
+ ```bash
121
+ py-lean update
122
+ ```
123
+
124
+ Options:
125
+
126
+ | Flag | Description |
127
+ |------|-------------|
128
+ | `--dry-run` | Preview changes without modifying files |
129
+ | `--diff` | Show a unified diff of the proposed changes |
130
+
131
+ Examples:
132
+
133
+ ```bash
134
+ # Preview changes
135
+ py-lean update --dry-run --diff
136
+
137
+ # Apply changes
138
+ py-lean update
139
+ ```
140
+
141
+ ## Configuration
142
+
143
+ ### Pattern precedence
144
+
145
+ `py-lean` determines which artefact patterns to check using the following precedence:
146
+
147
+ 1. __CLI `--patterns`__ — highest priority; replaces other pattern sources.
148
+ 2. __`tool.uv.build-backend.source-exclude`__ in `pyproject.toml` — patterns already configured for the build backend.
149
+ 3. __Built-in defaults__ — used when neither of the above is configured.
150
+
151
+ Use `--patterns` to override the default patterns:
152
+
153
+ ```bash
154
+ py-lean lint -p "tests/" -p "benchmarks/"
155
+ ```
156
+
157
+ By default, `py-lean` checks a built-in set of common repository artefacts.
158
+
159
+ ## Development
160
+
161
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development instructions.
162
+
163
+ ## License
164
+
165
+ This project is licensed under the MIT License. See [LICENSE.md](LICENSE.md) for details.
166
+
167
+ ## Related projects
168
+
169
+ - [lean-package-validator](https://github.com/raphaelstolt/lean-package-validator) — The `PHP` equivalent
170
+ - [cargo-lean](https://github.com/raphaelstolt/cargo-lean) — The `Rust` equivalent.
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["uv_build"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "py-lean-package"
7
+ version = "0.1.1"
8
+ description = "Detect and fix missing source-exclude patterns in Python projects."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "pydantic>=2.14.1",
13
+ "tomlkit>=0.15.2",
14
+ "typer>=0.27.3",
15
+ ]
16
+
17
+ [project.license]
18
+ text = "MIT"
19
+
20
+ [[project.authors]]
21
+ name = "Raphael Stolt"
22
+ email = "raphael.stolt@gmail.com"
23
+
24
+ [project.scripts]
25
+ py-lean = "py_lean_package.cli:app"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "hypothesis>=6.170.13",
30
+ "pytest>=9.2.0",
31
+ "ruff>=0.17.3",
32
+ ]
33
+
34
+ [tool.ruff]
35
+ target-version = "py311"
36
+ line-length = 120
37
+
38
+ [tool.ruff.lint]
39
+ select = [
40
+ "E",
41
+ "F",
42
+ "I",
43
+ "N",
44
+ "W",
45
+ "UP",
46
+ "B",
47
+ "SIM",
48
+ "RUF",
49
+ ]
50
+ ignore = ["B008"]
51
+
52
+ [tool.uv.build-backend]
53
+ source-exclude = [
54
+ ".github/**",
55
+ "tests/**",
56
+ ".gitignore",
57
+ "CHANGELOG.md",
58
+ "RELEASE.md",
59
+ "CONTRIBUTING.md",
60
+ ]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["uv_build"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "py-lean-package"
7
+ version = "0.1.1"
8
+ description = "Detect and fix missing source-exclude patterns in Python projects."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ {name = "Raphael Stolt", email = "raphael.stolt@gmail.com"},
14
+ ]
15
+ dependencies = [
16
+ "pydantic>=2.14.1",
17
+ "tomlkit>=0.15.2",
18
+ "typer>=0.27.3",
19
+ ]
20
+
21
+ [project.scripts]
22
+ py-lean = "py_lean_package.cli:app"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "hypothesis>=6.170.13",
27
+ "pytest>=9.2.0",
28
+ "ruff>=0.17.3",
29
+ ]
30
+
31
+ [tool.ruff]
32
+ target-version = "py311"
33
+ line-length = 120
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "RUF"]
37
+ ignore = ["B008"]
38
+
39
+ [tool.uv.build-backend]
40
+ source-exclude = [
41
+ ".github/**",
42
+ "tests/**",
43
+ ".gitignore",
44
+ "CHANGELOG.md",
45
+ "RELEASE.md",
46
+ "CONTRIBUTING.md",
47
+ ]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
@@ -0,0 +1,5 @@
1
+ """py-lean: Detect and fix missing source-exclude patterns in Python projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.1"
@@ -0,0 +1,279 @@
1
+ """Core analysis logic for py-lean."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import tomlkit
10
+
11
+ from py_lean_package.models import AnalysisResult, Issue, IssueType, LeanConfig
12
+
13
+
14
+ def read_gitignore(project_root: Path) -> list[str]:
15
+ """Parse a local `.gitignore` file and return normalized ignore patterns.
16
+
17
+ Returns an empty list if the file does not exist or cannot be read.
18
+ Skips blank lines, comments (lines starting with `#`), and negation patterns (starting with `!`).
19
+ Normalizes patterns by stripping leading `/` (which anchors to the repo root) and trailing `/`
20
+ (which indicates a directory-only match).
21
+ """
22
+ gitignore_path = project_root / ".gitignore"
23
+ try:
24
+ content = gitignore_path.read_text(encoding="utf-8")
25
+ except (FileNotFoundError, OSError):
26
+ return []
27
+
28
+ patterns: list[str] = []
29
+ for line in content.splitlines():
30
+ line = line.strip()
31
+ # Skip empty lines, comments, and negation patterns
32
+ if not line or line.startswith("#") or line.startswith("!"):
33
+ continue
34
+ # Normalize: strip leading `/` (root anchor) and trailing `/` (directory indicator)
35
+ line = line.removeprefix("/").removesuffix("/")
36
+ if line:
37
+ patterns.append(line)
38
+ return patterns
39
+
40
+
41
+ def read_global_gitignore() -> list[str]:
42
+ """Read the global gitignore file configured via `core.excludesFile`.
43
+
44
+ Returns an empty list if the config is not set or the file cannot be read.
45
+ Uses the same normalization as `read_gitignore`.
46
+ """
47
+ try:
48
+ result = subprocess.run(
49
+ ["git", "config", "--get", "core.excludesFile"],
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=5,
53
+ )
54
+ if result.returncode != 0:
55
+ return []
56
+ excludes_file = result.stdout.strip()
57
+ if not excludes_file:
58
+ return []
59
+
60
+ content = Path(excludes_file).expanduser().read_text(encoding="utf-8")
61
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
62
+ return []
63
+
64
+ patterns: list[str] = []
65
+ for line in content.splitlines():
66
+ line = line.strip()
67
+ if not line or line.startswith("#") or line.startswith("!"):
68
+ continue
69
+ line = line.removeprefix("/").removesuffix("/")
70
+ if line:
71
+ patterns.append(line)
72
+ return patterns
73
+
74
+
75
+ def is_gitignored(pattern: str, gitignore_entries: list[str]) -> bool:
76
+ """Check if a pattern is already covered by a .gitignore entry.
77
+
78
+ Uses simple string matching and basic glob matching.
79
+ """
80
+ pattern = pattern.rstrip("/")
81
+ for entry in gitignore_entries:
82
+ # Direct match
83
+ if entry == pattern:
84
+ return True
85
+ # Glob-like match
86
+ if fnmatch.fnmatch(pattern, entry):
87
+ return True
88
+ return False
89
+
90
+
91
+ def load_manifest(manifest_path: Path) -> tomlkit.TOMLDocument:
92
+ """Load and parse a pyproject.toml file."""
93
+ if not manifest_path.exists():
94
+ raise FileNotFoundError(f"Manifest not found: {manifest_path}")
95
+ content = manifest_path.read_text(encoding="utf-8")
96
+ return tomlkit.parse(content)
97
+
98
+
99
+ def check_build_system(manifest: tomlkit.TOMLDocument) -> bool:
100
+ """Verify the project uses uv_build as its build backend.
101
+
102
+ Checks for [build-system] with `requires` containing `uv_build` and
103
+ `build-backend` set to `uv_build.build`.
104
+
105
+ Returns:
106
+ True if the build system is correctly configured for uv_build.
107
+ """
108
+ build_system = manifest.get("build-system", {}) or {}
109
+ requires = build_system.get("requires", []) or []
110
+ backend = build_system.get("build-backend", "")
111
+
112
+ has_uv_build_requires = any("uv_build" in str(r) for r in requires)
113
+ has_uv_build_backend = str(backend) == "uv_build.build"
114
+
115
+ return has_uv_build_requires and has_uv_build_backend
116
+
117
+
118
+ def load_config(
119
+ manifest: tomlkit.TOMLDocument | None = None,
120
+ patterns: list[str] | None = None,
121
+ ) -> LeanConfig:
122
+ """Load py-lean configuration with the following precedence:
123
+
124
+ 1. CLI --patterns (highest priority — completely replaces other sources)
125
+ 2. tool.uv.build-backend.source-exclude from pyproject.toml
126
+ 3. DEFAULT_SOURCE_EXCLUDE_PATTERNS (lowest priority — used when nothing is configured)
127
+
128
+ Args:
129
+ manifest: Parsed pyproject.toml document to read source-exclude from.
130
+ patterns: CLI-provided patterns that override all other sources.
131
+
132
+ Returns:
133
+ LeanConfig with patterns set according to the precedence rules above.
134
+ """
135
+ config = LeanConfig()
136
+
137
+ # If CLI patterns are provided, they take full precedence
138
+ if patterns is not None:
139
+ config.patterns = patterns
140
+ return config
141
+
142
+ # Otherwise, try to load from tool.uv.build-backend.source-exclude
143
+ if manifest is not None:
144
+ source_exclude = _get_source_exclude_patterns(manifest)
145
+ if source_exclude:
146
+ config.patterns = source_exclude
147
+
148
+ # If neither CLI nor source-exclude provides patterns, effective_patterns()
149
+ # will fall back to DEFAULT_SOURCE_EXCLUDE_PATTERNS
150
+ return config
151
+
152
+
153
+ def _get_source_exclude_patterns(manifest: tomlkit.TOMLDocument) -> list[str]:
154
+ """Extract source-exclude patterns from tool.uv.build-backend.source-exclude."""
155
+ patterns: list[str] = []
156
+ tool = manifest.get("tool", {})
157
+ if tool is None:
158
+ return patterns
159
+ uv = tool.get("uv", {})
160
+ if uv is None:
161
+ return patterns
162
+ build_backend = uv.get("build-backend", {})
163
+ if build_backend is None:
164
+ return patterns
165
+ source_exclude = build_backend.get("source-exclude", [])
166
+ if source_exclude is not None:
167
+ patterns = [str(p) for p in source_exclude]
168
+ return patterns
169
+
170
+
171
+ def _pattern_covers(pattern: str, exclude_pattern: str) -> bool:
172
+ """Check if a source-exclude pattern covers a given artifact pattern.
173
+
174
+ Both patterns are treated as glob patterns. The exclude_pattern must match
175
+ the artifact pattern or be a parent directory of it.
176
+ """
177
+ # Direct match
178
+ if fnmatch.fnmatch(pattern, exclude_pattern):
179
+ return True
180
+
181
+ # Check if the exclude pattern is a parent directory
182
+ # e.g., exclude "tests" covers "tests/**"
183
+ if pattern.startswith(exclude_pattern + "/"):
184
+ return True
185
+
186
+ # Check if the pattern's top-level component matches
187
+ pattern_top = pattern.split("/")[0]
188
+ exclude_top = exclude_pattern.split("/")[0]
189
+ return bool(fnmatch.fnmatch(pattern_top, exclude_top))
190
+
191
+
192
+ def detect_missing_source_exclude(
193
+ manifest: tomlkit.TOMLDocument,
194
+ project_root: Path,
195
+ config: LeanConfig,
196
+ gitignore_entries: list[str] | None = None,
197
+ ) -> list[Issue]:
198
+ """Detect common repository artefacts that exist but are not in source-exclude.
199
+
200
+ Checks if artefacts listed in the config's patterns (or defaults) exist in the
201
+ project root but are not covered by tool.uv.build-backend.source-exclude.
202
+ Artefacts already covered by .gitignore entries are skipped.
203
+ """
204
+ if gitignore_entries is None:
205
+ gitignore_entries = []
206
+
207
+ issues: list[Issue] = []
208
+ exclude_patterns = _get_source_exclude_patterns(manifest)
209
+ patterns_to_check = config.effective_patterns()
210
+
211
+ for pattern in patterns_to_check:
212
+ # Check if this pattern exists in the project root
213
+ if not _artifact_exists(project_root, pattern):
214
+ continue
215
+
216
+ # Skip if already covered by .gitignore
217
+ if is_gitignored(pattern, gitignore_entries):
218
+ continue
219
+
220
+ # Check if it's covered by any source-exclude pattern
221
+ covered = any(_pattern_covers(pattern, ep) for ep in exclude_patterns)
222
+ if not covered:
223
+ issues.append(
224
+ Issue(
225
+ issue_type=IssueType.MISSING_SOURCE_EXCLUDE,
226
+ subject=pattern,
227
+ description=(
228
+ f"Artefact '{pattern}' exists in the project root but is not listed in "
229
+ f"tool.uv.build-backend.source-exclude."
230
+ ),
231
+ location="pyproject.toml",
232
+ suggestion=f"Add '{pattern}' to tool.uv.build-backend.source-exclude.",
233
+ )
234
+ )
235
+
236
+ return issues
237
+
238
+
239
+ def _artifact_exists(project_root: Path, pattern: str) -> bool:
240
+ """Check if an artifact matching the pattern exists in the project root."""
241
+ # Handle glob patterns like *.egg-info, *.pyc, *.pyo
242
+ if "*" in pattern:
243
+ matches = list(project_root.glob(pattern))
244
+ return len(matches) > 0
245
+
246
+ # Handle directory/file patterns
247
+ path = project_root / pattern
248
+ return path.exists()
249
+
250
+
251
+ def analyze(
252
+ manifest_path: Path,
253
+ patterns: list[str] | None = None,
254
+ ) -> AnalysisResult:
255
+ """Run full analysis on a project.
256
+
257
+ Checks for missing source-exclude patterns for common repository artefacts.
258
+ Artefacts already covered by .gitignore (local or global) are skipped.
259
+
260
+ Args:
261
+ manifest_path: Path to the pyproject.toml file.
262
+ patterns: Optional list of artefact patterns to check for source-exclude.
263
+
264
+ Returns:
265
+ AnalysisResult containing all detected issues.
266
+ """
267
+ project_root = manifest_path.parent
268
+ manifest = load_manifest(manifest_path)
269
+ config = load_config(manifest=manifest, patterns=patterns)
270
+
271
+ # Collect gitignore entries from local .gitignore and global excludesFile
272
+ gitignore_entries: list[str] = []
273
+ gitignore_entries.extend(read_gitignore(project_root))
274
+ gitignore_entries.extend(read_global_gitignore())
275
+
276
+ issues: list[Issue] = []
277
+ issues.extend(detect_missing_source_exclude(manifest, project_root, config, gitignore_entries))
278
+
279
+ return AnalysisResult(manifest_path=manifest_path, issues=issues)
@@ -0,0 +1,358 @@
1
+ """CLI entry point for py-lean."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from difflib import unified_diff
7
+ from pathlib import Path
8
+
9
+ import tomlkit
10
+ import typer
11
+
12
+ from py_lean_package import __version__
13
+ from py_lean_package.analyzer import _get_source_exclude_patterns, analyze, check_build_system, load_manifest
14
+ from py_lean_package.models import DEFAULT_SOURCE_EXCLUDE_PATTERNS, IssueType, OutputFormat
15
+
16
+ app = typer.Typer(
17
+ name="py-lean",
18
+ help="Detect and fix missing source-exclude patterns in Python projects.",
19
+ add_completion=False,
20
+ )
21
+
22
+
23
+ # Common options
24
+ def manifest_path_callback(ctx: typer.Context, value: str) -> Path:
25
+ """Validate and resolve the manifest path."""
26
+ path = Path(value)
27
+ if not path.exists():
28
+ raise typer.Exit(1)
29
+ return path.resolve()
30
+
31
+
32
+ #
33
+ # Commands
34
+ #
35
+
36
+
37
+ @app.command("lint")
38
+ def cmd_lint(
39
+ manifest_path: Path = typer.Option(
40
+ "pyproject.toml",
41
+ "-m",
42
+ "--manifest-path",
43
+ callback=manifest_path_callback,
44
+ help="Path to the pyproject.toml file.",
45
+ ),
46
+ format: OutputFormat = typer.Option(
47
+ OutputFormat.TEXT,
48
+ "--format",
49
+ help="Output format: text or json.",
50
+ ),
51
+ patterns: list[str] | None = typer.Option(
52
+ None,
53
+ "-p",
54
+ "--patterns",
55
+ help="Artefact patterns to check for source-exclude (e.g., tests).",
56
+ ),
57
+ dry_run: bool = typer.Option(
58
+ False,
59
+ "--dry-run",
60
+ help="Show what would be done without making changes.",
61
+ ),
62
+ diff: bool = typer.Option(
63
+ False,
64
+ "--diff",
65
+ help="Show a diff of the changes that would be made.",
66
+ ),
67
+ ) -> None:
68
+ """Analyze a project for missing source-exclude patterns."""
69
+ manifest = load_manifest(manifest_path)
70
+ if not check_build_system(manifest):
71
+ typer.echo(
72
+ "Error: This project is not configured with uv_build as its build backend.\n\n"
73
+ "py-lean requires the following in pyproject.toml:\n\n"
74
+ " [build-system]\n"
75
+ ' requires = ["uv_build"]\n'
76
+ ' build-backend = "uv_build.build"',
77
+ err=True,
78
+ )
79
+ raise typer.Exit(1)
80
+ result = analyze(manifest_path, patterns)
81
+
82
+ if format == OutputFormat.JSON:
83
+ typer.echo(json.dumps(result.to_dict(), indent=2))
84
+ else:
85
+ if result.is_clean:
86
+ typer.echo("✓ No issues found.")
87
+ else:
88
+ _print_issues(result.issues)
89
+ typer.echo(f"\nFound {len(result.issues)} issue(s).", err=True)
90
+ raise typer.Exit(1)
91
+
92
+ if dry_run:
93
+ typer.echo("(dry-run: no changes made)")
94
+ if diff:
95
+ _show_diff(manifest_path, result.issues)
96
+
97
+
98
+ @app.command("create")
99
+ def cmd_create(
100
+ manifest_path: Path = typer.Option(
101
+ "pyproject.toml",
102
+ "-m",
103
+ "--manifest-path",
104
+ callback=manifest_path_callback,
105
+ help="Path to the pyproject.toml file.",
106
+ ),
107
+ dry_run: bool = typer.Option(
108
+ False,
109
+ "--dry-run",
110
+ help="Show what would be done without making changes.",
111
+ ),
112
+ diff: bool = typer.Option(
113
+ False,
114
+ "--diff",
115
+ help="Show a diff of the changes that would be made.",
116
+ ),
117
+ ) -> None:
118
+ """Add source-exclude patterns for repository artefacts to tool.uv.build-backend in pyproject.toml.
119
+
120
+ Merges configured patterns with built-in defaults and validates against the Git repository.
121
+ Only adds patterns for artefacts that actually exist in the project.
122
+ """
123
+ manifest = load_manifest(manifest_path)
124
+ if not check_build_system(manifest):
125
+ typer.echo(
126
+ "Error: This project is not configured with uv_build as its build backend.\n\n"
127
+ "py-lean requires the following in pyproject.toml:\n\n"
128
+ " [build-system]\n"
129
+ ' requires = ["uv_build"]\n'
130
+ ' build-backend = "uv_build.build"',
131
+ err=True,
132
+ )
133
+ raise typer.Exit(1)
134
+ # Merge configured patterns with built-in defaults
135
+ manifest = tomlkit.parse(manifest_path.read_text(encoding="utf-8"))
136
+ current_excludes = _get_source_exclude_patterns(manifest)
137
+ merged_patterns = list(current_excludes)
138
+ for pattern in DEFAULT_SOURCE_EXCLUDE_PATTERNS:
139
+ if pattern not in merged_patterns:
140
+ merged_patterns.append(pattern)
141
+
142
+ # Analyze with merged patterns to find which artefacts exist in the repo
143
+ result = analyze(manifest_path, merged_patterns)
144
+
145
+ if result.is_clean:
146
+ typer.echo("✓ No missing source-exclude patterns to add.")
147
+ return
148
+
149
+ original_content = manifest_path.read_text(encoding="utf-8")
150
+ manifest = tomlkit.parse(original_content)
151
+
152
+ # Ensure tool.uv.build-backend section exists
153
+ if "tool" not in manifest:
154
+ manifest["tool"] = {}
155
+ if "uv" not in manifest["tool"]:
156
+ manifest["tool"]["uv"] = {}
157
+ if "build-backend" not in manifest["tool"]["uv"]:
158
+ manifest["tool"]["uv"]["build-backend"] = {}
159
+
160
+ bb = manifest["tool"]["uv"]["build-backend"]
161
+ current = list(bb.get("source-exclude", []))
162
+
163
+ # Add only the missing patterns that exist in the repo
164
+ added = 0
165
+ for issue in result.issues:
166
+ if issue.issue_type == IssueType.MISSING_SOURCE_EXCLUDE:
167
+ pattern = issue.subject
168
+ if pattern not in current:
169
+ current.append(pattern)
170
+ added += 1
171
+
172
+ bb["source-exclude"] = current
173
+ new_content = tomlkit.dumps(manifest)
174
+
175
+ if dry_run or diff:
176
+ if diff:
177
+ _print_diff(original_content, new_content, str(manifest_path))
178
+ if dry_run:
179
+ typer.echo("(dry-run: no changes made)")
180
+ return
181
+
182
+ manifest_path.write_text(new_content, encoding="utf-8")
183
+ typer.echo(f"Added {added} source-exclude pattern(s) to {manifest_path}")
184
+
185
+
186
+ @app.command("update")
187
+ def cmd_update(
188
+ manifest_path: Path = typer.Option(
189
+ "pyproject.toml",
190
+ "-m",
191
+ "--manifest-path",
192
+ callback=manifest_path_callback,
193
+ help="Path to the pyproject.toml file.",
194
+ ),
195
+ dry_run: bool = typer.Option(
196
+ False,
197
+ "--dry-run",
198
+ help="Show what would be done without making changes.",
199
+ ),
200
+ diff: bool = typer.Option(
201
+ False,
202
+ "--diff",
203
+ help="Show a diff of the changes that would be made.",
204
+ ),
205
+ ) -> None:
206
+ """Merge configured patterns with built-in defaults and update source-exclude in pyproject.toml.
207
+
208
+ Validates against the Git repository — only adds patterns for artefacts that actually exist.
209
+ """
210
+ manifest = load_manifest(manifest_path)
211
+ if not check_build_system(manifest):
212
+ typer.echo(
213
+ "Error: This project is not configured with uv_build as its build backend.\n\n"
214
+ "py-lean requires the following in pyproject.toml:\n\n"
215
+ " [build-system]\n"
216
+ ' requires = ["uv_build"]\n'
217
+ ' build-backend = "uv_build.build"',
218
+ err=True,
219
+ )
220
+ raise typer.Exit(1)
221
+ # Merge configured patterns with built-in defaults
222
+ manifest = tomlkit.parse(manifest_path.read_text(encoding="utf-8"))
223
+ current_excludes = _get_source_exclude_patterns(manifest)
224
+ merged_patterns = list(current_excludes)
225
+ for pattern in DEFAULT_SOURCE_EXCLUDE_PATTERNS:
226
+ if pattern not in merged_patterns:
227
+ merged_patterns.append(pattern)
228
+
229
+ # Analyze with merged patterns to find which artefacts exist in the repo
230
+ result = analyze(manifest_path, merged_patterns)
231
+
232
+ if result.is_clean:
233
+ typer.echo("✓ No missing source-exclude patterns to add.")
234
+ return
235
+
236
+ original_content = manifest_path.read_text(encoding="utf-8")
237
+ manifest = tomlkit.parse(original_content)
238
+
239
+ # Ensure tool.uv.build-backend section exists
240
+ if "tool" not in manifest:
241
+ manifest["tool"] = {}
242
+ if "uv" not in manifest["tool"]:
243
+ manifest["tool"]["uv"] = {}
244
+ if "build-backend" not in manifest["tool"]["uv"]:
245
+ manifest["tool"]["uv"]["build-backend"] = {}
246
+
247
+ bb = manifest["tool"]["uv"]["build-backend"]
248
+ current = list(bb.get("source-exclude", []))
249
+
250
+ # Add only the missing patterns that exist in the repo
251
+ added = 0
252
+ for issue in result.issues:
253
+ if issue.issue_type == IssueType.MISSING_SOURCE_EXCLUDE:
254
+ pattern = issue.subject
255
+ if pattern not in current:
256
+ current.append(pattern)
257
+ added += 1
258
+
259
+ bb["source-exclude"] = current
260
+ new_content = tomlkit.dumps(manifest)
261
+
262
+ if dry_run or diff:
263
+ if diff:
264
+ _print_diff(original_content, new_content, str(manifest_path))
265
+ if dry_run:
266
+ typer.echo("(dry-run: no changes made)")
267
+ return
268
+
269
+ manifest_path.write_text(new_content, encoding="utf-8")
270
+ typer.echo(f"Added {added} source-exclude pattern(s) to {manifest_path}")
271
+
272
+
273
+ #
274
+ # Helpers
275
+ #
276
+
277
+
278
+ def _print_issues(issues: list) -> None:
279
+ """Print issues in a human-readable format."""
280
+ for issue in issues:
281
+ icon = _issue_icon(issue.issue_type)
282
+ typer.echo(f"{icon} {issue.subject}")
283
+ typer.echo(f" {issue.description}")
284
+ if issue.suggestion:
285
+ typer.echo(f" → {issue.suggestion}")
286
+ typer.echo()
287
+
288
+
289
+ def _issue_icon(issue_type: IssueType) -> str:
290
+ """Get a display icon for an issue type."""
291
+ icons = {
292
+ IssueType.MISSING_SOURCE_EXCLUDE: "✕",
293
+ }
294
+ return icons.get(issue_type, "?")
295
+
296
+
297
+ def _print_diff(old: str, new: str, path: str) -> None:
298
+ """Print a unified diff between old and new content."""
299
+ old_lines = old.splitlines(keepends=True)
300
+ new_lines = new.splitlines(keepends=True)
301
+
302
+ diff_lines = unified_diff(old_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{path}")
303
+ for line in diff_lines:
304
+ typer.echo(line, nl=False)
305
+
306
+
307
+ def _show_diff(manifest_path: Path, issues: list) -> None:
308
+ """Show what changes would be made to fix the issues."""
309
+ original_content = manifest_path.read_text(encoding="utf-8")
310
+ manifest = tomlkit.parse(original_content)
311
+
312
+ # Handle missing source-exclude
313
+ missing_excludes = [i for i in issues if i.issue_type == IssueType.MISSING_SOURCE_EXCLUDE]
314
+ if missing_excludes:
315
+ if "tool" not in manifest:
316
+ manifest["tool"] = {}
317
+ if "uv" not in manifest["tool"]:
318
+ manifest["tool"]["uv"] = {}
319
+ if "build-backend" not in manifest["tool"]["uv"]:
320
+ manifest["tool"]["uv"]["build-backend"] = {}
321
+
322
+ bb = manifest["tool"]["uv"]["build-backend"]
323
+ current_excludes = list(bb.get("source-exclude", []))
324
+ for issue in missing_excludes:
325
+ if issue.subject not in current_excludes:
326
+ current_excludes.append(issue.subject)
327
+ if current_excludes:
328
+ bb["source-exclude"] = current_excludes
329
+
330
+ new_content = tomlkit.dumps(manifest)
331
+ if new_content != original_content:
332
+ _print_diff(original_content, new_content, str(manifest_path))
333
+
334
+
335
+ def version_callback(value: bool) -> None:
336
+ """Print the version and exit."""
337
+ if value:
338
+ typer.echo(f"py-lean {__version__}")
339
+ raise typer.Exit()
340
+
341
+
342
+ @app.callback()
343
+ def main(
344
+ _version: bool = typer.Option(
345
+ False,
346
+ "--version",
347
+ "-V",
348
+ callback=version_callback,
349
+ is_eager=True,
350
+ help="Show the version and exit.",
351
+ ),
352
+ ) -> None:
353
+ """py-lean: Detect and fix missing source-exclude patterns in Python projects."""
354
+ pass
355
+
356
+
357
+ if __name__ == "__main__":
358
+ app()
@@ -0,0 +1,110 @@
1
+ """Data models for py-lean."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+
9
+
10
+ class IssueType(StrEnum):
11
+ """Types of issues that py-lean can detect."""
12
+
13
+ MISSING_SOURCE_EXCLUDE = "missing_source_exclude"
14
+
15
+
16
+ class OutputFormat(StrEnum):
17
+ """Output format for py-lean results."""
18
+
19
+ TEXT = "text"
20
+ JSON = "json"
21
+
22
+
23
+ @dataclass
24
+ class Issue:
25
+ """A single issue detected by py-lean."""
26
+
27
+ issue_type: IssueType
28
+ subject: str
29
+ description: str
30
+ location: str | None = None
31
+ suggestion: str | None = None
32
+
33
+ def to_dict(self) -> dict:
34
+ """Convert the issue to a dictionary for JSON serialization."""
35
+ result: dict = {
36
+ "type": self.issue_type.value,
37
+ "subject": self.subject,
38
+ "description": self.description,
39
+ }
40
+ if self.location:
41
+ result["location"] = self.location
42
+ if self.suggestion:
43
+ result["suggestion"] = self.suggestion
44
+ return result
45
+
46
+
47
+ @dataclass
48
+ class AnalysisResult:
49
+ """Result of analyzing a project."""
50
+
51
+ manifest_path: Path
52
+ issues: list[Issue] = field(default_factory=list)
53
+
54
+ @property
55
+ def is_clean(self) -> bool:
56
+ """Return True if no issues were found."""
57
+ return len(self.issues) == 0
58
+
59
+ def to_dict(self) -> dict:
60
+ """Convert the result to a dictionary for JSON serialization."""
61
+ return {
62
+ "manifest_path": str(self.manifest_path),
63
+ "issues": [issue.to_dict() for issue in self.issues],
64
+ }
65
+
66
+
67
+ # Default artifact patterns that should typically be excluded from builds.
68
+ # These are common repository artefacts that should not end up in wheels or sdists.
69
+ DEFAULT_SOURCE_EXCLUDE_PATTERNS: list[str] = [
70
+ "tests",
71
+ "docs",
72
+ ".venv",
73
+ "__pycache__",
74
+ "*.egg-info",
75
+ ".tox",
76
+ ".mypy_cache",
77
+ ".pytest_cache",
78
+ "dist",
79
+ "build",
80
+ ".github",
81
+ ".idea",
82
+ ".vscode",
83
+ ".vs",
84
+ ".DS_Store",
85
+ "*.pyc",
86
+ "*.pyo",
87
+ ".coverage",
88
+ "htmlcov",
89
+ ".ruff_cache",
90
+ ]
91
+
92
+
93
+ @dataclass
94
+ class LeanConfig:
95
+ """Configuration for py-lean.
96
+
97
+ Patterns are determined by the following precedence:
98
+ 1. CLI --patterns (highest priority)
99
+ 2. tool.uv.build-backend.source-exclude from pyproject.toml
100
+ 3. Built-in defaults (DEFAULT_SOURCE_EXCLUDE_PATTERNS)
101
+ """
102
+
103
+ # Source exclude patterns to check for (defaults to DEFAULT_SOURCE_EXCLUDE_PATTERNS)
104
+ patterns: list[str] = field(default_factory=list)
105
+
106
+ def effective_patterns(self) -> list[str]:
107
+ """Return the patterns to use, falling back to defaults if none configured."""
108
+ if self.patterns:
109
+ return self.patterns
110
+ return list(DEFAULT_SOURCE_EXCLUDE_PATTERNS)