code-towel 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ # Copyright 2025 Eric Allen
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Utilities for understanding project/package layout to generate robust import paths
17
+ for cross-file refactorings.
18
+
19
+ This module is runtime-dependency-free and uses only the standard library.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any, Dict, Iterable, List, Optional
27
+
28
+
29
+ def _load_pyproject(project_root: Path) -> Dict[str, Any]:
30
+ """Best-effort load of pyproject.toml with stdlib only.
31
+
32
+ Returns an empty dict if parsing fails or file does not exist.
33
+ """
34
+ pyproject_path = project_root / "pyproject.toml"
35
+ if not pyproject_path.exists():
36
+ return {}
37
+
38
+ # Python 3.11+: tomllib in stdlib; earlier versions won't have it.
39
+ try:
40
+ import tomllib
41
+
42
+ with pyproject_path.open("rb") as f:
43
+ return tomllib.load(f)
44
+ except Exception:
45
+ # Silently ignore - we don't want a hard runtime dependency
46
+ return {}
47
+
48
+
49
+ def _is_package_dir(path: Path, pep420: bool) -> bool:
50
+ """Determine if a directory is a Python package root.
51
+
52
+ - Traditional packages: must contain __init__.py
53
+ - Namespace packages (PEP 420): any directory is a potential package component
54
+ """
55
+ if not path.is_dir():
56
+ return False
57
+ if pep420:
58
+ return True
59
+ return (path / "__init__.py").exists()
60
+
61
+
62
+ @dataclass
63
+ class ProjectLayout:
64
+ """Represents the directory structure and import configuration of a Python project.
65
+
66
+ Discovers project root markers (pyproject.toml, setup.py, .git) and source
67
+ roots from package configuration to generate correct import paths for refactored code.
68
+ """
69
+
70
+ project_root: Path
71
+ source_roots: List[Path]
72
+ prefer_absolute_imports: bool = True
73
+ pep420_namespace_packages: bool = True
74
+
75
+ @classmethod
76
+ def discover(
77
+ cls,
78
+ start_path: Path,
79
+ *,
80
+ prefer_absolute_imports: Optional[bool] = None,
81
+ pep420_namespace_packages: Optional[bool] = None,
82
+ ) -> "ProjectLayout":
83
+ """Discover project layout from a starting path.
84
+
85
+ Heuristics:
86
+ - Project root is the nearest ancestor containing pyproject.toml, setup.cfg, setup.py, or .git
87
+ - Source roots come from pyproject [tool.setuptools.package-dir] (e.g., {"": "src"})
88
+ or default to [project_root] (flat layout). If mapping exists, add each mapped directory.
89
+ """
90
+ project_root = _find_project_root(start_path)
91
+ data = _load_pyproject(project_root)
92
+
93
+ # Default settings
94
+ prefer_abs = True if prefer_absolute_imports is None else prefer_absolute_imports
95
+ pep420 = True if pep420_namespace_packages is None else pep420_namespace_packages
96
+
97
+ # Determine source roots
98
+ source_roots: List[Path] = []
99
+
100
+ try:
101
+ mapping = data.get("tool", {}).get("setuptools", {}).get("package-dir", {})
102
+ # mapping: {"" : "src"} or {"mypkg": "src/mypkg"}
103
+ candidates: Iterable[str] = mapping.values() if isinstance(mapping, dict) else []
104
+ for rel in candidates:
105
+ root = (project_root / rel).resolve()
106
+ if root.exists() and root.is_dir():
107
+ source_roots.append(root)
108
+ except Exception:
109
+ pass
110
+
111
+ start_resolved = start_path.resolve()
112
+ start_dir = start_resolved.parent if start_resolved.is_file() else start_resolved
113
+
114
+ # If no explicit mapping was found, default to treating the entire project root
115
+ # as the import anchor. We intentionally DO NOT auto-add conventional directories
116
+ # like "src" or "lib" as source roots when pyproject mapping is absent, because
117
+ # test scenarios import modules with sys.path pointing at the project root.
118
+ # In that context, absolute module names should include the top-level directory
119
+ # component (e.g., "src.data_processor"), which fails if we strip it as a source root.
120
+ if not source_roots:
121
+ source_roots = [project_root]
122
+
123
+ # Prefer source roots that actually contain the starting directory. When none of the
124
+ # discovered roots include the path we're analyzing (common for test fixtures copied
125
+ # outside the main package tree), fall back to treating the starting directory as the
126
+ # root so relative imports remain valid.
127
+ filtered_roots: List[Path] = []
128
+ for root in source_roots:
129
+ try:
130
+ start_dir.relative_to(root)
131
+ filtered_roots.append(root)
132
+ except ValueError:
133
+ continue
134
+
135
+ if filtered_roots:
136
+ source_roots = filtered_roots
137
+ elif start_dir != project_root:
138
+ project_root = start_dir
139
+ source_roots = [start_dir]
140
+
141
+ return cls(
142
+ project_root=project_root,
143
+ source_roots=source_roots,
144
+ prefer_absolute_imports=prefer_abs,
145
+ pep420_namespace_packages=pep420,
146
+ )
147
+
148
+ def module_name_for(self, file_path: Path) -> Optional[str]:
149
+ """Compute a dotted module name for a given file path.
150
+
151
+ Returns None if file is outside all known source roots.
152
+ """
153
+ file_path = file_path.resolve()
154
+ for src_root in self.source_roots:
155
+ try:
156
+ rel = file_path.relative_to(src_root)
157
+ if rel.suffix != ".py":
158
+ return None
159
+ parts = list(rel.with_suffix("").parts)
160
+ # Validate package path components
161
+ if not parts:
162
+ return None
163
+ if not self.pep420_namespace_packages:
164
+ # Ensure every directory in the chain is a traditional package
165
+ cursor = src_root
166
+ for comp in parts[:-1]:
167
+ cursor = cursor / comp
168
+ if not _is_package_dir(cursor, pep420=False):
169
+ # Not a classic package path; fall back to absolute-from-project
170
+ break
171
+ return ".".join(parts)
172
+ except ValueError:
173
+ continue
174
+
175
+ # Fallback: compute relative to project_root
176
+ try:
177
+ rel = file_path.relative_to(self.project_root)
178
+ if rel.suffix != ".py":
179
+ return None
180
+ return ".".join(rel.with_suffix("").parts)
181
+ except Exception:
182
+ return None
183
+
184
+
185
+ def _find_project_root(start_path: Path) -> Path:
186
+ """Find nearest ancestor that looks like the project root."""
187
+ start = start_path.resolve()
188
+ base_dir = start.parent if start.is_file() else start
189
+
190
+ # Prefer a nearby directory containing packaging markers, but fall back to
191
+ # the provided directory when none are found while walking upward.
192
+ markers = {"pyproject.toml", "setup.cfg", "setup.py"}
193
+ current = base_dir
194
+ while True:
195
+ if any((current / m).exists() for m in markers) or (current / ".git").exists():
196
+ return current
197
+ parent = current.parent
198
+ if parent == current:
199
+ break
200
+ current = parent
201
+ return base_dir