pymodularity 0.2.0__tar.gz → 0.3.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.
Files changed (28) hide show
  1. pymodularity-0.3.1/PKG-INFO +112 -0
  2. pymodularity-0.3.1/README.md +100 -0
  3. {pymodularity-0.2.0 → pymodularity-0.3.1}/pyproject.toml +11 -6
  4. {pymodularity-0.2.0 → pymodularity-0.3.1}/pyproject.toml.orig +17 -6
  5. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_check.py +25 -7
  6. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_deps.py +64 -4
  7. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_metrics.py +5 -3
  8. pymodularity-0.3.1/src/pymodularity/_parse/_resolve.py +175 -0
  9. pymodularity-0.3.1/src/pymodularity/_parse/attrs.py +144 -0
  10. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_parse/graph.py +108 -94
  11. pymodularity-0.3.1/src/pymodularity/_parse/imports.py +179 -0
  12. pymodularity-0.3.1/src/pymodularity/_parse/packages.py +94 -0
  13. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_structure.py +5 -4
  14. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/cli.py +95 -27
  15. pymodularity-0.2.0/PKG-INFO +0 -85
  16. pymodularity-0.2.0/README.md +0 -73
  17. pymodularity-0.2.0/src/pymodularity/_parse/_resolve.py +0 -225
  18. pymodularity-0.2.0/src/pymodularity/_parse/packages.py +0 -32
  19. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/__init__.py +0 -0
  20. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/__main__.py +0 -0
  21. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_cycles.py +0 -0
  22. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_parse/__init__.py +0 -0
  23. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_parse/names.py +0 -0
  24. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_scores.py +0 -0
  25. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_view_edges.py +0 -0
  26. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_views/__init__.py +0 -0
  27. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_views/_summary.py +0 -0
  28. {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_views/render.py +0 -0
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymodularity
3
+ Version: 0.3.1
4
+ Summary: See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code.
5
+ Author: Tilo Himmelsbach
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Project-URL: Homepage, https://github.com/dertilo/pymodularity
9
+ Project-URL: Repository, https://github.com/dertilo/pymodularity
10
+ Project-URL: Issues, https://github.com/dertilo/pymodularity/issues
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pymodularity
14
+
15
+ The underscore's position in a Python import path sets the import's scope.
16
+ model: Rust's `pub(super)` and `pub(crate)`, applied to PEP 8 underscores.
17
+
18
+ `pymodularity` does 3 jobs on a repo's real Python imports:
19
+
20
+ - it draws them as mermaid diagrams.
21
+ - it checks them against conventions derived from the code itself, with no allow-list to maintain.
22
+ - it reports which files and directories carry the most structural coupling.
23
+
24
+ The analysis needs only the standard library, so `pymodularity` has no runtime dependencies.
25
+ exception: `check`'s dependency rule needs `uv` on PATH.
26
+
27
+ ## Install and run
28
+
29
+ ```bash
30
+ uvx pymodularity check .
31
+ uvx pymodularity graph . --out docs/imports
32
+ uvx pymodularity metrics .
33
+ ```
34
+
35
+ Or, inside a project: `uv add --dev pymodularity` and run `pymodularity ...` in that project's venv.
36
+
37
+ ## The rules `check` enforces
38
+
39
+ `pymodularity check .` exits 1 on any finding.
40
+ Each finding names the importing file, the target and the owner.
41
+
42
+ - **No import cycles:** between files, and between directories at every depth.
43
+ - `TYPE_CHECKING` imports and in-function imports count.
44
+ - no baseline: an existing cycle fails too.
45
+ - **Private items stay inside their owner:** only code inside the owner may import a `_`-prefixed module or name.
46
+ - private module: the owner is the directory holding it.
47
+ - private name from a package: the owner is the package directory.
48
+ - private name from a regular file: the owner is that file alone, so even a sibling file is a finding.
49
+ - attribute access such as `mod._x` counts like an import.
50
+ - **Declared dependencies match the imports:** [deptry](https://github.com/fpgmaas/deptry) reports unused, missing and transitive dependencies per package root.
51
+ - requirement: `uv` on PATH. Without it, `check` exits 2.
52
+ - `--no-deps` skips this rule.
53
+ - **Structure limits:** at most 500 lines per file, 10 entries per directory, 100 files per package root and 10 public names per file.
54
+ - no config, no baseline.
55
+ - ceiling: a limit marks the size that is clearly too much. It is no target.
56
+ - below a limit: a smaller interface is still better. `metrics` reports interface width and exposure share.
57
+
58
+ Worked cases: the [showcase](tests/showcase/README.md) shows each rule except the dependency rule as allowed and finding cases.
59
+ Definitions: [docs/rules.md](docs/rules.md) holds the counting rules, the deptry run, and the ruff rules for functions and classes.
60
+
61
+ ## The 3 subcommands
62
+
63
+ All 3 also take a repeatable `--exclude PATTERN`, merged with `[tool.pymodularity] exclude` in `<repo>`'s own pyproject.toml.
64
+ Pattern: `fnmatch` against the POSIX path relative to `<repo>`, matched on the path itself or any parent directory.
65
+ Use case: generated code (an OpenAPI client, protobuf stubs) and vendored code.
66
+ Effect: an excluded tree is invisible to every rule and report, except deptry's scan of a kept package that contains it.
67
+ An import from kept code into it behaves like an import of code `pymodularity` can't resolve.
68
+ Details: [docs/rules.md](docs/rules.md#excluding-code).
69
+
70
+ - `pymodularity check <repo> [--no-deps]`: runs the 4 rules above and prints the findings.
71
+ - exit code: 1 on any finding.
72
+ - `--no-deps`: skips the dependency rule.
73
+ - `pymodularity graph <repo> --out DIR [--html]`: writes one mermaid view per directory under `DIR`.
74
+ - `index.md`: written for every view.
75
+ - `--html`: adds a clickable `index.html` per view. The browser needs network access to load mermaid from a CDN.
76
+ - `pymodularity metrics <repo>`: prints a coupling report to stdout.
77
+ - reach: transitive dependents and dependencies, plus a `score` per file and per directory.
78
+ - interface width: the names other code imports from a unit.
79
+ - exposure share: how much of what a file defines other code uses.
80
+ - entry files: how much of a directory outside code reaches into.
81
+ - misplaced files: files whose closest partners sit mostly in another directory.
82
+ - exit code: always 0, because the report is no gate.
83
+
84
+ ## Blind spots
85
+
86
+ Worked cases: the [showcase](tests/showcase/README.md) gap cases G1 to G8, each tagged with its outcome.
87
+
88
+ `pymodularity` shows import coupling only.
89
+ Import coupling is a lower bound on logical coupling: "if A changes, must B change?"
90
+ Coupling that bypasses Python imports stays invisible:
91
+
92
+ - HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
93
+ - Two files with no edge can still force each other's edits. **Never read a missing edge as independence.**
94
+
95
+ Other limits:
96
+
97
+ - ambiguous import: `check` prints `warning: ambiguous import` to stderr and draws no edge.
98
+ - cause: several [import roots](docs/internals.md#how-an-absolute-import-resolves-to-a-file) provide the module, and none is an ancestor of the importer.
99
+ - runtime `sys.path` edit: an import that resolves only through such an edit gets an edge only if the inserted directory is an import root.
100
+ - script sibling: `python main.py` importing its sibling `execution.py`, with no package root nearby, gets no edge.
101
+ - reason: resolution looks only through import roots, never through the importer's own directory.
102
+ - tests: `pymodularity` always skips test directories (`tests/`, `test/`) and test files (`test_*.py`, `*_test.py`, `conftest.py`).
103
+ - reason: a test importing a sibling would draw a production dependency that production never has.
104
+ - `metrics` as a gate: its numbers move even on a repo that passes `check`.
105
+ - consequence: a gate on them rewards gaming the number, for example replacing an import with a string-keyed registry.
106
+ - attribute form, missed cases:
107
+ - an instance attribute such as `obj._y`, because `pymodularity` has no type information.
108
+ - complement for that case: ruff's `SLF001`. It has no owner scope, so it also flags access from inside the owner.
109
+ - a base name bound to a third-party module, or to a module outside the repo.
110
+ - `getattr(mod, "_x")` and other `importlib` access.
111
+ - a name the file also rebinds anywhere: a parameter, an assignment target, a `for`, `with`, `except` or comprehension target, a nested `def` or `class`.
112
+ - reason for the last case: a gate must trade false findings for missed ones, so `pymodularity` skips such a name.
@@ -0,0 +1,100 @@
1
+ # pymodularity
2
+
3
+ The underscore's position in a Python import path sets the import's scope.
4
+ model: Rust's `pub(super)` and `pub(crate)`, applied to PEP 8 underscores.
5
+
6
+ `pymodularity` does 3 jobs on a repo's real Python imports:
7
+
8
+ - it draws them as mermaid diagrams.
9
+ - it checks them against conventions derived from the code itself, with no allow-list to maintain.
10
+ - it reports which files and directories carry the most structural coupling.
11
+
12
+ The analysis needs only the standard library, so `pymodularity` has no runtime dependencies.
13
+ exception: `check`'s dependency rule needs `uv` on PATH.
14
+
15
+ ## Install and run
16
+
17
+ ```bash
18
+ uvx pymodularity check .
19
+ uvx pymodularity graph . --out docs/imports
20
+ uvx pymodularity metrics .
21
+ ```
22
+
23
+ Or, inside a project: `uv add --dev pymodularity` and run `pymodularity ...` in that project's venv.
24
+
25
+ ## The rules `check` enforces
26
+
27
+ `pymodularity check .` exits 1 on any finding.
28
+ Each finding names the importing file, the target and the owner.
29
+
30
+ - **No import cycles:** between files, and between directories at every depth.
31
+ - `TYPE_CHECKING` imports and in-function imports count.
32
+ - no baseline: an existing cycle fails too.
33
+ - **Private items stay inside their owner:** only code inside the owner may import a `_`-prefixed module or name.
34
+ - private module: the owner is the directory holding it.
35
+ - private name from a package: the owner is the package directory.
36
+ - private name from a regular file: the owner is that file alone, so even a sibling file is a finding.
37
+ - attribute access such as `mod._x` counts like an import.
38
+ - **Declared dependencies match the imports:** [deptry](https://github.com/fpgmaas/deptry) reports unused, missing and transitive dependencies per package root.
39
+ - requirement: `uv` on PATH. Without it, `check` exits 2.
40
+ - `--no-deps` skips this rule.
41
+ - **Structure limits:** at most 500 lines per file, 10 entries per directory, 100 files per package root and 10 public names per file.
42
+ - no config, no baseline.
43
+ - ceiling: a limit marks the size that is clearly too much. It is no target.
44
+ - below a limit: a smaller interface is still better. `metrics` reports interface width and exposure share.
45
+
46
+ Worked cases: the [showcase](tests/showcase/README.md) shows each rule except the dependency rule as allowed and finding cases.
47
+ Definitions: [docs/rules.md](docs/rules.md) holds the counting rules, the deptry run, and the ruff rules for functions and classes.
48
+
49
+ ## The 3 subcommands
50
+
51
+ All 3 also take a repeatable `--exclude PATTERN`, merged with `[tool.pymodularity] exclude` in `<repo>`'s own pyproject.toml.
52
+ Pattern: `fnmatch` against the POSIX path relative to `<repo>`, matched on the path itself or any parent directory.
53
+ Use case: generated code (an OpenAPI client, protobuf stubs) and vendored code.
54
+ Effect: an excluded tree is invisible to every rule and report, except deptry's scan of a kept package that contains it.
55
+ An import from kept code into it behaves like an import of code `pymodularity` can't resolve.
56
+ Details: [docs/rules.md](docs/rules.md#excluding-code).
57
+
58
+ - `pymodularity check <repo> [--no-deps]`: runs the 4 rules above and prints the findings.
59
+ - exit code: 1 on any finding.
60
+ - `--no-deps`: skips the dependency rule.
61
+ - `pymodularity graph <repo> --out DIR [--html]`: writes one mermaid view per directory under `DIR`.
62
+ - `index.md`: written for every view.
63
+ - `--html`: adds a clickable `index.html` per view. The browser needs network access to load mermaid from a CDN.
64
+ - `pymodularity metrics <repo>`: prints a coupling report to stdout.
65
+ - reach: transitive dependents and dependencies, plus a `score` per file and per directory.
66
+ - interface width: the names other code imports from a unit.
67
+ - exposure share: how much of what a file defines other code uses.
68
+ - entry files: how much of a directory outside code reaches into.
69
+ - misplaced files: files whose closest partners sit mostly in another directory.
70
+ - exit code: always 0, because the report is no gate.
71
+
72
+ ## Blind spots
73
+
74
+ Worked cases: the [showcase](tests/showcase/README.md) gap cases G1 to G8, each tagged with its outcome.
75
+
76
+ `pymodularity` shows import coupling only.
77
+ Import coupling is a lower bound on logical coupling: "if A changes, must B change?"
78
+ Coupling that bypasses Python imports stays invisible:
79
+
80
+ - HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
81
+ - Two files with no edge can still force each other's edits. **Never read a missing edge as independence.**
82
+
83
+ Other limits:
84
+
85
+ - ambiguous import: `check` prints `warning: ambiguous import` to stderr and draws no edge.
86
+ - cause: several [import roots](docs/internals.md#how-an-absolute-import-resolves-to-a-file) provide the module, and none is an ancestor of the importer.
87
+ - runtime `sys.path` edit: an import that resolves only through such an edit gets an edge only if the inserted directory is an import root.
88
+ - script sibling: `python main.py` importing its sibling `execution.py`, with no package root nearby, gets no edge.
89
+ - reason: resolution looks only through import roots, never through the importer's own directory.
90
+ - tests: `pymodularity` always skips test directories (`tests/`, `test/`) and test files (`test_*.py`, `*_test.py`, `conftest.py`).
91
+ - reason: a test importing a sibling would draw a production dependency that production never has.
92
+ - `metrics` as a gate: its numbers move even on a repo that passes `check`.
93
+ - consequence: a gate on them rewards gaming the number, for example replacing an import with a string-keyed registry.
94
+ - attribute form, missed cases:
95
+ - an instance attribute such as `obj._y`, because `pymodularity` has no type information.
96
+ - complement for that case: ruff's `SLF001`. It has no owner scope, so it also flags access from inside the owner.
97
+ - a base name bound to a third-party module, or to a module outside the repo.
98
+ - `getattr(mod, "_x")` and other `importlib` access.
99
+ - a name the file also rebinds anywhere: a parameter, an assignment target, a `for`, `with`, `except` or comprehension target, a nested `def` or `class`.
100
+ - reason for the last case: a gate must trade false findings for missed ones, so `pymodularity` skips such a name.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymodularity"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -22,22 +22,27 @@ pymodularity = "pymodularity.cli:main"
22
22
  dev = [
23
23
  "pytest",
24
24
  "ruff",
25
- "basedpyright",
25
+ "ty==0.0.90",
26
26
  ]
27
27
 
28
28
  [build-system]
29
29
  requires = ["uv_build>=0.10.7,<0.11.0"]
30
30
  build-backend = "uv_build"
31
31
 
32
+ [tool.pytest.ini_options]
33
+ pythonpath = ["tests"]
34
+
32
35
  [tool.ruff]
33
36
  line-length = 90
34
37
  target-version = "py311"
38
+ exclude = ["tests/showcase"]
39
+
40
+ [tool.ty.environment]
41
+ python-version = "3.11"
35
42
 
36
- [tool.basedpyright]
43
+ [tool.ty.src]
37
44
  include = [
38
45
  "src",
39
46
  "tests",
40
47
  ]
41
- pythonVersion = "3.11"
42
- reportUnusedCallResult = false
43
- reportAny = false
48
+ exclude = ["tests/showcase"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymodularity"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -17,18 +17,29 @@ Issues = "https://github.com/dertilo/pymodularity/issues"
17
17
  pymodularity = "pymodularity.cli:main"
18
18
 
19
19
  [dependency-groups]
20
- dev = ["pytest", "ruff", "basedpyright"]
20
+ dev = [
21
+ "pytest",
22
+ "ruff",
23
+ "ty==0.0.90",
24
+ ]
21
25
 
22
26
  [build-system]
23
27
  requires = ["uv_build>=0.10.7,<0.11.0"]
24
28
  build-backend = "uv_build"
25
29
 
30
+ [tool.pytest.ini_options]
31
+ pythonpath = ["tests"]
32
+
26
33
  [tool.ruff]
27
34
  line-length = 90
28
35
  target-version = "py311"
36
+ # a static fixture repo, not real project code: committed golden-test input
37
+ exclude = ["tests/showcase"]
38
+
39
+ [tool.ty.environment]
40
+ python-version = "3.11"
29
41
 
30
- [tool.basedpyright]
42
+ [tool.ty.src]
31
43
  include = ["src", "tests"]
32
- pythonVersion = "3.11"
33
- reportUnusedCallResult = false
34
- reportAny = false
44
+ # a static fixture repo, not real project code: committed golden-test input
45
+ exclude = ["tests/showcase"]
@@ -34,15 +34,22 @@ def _visibility_scope(rel: Path, package_roots: Sequence[Path]) -> Path:
34
34
  @dataclass(frozen=True, slots=True)
35
35
  class _PrivacyViolation:
36
36
  """An import that reaches a module private to a directory it is not
37
- under."""
37
+ under. `via_attribute` marks a module reached through an attribute
38
+ chain (`import a; a._internal.x`) rather than an import statement
39
+ itself -- same scope rule, same `find_privacy_violations`, called a
40
+ second time on the attribute-form edges `_parse.graph` collects."""
38
41
 
39
42
  importer: Path
40
43
  target: Path
41
44
  owner: Path
45
+ via_attribute: bool = False
42
46
 
43
47
 
44
48
  def find_privacy_violations(
45
- edge_names: EdgeNames, package_roots: Sequence[Path]
49
+ edge_names: EdgeNames,
50
+ package_roots: Sequence[Path],
51
+ *,
52
+ via_attribute: bool = False,
46
53
  ) -> tuple[_PrivacyViolation, ...]:
47
54
  violations: list[_PrivacyViolation] = []
48
55
  scope_cache: dict[Path, Path] = {}
@@ -56,7 +63,9 @@ def find_privacy_violations(
56
63
  scope = scope_of(target_rel)
57
64
  if importer_rel.is_relative_to(scope):
58
65
  continue
59
- violations.append(_PrivacyViolation(importer_rel, target_rel, scope))
66
+ violations.append(
67
+ _PrivacyViolation(importer_rel, target_rel, scope, via_attribute)
68
+ )
60
69
 
61
70
  violations.sort(key=lambda v: (str(v.importer), str(v.target)))
62
71
  return tuple(violations)
@@ -84,16 +93,22 @@ def _name_owner(target_rel: Path) -> _NameOwner:
84
93
  @dataclass(frozen=True, slots=True)
85
94
  class _NameViolation:
86
95
  """An import of a private name (`from m import _x`) that reaches
87
- outside the directory (package) or file that name is private to."""
96
+ outside the directory (package) or file that name is private to.
97
+ `via_attribute` marks a name reached as `mod._x` rather than imported --
98
+ same scope rule, same `find_name_violations`, called a second time on
99
+ the attribute-form edges `_parse.graph` collects."""
88
100
 
89
101
  importer: Path
90
102
  target: Path
91
103
  name: str
92
104
  owner: Path
93
105
  owner_is_dir: bool
106
+ via_attribute: bool = False
94
107
 
95
108
 
96
- def find_name_violations(edge_names: EdgeNames) -> tuple[_NameViolation, ...]:
109
+ def find_name_violations(
110
+ edge_names: EdgeNames, *, via_attribute: bool = False
111
+ ) -> tuple[_NameViolation, ...]:
97
112
  violations: list[_NameViolation] = []
98
113
  for (importer_rel, target_rel), names in edge_names.items():
99
114
  for name in names:
@@ -109,6 +124,7 @@ def find_name_violations(edge_names: EdgeNames) -> tuple[_NameViolation, ...]:
109
124
  name,
110
125
  owner_info.owner,
111
126
  owner_info.owner_is_dir,
127
+ via_attribute,
112
128
  )
113
129
  )
114
130
  violations.sort(key=lambda v: (str(v.importer), str(v.target), v.name))
@@ -149,12 +165,14 @@ def print_check_findings(
149
165
  total_private = len(privacy_violations) + len(name_violations)
150
166
  print(f"## Private-item violations: {total_private} finding(s)")
151
167
  for v in privacy_violations:
152
- print(f"- {v.importer} -> {v.target}: allowed only inside {v.owner}")
168
+ suffix = " (via attribute access)" if v.via_attribute else ""
169
+ print(f"- {v.importer} -> {v.target}: allowed only inside {v.owner}{suffix}")
153
170
  for nv in name_violations:
154
171
  scope = "directory" if nv.owner_is_dir else "file"
172
+ suffix = " (via attribute access)" if nv.via_attribute else ""
155
173
  print(
156
174
  f"- {nv.importer} -> {nv.target}: name {nv.name} private to "
157
- + f"{scope} {nv.owner}"
175
+ + f"{scope} {nv.owner}{suffix}"
158
176
  )
159
177
  print()
160
178
 
@@ -22,7 +22,7 @@ from dataclasses import dataclass, field
22
22
  from pathlib import Path
23
23
  from typing import cast
24
24
 
25
- from pymodularity._parse.packages import find_package_roots
25
+ from pymodularity._parse.packages import find_package_roots, should_skip_dir
26
26
 
27
27
  _DEPTRY_VERSION = "0.25.1"
28
28
 
@@ -85,6 +85,59 @@ def _has_runtime_dependencies(pyproject: Path) -> bool:
85
85
  return isinstance(deps, list) and len(cast(list[object], deps)) > 0
86
86
 
87
87
 
88
+ def _top_level_package_names(repo: Path, pkg: Path) -> tuple[str, ...]:
89
+ """Top-level first-party package/module names deptry should be told
90
+ about explicitly: deptry does not treat a hatch-autodetected src-layout
91
+ package (no explicit `[tool.hatch.build.targets.wheel] packages`) as
92
+ first-party on its own, so a module importing its own package is
93
+ wrongly reported as DEP003. `pkg/src` is the base if present, the same
94
+ src-layout check `_resolve.build_index` uses for import roots;
95
+ otherwise pkg itself."""
96
+ base = repo / pkg / "src"
97
+ if not base.is_dir():
98
+ base = repo / pkg
99
+ if not base.is_dir():
100
+ return ()
101
+ names: set[str] = set()
102
+ for entry in base.iterdir():
103
+ if entry.name.startswith(".") or should_skip_dir(entry.name):
104
+ continue
105
+ if entry.is_dir() and (entry / "__init__.py").is_file():
106
+ names.add(entry.name)
107
+ elif entry.is_file() and entry.suffix == ".py" and entry.stem != "__init__":
108
+ names.add(entry.stem)
109
+ return tuple(sorted(names))
110
+
111
+
112
+ def _configured_known_first_party(pyproject: Path) -> tuple[str, ...]:
113
+ """`[tool.deptry] known_first_party` from the package's own
114
+ pyproject.toml. deptry's CLI `--known-first-party` replaces this
115
+ config value rather than merging with it (deptry's own
116
+ `read_configuration_from_pyproject_toml`: pyproject.toml overrides the
117
+ default, the command line overrides pyproject.toml -- the command line
118
+ always wins, it never merges), so callers must merge this in
119
+ themselves to avoid dropping a user's own setting."""
120
+ with pyproject.open("rb") as f:
121
+ data = tomllib.load(f)
122
+ tool = cast(dict[str, object], data.get("tool", {}))
123
+ deptry_cfg = cast(dict[str, object], tool.get("deptry", {}))
124
+ configured = deptry_cfg.get("known_first_party", [])
125
+ if not isinstance(configured, list):
126
+ return ()
127
+ return tuple(str(name) for name in cast(list[object], configured))
128
+
129
+
130
+ def _known_first_party_names(repo: Path, pkg: Path, pyproject: Path) -> tuple[str, ...]:
131
+ """Union of the autodetected top-level package names and whatever the
132
+ package's own pyproject.toml already configures for deptry."""
133
+ return tuple(
134
+ sorted(
135
+ set(_top_level_package_names(repo, pkg))
136
+ | set(_configured_known_first_party(pyproject))
137
+ )
138
+ )
139
+
140
+
88
141
  def _nested_exclusions(pkg: Path, package_roots: Sequence[Path]) -> tuple[Path, ...]:
89
142
  """Subdirectories of pkg that are themselves package roots, relative to
90
143
  pkg, so a package's own scan does not re-scan a nested package."""
@@ -155,6 +208,7 @@ def _run_deptry_on(
155
208
  cmd = [
156
209
  "uv",
157
210
  "run",
211
+ "--isolated",
158
212
  "--directory",
159
213
  str(pkg_abs),
160
214
  "--with",
@@ -167,6 +221,8 @@ def _run_deptry_on(
167
221
  for rel in _nested_exclusions(pkg, package_roots):
168
222
  pattern = "^" + re.escape(rel.as_posix()) + "(/|$)"
169
223
  cmd += ["--extend-exclude", pattern]
224
+ for name in _known_first_party_names(repo, pkg, pkg_abs / "pyproject.toml"):
225
+ cmd += ["--known-first-party", name]
170
226
  json_out = tmp_dir / f"{pkg.as_posix().replace('/', '__') or 'root'}.json"
171
227
  cmd += ["--json-output", str(json_out)]
172
228
 
@@ -189,13 +245,17 @@ class UvMissing:
189
245
  all, never a silent skip."""
190
246
 
191
247
 
192
- def run_dependency_checks(repo: Path) -> _DepsResult | UvMissing:
248
+ def run_dependency_checks(
249
+ repo: Path, excludes: Sequence[str] = ()
250
+ ) -> _DepsResult | UvMissing:
193
251
  """Scans every package root under repo with deptry, one uv-managed
194
- environment per package."""
252
+ environment per package. `excludes` prunes matching trees out of
253
+ package-root discovery first, so a package root entirely under an
254
+ excluded tree gets no deptry run at all."""
195
255
  if shutil.which("uv") is None:
196
256
  return UvMissing()
197
257
 
198
- package_roots = find_package_roots(repo)
258
+ package_roots = find_package_roots(repo, excludes)
199
259
  findings: list[_DepFinding] = []
200
260
  skipped: list[_Skipped] = []
201
261
  failed: list[_Failed] = []
@@ -254,7 +254,7 @@ def _compute_misplaced(
254
254
  # tie-break deterministically: smallest path among the ties
255
255
  other_dir = min(d for d, c in other_counts.items() if c == best)
256
256
  other = best
257
- if other >= 3 and other > own:
257
+ if other >= 3 and 2 * other > len(partners):
258
258
  imported_by = sum(1 for p in importers if p.parent == other_dir)
259
259
  imports = sum(1 for p in imports_of if p.parent == other_dir)
260
260
  results.append(_Misplaced(f, own, other, other_dir, imported_by, imports))
@@ -434,8 +434,10 @@ def render_metrics(
434
434
  lines.append(
435
435
  "partners(f) = files f imports directly plus files importing f "
436
436
  + "directly; own = partners in f's own parent directory; other = "
437
- + "the largest count among all other parent directories, naming "
438
- + "that directory; misplaced when other >= 3 and other > own. "
437
+ + "the largest count among all other parent directories (a "
438
+ + "subdirectory of f's own directory counts as other), naming "
439
+ + "that directory; misplaced when other >= 3 and other exceeds "
440
+ + "half of all partners. "
439
441
  + "'imported by' = those other-directory partners that import f; "
440
442
  + "'imports' = those other-directory partners f imports; a "
441
443
  + "partner doing both counts in both, so the two may sum to more "