python-package-folder 1.1.3__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,24 @@
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
+ "SubfolderBuildConfig",
12
+ "VersionManager",
13
+ "find_project_root",
14
+ "find_source_directory",
15
+ )
16
+
17
+ from .analyzer import ImportAnalyzer
18
+ from .finder import ExternalDependencyFinder
19
+ from .manager import BuildManager
20
+ from .publisher import Publisher, Repository
21
+ from .subfolder_build import SubfolderBuildConfig
22
+ from .types import ExternalDependency, ImportInfo
23
+ from .utils import find_project_root, find_source_directory
24
+ 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,234 @@
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
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__(
32
+ self, project_root: Path, src_dir: Path, exclude_patterns: list[str] | None = None
33
+ ) -> None:
34
+ """
35
+ Initialize the dependency finder.
36
+
37
+ Args:
38
+ project_root: Root directory of the project
39
+ src_dir: Source directory to analyze
40
+ exclude_patterns: Additional patterns to exclude (default: common sandbox patterns)
41
+ """
42
+ self.project_root = project_root.resolve()
43
+ self.src_dir = src_dir.resolve()
44
+ self.analyzer = ImportAnalyzer(project_root)
45
+ # Patterns for directories/files to exclude (sandbox, skip, etc.)
46
+ default_patterns = [
47
+ "_SS",
48
+ "__SS",
49
+ "_sandbox",
50
+ "__sandbox",
51
+ "_skip",
52
+ "__skip",
53
+ "_test",
54
+ "__test__",
55
+ ]
56
+ self.exclude_patterns = default_patterns + (exclude_patterns or [])
57
+
58
+ def find_external_dependencies(self, python_files: list[Path]) -> list[ExternalDependency]:
59
+ """
60
+ Find all external dependencies that need to be copied.
61
+
62
+ Analyzes all provided Python files, classifies their imports,
63
+ and identifies which external files/directories need to be copied
64
+ into the source directory.
65
+
66
+ Args:
67
+ python_files: List of Python file paths to analyze
68
+
69
+ Returns:
70
+ List of ExternalDependency objects representing files/directories
71
+ that need to be copied
72
+ """
73
+ external_deps: list[ExternalDependency] = []
74
+ seen_paths: set[Path] = set()
75
+
76
+ for file_path in python_files:
77
+ imports = self.analyzer.extract_imports(file_path)
78
+ for imp in imports:
79
+ self.analyzer.classify_import(imp, self.src_dir)
80
+
81
+ if imp.classification == "external" and imp.resolved_path:
82
+ source_path = imp.resolved_path
83
+
84
+ # Skip excluded paths (sandbox directories, etc.)
85
+ if self._should_exclude_path(source_path):
86
+ continue
87
+
88
+ # For files, only copy parent directory if it's a package
89
+ # Otherwise, copy just the individual file
90
+ if source_path.is_file():
91
+ parent_dir = source_path.parent
92
+
93
+ # Only copy parent directory if:
94
+ # 1. It's a package (has __init__.py), OR
95
+ # 2. Files from it are actually imported (which is the case here)
96
+ # But only copy the immediate parent, not entire directory trees
97
+ parent_is_package = (parent_dir / "__init__.py").exists()
98
+ files_are_imported = True # Always true when processing an import
99
+
100
+ # Only copy immediate parent directory, not grandparent directories
101
+ # This prevents copying entire trees like models/Information_extraction
102
+ # when we only need models/Information_extraction/_shared_ie
103
+ should_copy_dir = (
104
+ not self._should_exclude_path(parent_dir)
105
+ and (
106
+ parent_is_package or files_are_imported
107
+ ) # Package OR files imported
108
+ and not parent_dir.is_relative_to(self.src_dir)
109
+ and not self.src_dir.is_relative_to(parent_dir)
110
+ and parent_dir != self.project_root
111
+ and parent_dir != self.project_root.parent
112
+ )
113
+
114
+ if should_copy_dir:
115
+ # Copy the directory instead of just the file
116
+ track_path = parent_dir
117
+ source_path = parent_dir
118
+ else:
119
+ # Copy just the file
120
+ track_path = source_path
121
+ elif source_path.is_dir():
122
+ # Don't copy directories that contain src_dir
123
+ if self.src_dir.is_relative_to(source_path):
124
+ continue
125
+ track_path = source_path
126
+ else:
127
+ continue
128
+
129
+ if track_path in seen_paths:
130
+ continue
131
+ seen_paths.add(track_path)
132
+
133
+ # Determine target path within src_dir
134
+ target_path = self._determine_target_path(source_path, imp.module_name)
135
+
136
+ if target_path:
137
+ # Only add if source is actually outside src_dir
138
+ if not source_path.is_relative_to(self.src_dir):
139
+ external_deps.append(
140
+ ExternalDependency(
141
+ source_path=source_path,
142
+ target_path=target_path,
143
+ import_name=imp.module_name,
144
+ file_path=file_path,
145
+ )
146
+ )
147
+
148
+ return external_deps
149
+
150
+ def _determine_target_path(self, source_path: Path, module_name: str) -> Path | None:
151
+ """
152
+ Determine where an external file should be copied within src_dir.
153
+
154
+ For files, attempts to maintain the module structure. For directories,
155
+ places them directly in src_dir with their original name.
156
+
157
+ Args:
158
+ source_path: Path to the source file or directory
159
+ module_name: Module name from the import statement
160
+
161
+ Returns:
162
+ Target path within src_dir, or None if cannot be determined
163
+ """
164
+ if not source_path.exists():
165
+ return None
166
+
167
+ # Always create target within src_dir
168
+ module_parts = module_name.split(".")
169
+
170
+ if source_path.is_file():
171
+ # For a file, create the directory structure based on module name
172
+ if len(module_parts) > 1:
173
+ # It's a submodule, create the directory structure
174
+ target = self.src_dir / "/".join(module_parts[:-1]) / source_path.name
175
+ else:
176
+ # Top-level module - try to find the main package directory
177
+ # or create a matching structure
178
+ main_pkg = self._find_main_package()
179
+ if main_pkg:
180
+ target = main_pkg / source_path.name
181
+ else:
182
+ target = self.src_dir / source_path.name
183
+ return target
184
+
185
+ # If it's a directory, copy the whole directory
186
+ if source_path.is_dir():
187
+ # Use the directory name directly in src_dir
188
+ target = self.src_dir / source_path.name
189
+ return target
190
+
191
+ return None
192
+
193
+ def _should_exclude_path(self, path: Path) -> bool:
194
+ """
195
+ Check if a path should be excluded from copying.
196
+
197
+ Excludes paths that match common sandbox/skip patterns like _SS, __SS, etc.
198
+
199
+ Args:
200
+ path: Path to check
201
+
202
+ Returns:
203
+ True if the path should be excluded, False otherwise
204
+ """
205
+ # Check each component of the path
206
+ for part in path.parts:
207
+ for pattern in self.exclude_patterns:
208
+ # Match if part equals pattern or starts with pattern
209
+ if part == pattern or part.startswith(pattern):
210
+ return True
211
+ return False
212
+
213
+ def _find_main_package(self) -> Path | None:
214
+ """
215
+ Find the main package directory within src_dir.
216
+
217
+ Looks for directories containing __init__.py files, which indicate
218
+ Python packages.
219
+
220
+ Returns:
221
+ Path to the main package directory, or None if not found
222
+ """
223
+ if not self.src_dir.exists():
224
+ return None
225
+
226
+ # Look for directories with __init__.py
227
+ package_dirs = [
228
+ d for d in self.src_dir.iterdir() if d.is_dir() and (d / "__init__.py").exists()
229
+ ]
230
+
231
+ if package_dirs:
232
+ return package_dirs[0]
233
+
234
+ return None