python-package-folder 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ """Python package folder - Build Python packages with external dependency management."""
2
+
3
+ __all__ = (
4
+ "BuildManager",
5
+ "ExternalDependency",
6
+ "ExternalDependencyFinder",
7
+ "ImportAnalyzer",
8
+ "ImportInfo",
9
+ "Publisher",
10
+ "Repository",
11
+ "VersionManager",
12
+ )
13
+
14
+ from .analyzer import ImportAnalyzer
15
+ from .finder import ExternalDependencyFinder
16
+ from .manager import BuildManager
17
+ from .publisher import Publisher, Repository
18
+ from .types import ExternalDependency, ImportInfo
19
+ from .version import VersionManager
@@ -0,0 +1,13 @@
1
+ """
2
+ Allow running the package as a module.
3
+
4
+ This module enables running the package with:
5
+ python -m python_package_folder
6
+
7
+ It simply delegates to the main() function from python_package_folder.py.
8
+ """
9
+
10
+ from .python_package_folder import main
11
+
12
+ if __name__ == "__main__":
13
+ exit(main())
@@ -0,0 +1,313 @@
1
+ """
2
+ Import analysis functionality.
3
+
4
+ This module provides the ImportAnalyzer class which is responsible for:
5
+ - Finding all Python files in a directory tree
6
+ - Extracting import statements using AST parsing
7
+ - Classifying imports as stdlib, third-party, local, external, or ambiguous
8
+ - Resolving import paths to actual file locations
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import importlib.util
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .types import ImportInfo
19
+
20
+
21
+ class ImportAnalyzer:
22
+ """
23
+ Analyzes Python files to extract and classify import statements.
24
+
25
+ This class uses Python's AST module to parse Python files and extract
26
+ all import statements. It can classify imports into different categories
27
+ and resolve their file paths.
28
+
29
+ Attributes:
30
+ project_root: Root directory of the project
31
+ _stdlib_modules: Cached set of standard library module names
32
+ """
33
+
34
+ def __init__(self, project_root: Path) -> None:
35
+ """
36
+ Initialize the import analyzer.
37
+
38
+ Args:
39
+ project_root: Root directory of the project to analyze
40
+ """
41
+ self.project_root = project_root.resolve()
42
+ self._stdlib_modules: set[str] | None = None
43
+
44
+ def find_all_python_files(self, directory: Path) -> list[Path]:
45
+ """
46
+ Recursively find all Python files in a directory.
47
+
48
+ Args:
49
+ directory: Directory to search for Python files
50
+
51
+ Returns:
52
+ List of paths to all .py files found in the directory tree
53
+ """
54
+ return [path for path in directory.rglob("*.py") if path.is_file()]
55
+
56
+ def extract_imports(self, file_path: Path) -> list[ImportInfo]:
57
+ """
58
+ Extract all import statements from a Python file.
59
+
60
+ Uses AST parsing to find both `import` and `from ... import` statements.
61
+ Handles syntax errors gracefully by returning an empty list.
62
+
63
+ Args:
64
+ file_path: Path to the Python file to analyze
65
+
66
+ Returns:
67
+ List of ImportInfo objects representing all imports found in the file
68
+ """
69
+ imports: list[ImportInfo] = []
70
+
71
+ try:
72
+ content = file_path.read_text(encoding="utf-8")
73
+ tree = ast.parse(content, filename=str(file_path))
74
+ except (SyntaxError, UnicodeDecodeError) as e:
75
+ print(f"Warning: Could not parse {file_path}: {e}", file=sys.stderr)
76
+ return imports
77
+
78
+ for node in ast.walk(tree):
79
+ if isinstance(node, ast.Import):
80
+ for alias in node.names:
81
+ imports.append(
82
+ ImportInfo(
83
+ module_name=alias.name,
84
+ import_type="import",
85
+ line_number=node.lineno,
86
+ file_path=file_path,
87
+ )
88
+ )
89
+ elif isinstance(node, ast.ImportFrom):
90
+ if node.module:
91
+ imports.append(
92
+ ImportInfo(
93
+ module_name=node.module,
94
+ import_type="from",
95
+ from_module=node.module,
96
+ line_number=node.lineno,
97
+ file_path=file_path,
98
+ )
99
+ )
100
+
101
+ return imports
102
+
103
+ def get_stdlib_modules(self) -> set[str]:
104
+ """
105
+ Get a set of standard library module names.
106
+
107
+ Caches the result for performance. Attempts to discover stdlib modules
108
+ by examining the Python installation directory, with a fallback list
109
+ of common standard library modules.
110
+
111
+ Returns:
112
+ Set of standard library module names
113
+ """
114
+ if self._stdlib_modules is not None:
115
+ return self._stdlib_modules
116
+
117
+ stdlib_modules: set[str] = set()
118
+
119
+ # Get standard library path
120
+ stdlib_path = (
121
+ Path(sys.executable).parent
122
+ / "lib"
123
+ / f"python{sys.version_info.major}.{sys.version_info.minor}"
124
+ )
125
+ if not stdlib_path.exists():
126
+ # Fallback: try to import and check sys.path
127
+ import sysconfig
128
+
129
+ stdlib_path = Path(sysconfig.get_path("stdlib"))
130
+
131
+ if stdlib_path.exists():
132
+ for item in stdlib_path.iterdir():
133
+ if item.is_file() and item.suffix == ".py":
134
+ stdlib_modules.add(item.stem)
135
+ elif (
136
+ item.is_dir()
137
+ and not item.name.startswith("_")
138
+ and (item / "__init__.py").exists()
139
+ ):
140
+ stdlib_modules.add(item.name)
141
+
142
+ # Add common stdlib modules that might not be in the directory
143
+ common_stdlib = {
144
+ "sys",
145
+ "os",
146
+ "json",
147
+ "pathlib",
148
+ "typing",
149
+ "collections",
150
+ "itertools",
151
+ "functools",
152
+ "dataclasses",
153
+ "enum",
154
+ "abc",
155
+ "contextlib",
156
+ "io",
157
+ "textwrap",
158
+ "ast",
159
+ "importlib",
160
+ "shutil",
161
+ "subprocess",
162
+ }
163
+ stdlib_modules.update(common_stdlib)
164
+
165
+ self._stdlib_modules = stdlib_modules
166
+ return stdlib_modules
167
+
168
+ def classify_import(self, import_info: ImportInfo, src_dir: Path) -> None:
169
+ """
170
+ Classify an import as stdlib, third-party, local, external, or ambiguous.
171
+
172
+ Modifies the ImportInfo object in place, setting its classification
173
+ and resolved_path attributes.
174
+
175
+ Args:
176
+ import_info: ImportInfo object to classify
177
+ src_dir: Source directory to use for determining local vs external
178
+ """
179
+ module_name = import_info.module_name
180
+ stdlib_modules = self.get_stdlib_modules()
181
+
182
+ # Check if it's a standard library module
183
+ root_module = module_name.split(".")[0]
184
+ if root_module in stdlib_modules:
185
+ import_info.classification = "stdlib"
186
+ return
187
+
188
+ # Try to resolve as a local import
189
+ resolved = self.resolve_local_import(import_info, src_dir)
190
+ if resolved is not None:
191
+ if resolved.is_relative_to(src_dir):
192
+ import_info.classification = "local"
193
+ else:
194
+ import_info.classification = "external"
195
+ import_info.resolved_path = resolved
196
+ return
197
+
198
+ # Check if it's a third-party package (in site-packages)
199
+ if self.is_third_party(module_name):
200
+ import_info.classification = "third_party"
201
+ return
202
+
203
+ # Mark as ambiguous if we can't determine
204
+ import_info.classification = "ambiguous"
205
+
206
+ def resolve_local_import(self, import_info: ImportInfo, src_dir: Path) -> Path | None:
207
+ """
208
+ Try to resolve a local import to a file path.
209
+
210
+ Handles both relative imports (starting with .) and absolute imports.
211
+ Checks multiple potential locations including parent directories
212
+ of the source directory.
213
+
214
+ Args:
215
+ import_info: ImportInfo object with the import to resolve
216
+ src_dir: Source directory to use as reference
217
+
218
+ Returns:
219
+ Path to the resolved file, or None if not found
220
+ """
221
+ module_name = import_info.module_name
222
+
223
+ # Handle relative imports
224
+ if import_info.file_path:
225
+ file_dir = import_info.file_path.parent
226
+ if module_name.startswith("."):
227
+ # Relative import
228
+ parts = module_name.split(".")
229
+ level = sum(1 for p in parts if p == "")
230
+ module_parts = [p for p in parts if p]
231
+
232
+ if level > 0:
233
+ # Go up 'level' directories
234
+ current = file_dir
235
+ for _ in range(level - 1):
236
+ current = current.parent
237
+ base_path = current
238
+ else:
239
+ base_path = file_dir
240
+
241
+ if module_parts:
242
+ potential_path = base_path / "/".join(module_parts)
243
+ else:
244
+ potential_path = base_path
245
+
246
+ # Try as module
247
+ if (potential_path / "__init__.py").exists():
248
+ return potential_path / "__init__.py"
249
+ if potential_path.with_suffix(".py").exists():
250
+ return potential_path.with_suffix(".py")
251
+ if potential_path.is_dir() and (potential_path / "__init__.py").exists():
252
+ return potential_path / "__init__.py"
253
+
254
+ # Handle absolute imports - check if it's in the project
255
+ # First check if it's in src_dir
256
+ module_path_str = module_name.replace(".", "/")
257
+ potential_paths = [
258
+ src_dir / module_path_str / "__init__.py",
259
+ (src_dir / module_path_str).with_suffix(".py"),
260
+ self.project_root / module_path_str / "__init__.py",
261
+ (self.project_root / module_path_str).with_suffix(".py"),
262
+ ]
263
+
264
+ for path in potential_paths:
265
+ if path.exists():
266
+ return path
267
+
268
+ # Check parent directories of src_dir and project_root for external dependencies
269
+ # Check up to project_root's parent
270
+ check_dirs = [src_dir.parent] + list(src_dir.parents)
271
+ for parent in check_dirs:
272
+ if parent == self.project_root.parent:
273
+ break
274
+ if not parent.exists():
275
+ continue
276
+
277
+ # Try as module directory
278
+ potential = parent / module_name.replace(".", "/")
279
+ if potential.is_dir() and (potential / "__init__.py").exists():
280
+ return potential / "__init__.py"
281
+ if potential.with_suffix(".py").is_file():
282
+ return potential.with_suffix(".py")
283
+
284
+ # Also check if the parent itself contains the module
285
+ potential_file = parent / f"{module_name.split('.')[-1]}.py"
286
+ if potential_file.exists():
287
+ return potential_file
288
+
289
+ return None
290
+
291
+ def is_third_party(self, module_name: str) -> bool:
292
+ """
293
+ Check if a module is a third-party package.
294
+
295
+ Uses importlib to find the module and checks if its location
296
+ is in site-packages or dist-packages.
297
+
298
+ Args:
299
+ module_name: Name of the module to check
300
+
301
+ Returns:
302
+ True if the module is a third-party package, False otherwise
303
+ """
304
+ root_module = module_name.split(".")[0]
305
+ try:
306
+ spec = importlib.util.find_spec(root_module)
307
+ if spec and spec.origin:
308
+ origin_path = Path(spec.origin)
309
+ # Check if it's in site-packages
310
+ return "site-packages" in str(origin_path) or "dist-packages" in str(origin_path)
311
+ except (ImportError, ValueError, AttributeError):
312
+ pass
313
+ return False
@@ -0,0 +1,188 @@
1
+ """
2
+ External dependency finding functionality.
3
+
4
+ This module provides the ExternalDependencyFinder class which identifies
5
+ files and directories that are imported from outside the source directory
6
+ and need to be temporarily copied during the build process.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from .analyzer import ImportAnalyzer
14
+ from .types import ExternalDependency, ImportInfo
15
+
16
+
17
+ class ExternalDependencyFinder:
18
+ """
19
+ Finds external dependencies that need to be copied.
20
+
21
+ This class analyzes Python files to identify imports that reference
22
+ modules outside the source directory. It determines which files or
23
+ directories need to be copied and where they should be placed.
24
+
25
+ Attributes:
26
+ project_root: Root directory of the project
27
+ src_dir: Source directory where the package code lives
28
+ analyzer: ImportAnalyzer instance for analyzing imports
29
+ """
30
+
31
+ def __init__(self, project_root: Path, src_dir: Path) -> None:
32
+ """
33
+ Initialize the dependency finder.
34
+
35
+ Args:
36
+ project_root: Root directory of the project
37
+ src_dir: Source directory to analyze
38
+ """
39
+ self.project_root = project_root.resolve()
40
+ self.src_dir = src_dir.resolve()
41
+ self.analyzer = ImportAnalyzer(project_root)
42
+
43
+ def find_external_dependencies(self, python_files: list[Path]) -> list[ExternalDependency]:
44
+ """
45
+ Find all external dependencies that need to be copied.
46
+
47
+ Analyzes all provided Python files, classifies their imports,
48
+ and identifies which external files/directories need to be copied
49
+ into the source directory.
50
+
51
+ Args:
52
+ python_files: List of Python file paths to analyze
53
+
54
+ Returns:
55
+ List of ExternalDependency objects representing files/directories
56
+ that need to be copied
57
+ """
58
+ external_deps: list[ExternalDependency] = []
59
+ seen_paths: set[Path] = set()
60
+
61
+ for file_path in python_files:
62
+ imports = self.analyzer.extract_imports(file_path)
63
+ for imp in imports:
64
+ self.analyzer.classify_import(imp, self.src_dir)
65
+
66
+ if imp.classification == "external" and imp.resolved_path:
67
+ source_path = imp.resolved_path
68
+
69
+ # For files, check if we should copy the parent directory instead
70
+ # (e.g., if importing from utility_folder/some_utility.py, copy utility_folder/)
71
+ if source_path.is_file():
72
+ # Check if the file is in a directory that should be copied as a whole
73
+ parent_dir = source_path.parent
74
+ module_parts = imp.module_name.split(".")
75
+
76
+ # Copy parent directory if:
77
+ # 1. Module name has multiple parts (suggesting it's a package structure)
78
+ # 2. Parent is outside src_dir
79
+ # 3. Parent doesn't contain src_dir (to avoid recursive copies)
80
+ # 4. Parent is not the project root
81
+ should_copy_dir = (
82
+ len(module_parts) > 2 # Has at least package.module structure
83
+ and not parent_dir.is_relative_to(self.src_dir)
84
+ and not self.src_dir.is_relative_to(parent_dir)
85
+ and parent_dir != self.project_root
86
+ and parent_dir != self.project_root.parent
87
+ )
88
+
89
+ if should_copy_dir:
90
+ # Copy the directory instead of just the file
91
+ track_path = parent_dir
92
+ source_path = parent_dir
93
+ else:
94
+ track_path = source_path
95
+ elif source_path.is_dir():
96
+ # Don't copy directories that contain src_dir
97
+ if self.src_dir.is_relative_to(source_path):
98
+ continue
99
+ track_path = source_path
100
+ else:
101
+ continue
102
+
103
+ if track_path in seen_paths:
104
+ continue
105
+ seen_paths.add(track_path)
106
+
107
+ # Determine target path within src_dir
108
+ target_path = self._determine_target_path(source_path, imp.module_name)
109
+
110
+ if target_path:
111
+ # Only add if source is actually outside src_dir
112
+ if not source_path.is_relative_to(self.src_dir):
113
+ external_deps.append(
114
+ ExternalDependency(
115
+ source_path=source_path,
116
+ target_path=target_path,
117
+ import_name=imp.module_name,
118
+ file_path=file_path,
119
+ )
120
+ )
121
+
122
+ return external_deps
123
+
124
+ def _determine_target_path(self, source_path: Path, module_name: str) -> Path | None:
125
+ """
126
+ Determine where an external file should be copied within src_dir.
127
+
128
+ For files, attempts to maintain the module structure. For directories,
129
+ places them directly in src_dir with their original name.
130
+
131
+ Args:
132
+ source_path: Path to the source file or directory
133
+ module_name: Module name from the import statement
134
+
135
+ Returns:
136
+ Target path within src_dir, or None if cannot be determined
137
+ """
138
+ if not source_path.exists():
139
+ return None
140
+
141
+ # Always create target within src_dir
142
+ module_parts = module_name.split(".")
143
+
144
+ if source_path.is_file():
145
+ # For a file, create the directory structure based on module name
146
+ if len(module_parts) > 1:
147
+ # It's a submodule, create the directory structure
148
+ target = self.src_dir / "/".join(module_parts[:-1]) / source_path.name
149
+ else:
150
+ # Top-level module - try to find the main package directory
151
+ # or create a matching structure
152
+ main_pkg = self._find_main_package()
153
+ if main_pkg:
154
+ target = main_pkg / source_path.name
155
+ else:
156
+ target = self.src_dir / source_path.name
157
+ return target
158
+
159
+ # If it's a directory, copy the whole directory
160
+ if source_path.is_dir():
161
+ # Use the directory name directly in src_dir
162
+ target = self.src_dir / source_path.name
163
+ return target
164
+
165
+ return None
166
+
167
+ def _find_main_package(self) -> Path | None:
168
+ """
169
+ Find the main package directory within src_dir.
170
+
171
+ Looks for directories containing __init__.py files, which indicate
172
+ Python packages.
173
+
174
+ Returns:
175
+ Path to the main package directory, or None if not found
176
+ """
177
+ if not self.src_dir.exists():
178
+ return None
179
+
180
+ # Look for directories with __init__.py
181
+ package_dirs = [
182
+ d for d in self.src_dir.iterdir() if d.is_dir() and (d / "__init__.py").exists()
183
+ ]
184
+
185
+ if package_dirs:
186
+ return package_dirs[0]
187
+
188
+ return None