python-package-folder 1.1.2__tar.gz → 1.1.3__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 (41) hide show
  1. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/PKG-INFO +1 -1
  2. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/finder.py +17 -5
  3. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/manager.py +27 -9
  4. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/publisher.py +22 -15
  5. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/python_package_folder.py +3 -1
  6. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/subfolder_build.py +43 -32
  7. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/utils.py +0 -1
  8. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/version.py +3 -4
  9. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/test_build_with_external_deps.py +24 -19
  10. python_package_folder-1.1.3/tests/test_linting.py +62 -0
  11. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/test_publisher.py +0 -1
  12. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/test_subfolder_build.py +20 -18
  13. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/test_utils.py +3 -2
  14. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/test_version_manager.py +2 -5
  15. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/tests.py +1 -1
  16. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/.copier-answers.yml +0 -0
  17. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/.cursor/rules/general.mdc +0 -0
  18. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/.cursor/rules/python.mdc +0 -0
  19. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/.github/workflows/ci.yml +0 -0
  20. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/.github/workflows/publish.yml +0 -0
  21. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/.gitignore +0 -0
  22. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/.vscode/settings.json +0 -0
  23. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/LICENSE +0 -0
  24. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/Makefile +0 -0
  25. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/README.md +0 -0
  26. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/coverage.svg +0 -0
  27. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/development.md +0 -0
  28. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/installation.md +0 -0
  29. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/publishing.md +0 -0
  30. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/pyproject.toml +0 -0
  31. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/__init__.py +0 -0
  32. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/__main__.py +0 -0
  33. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/analyzer.py +0 -0
  34. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/py.typed +0 -0
  35. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/src/python_package_folder/types.py +0 -0
  36. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/folder_structure/some_globals.py +0 -0
  37. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  38. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  39. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  40. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  41. {python_package_folder-1.1.2 → python_package_folder-1.1.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: Python package to automatically package and build a folder, fetching all relevant dependencies.
5
5
  Project-URL: Repository, https://github.com/alelom/python-package-folder
6
6
  Author-email: Alessio Lombardi <work@alelom.com>
@@ -28,7 +28,9 @@ class ExternalDependencyFinder:
28
28
  analyzer: ImportAnalyzer instance for analyzing imports
29
29
  """
30
30
 
31
- def __init__(self, project_root: Path, src_dir: Path, exclude_patterns: list[str] | None = None) -> None:
31
+ def __init__(
32
+ self, project_root: Path, src_dir: Path, exclude_patterns: list[str] | None = None
33
+ ) -> None:
32
34
  """
33
35
  Initialize the dependency finder.
34
36
 
@@ -41,7 +43,16 @@ class ExternalDependencyFinder:
41
43
  self.src_dir = src_dir.resolve()
42
44
  self.analyzer = ImportAnalyzer(project_root)
43
45
  # Patterns for directories/files to exclude (sandbox, skip, etc.)
44
- default_patterns = ["_SS", "__SS", "_sandbox", "__sandbox", "_skip", "__skip", "_test", "__test__"]
46
+ default_patterns = [
47
+ "_SS",
48
+ "__SS",
49
+ "_sandbox",
50
+ "__sandbox",
51
+ "_skip",
52
+ "__skip",
53
+ "_test",
54
+ "__test__",
55
+ ]
45
56
  self.exclude_patterns = default_patterns + (exclude_patterns or [])
46
57
 
47
58
  def find_external_dependencies(self, python_files: list[Path]) -> list[ExternalDependency]:
@@ -78,7 +89,6 @@ class ExternalDependencyFinder:
78
89
  # Otherwise, copy just the individual file
79
90
  if source_path.is_file():
80
91
  parent_dir = source_path.parent
81
- module_parts = imp.module_name.split(".")
82
92
 
83
93
  # Only copy parent directory if:
84
94
  # 1. It's a package (has __init__.py), OR
@@ -86,13 +96,15 @@ class ExternalDependencyFinder:
86
96
  # But only copy the immediate parent, not entire directory trees
87
97
  parent_is_package = (parent_dir / "__init__.py").exists()
88
98
  files_are_imported = True # Always true when processing an import
89
-
99
+
90
100
  # Only copy immediate parent directory, not grandparent directories
91
101
  # This prevents copying entire trees like models/Information_extraction
92
102
  # when we only need models/Information_extraction/_shared_ie
93
103
  should_copy_dir = (
94
104
  not self._should_exclude_path(parent_dir)
95
- and (parent_is_package or files_are_imported) # Package OR files imported
105
+ and (
106
+ parent_is_package or files_are_imported
107
+ ) # Package OR files imported
96
108
  and not parent_dir.is_relative_to(self.src_dir)
97
109
  and not self.src_dir.is_relative_to(parent_dir)
98
110
  and parent_dir != self.project_root
@@ -42,7 +42,12 @@ class BuildManager:
42
42
  copied_dirs: List of directory paths that were copied (for cleanup)
43
43
  """
44
44
 
45
- def __init__(self, project_root: Path, src_dir: Path | None = None, exclude_patterns: list[str] | None = None) -> None:
45
+ def __init__(
46
+ self,
47
+ project_root: Path,
48
+ src_dir: Path | None = None,
49
+ exclude_patterns: list[str] | None = None,
50
+ ) -> None:
46
51
  """
47
52
  Initialize the build manager.
48
53
 
@@ -77,7 +82,9 @@ class BuildManager:
77
82
  self.copied_files: list[Path] = []
78
83
  self.copied_dirs: list[Path] = []
79
84
  self.exclude_patterns = exclude_patterns or []
80
- self.finder = ExternalDependencyFinder(self.project_root, self.src_dir, exclude_patterns=exclude_patterns)
85
+ self.finder = ExternalDependencyFinder(
86
+ self.project_root, self.src_dir, exclude_patterns=exclude_patterns
87
+ )
81
88
 
82
89
  # Check if it's a valid Python package directory
83
90
  if not any(self.src_dir.glob("*.py")) and not (self.src_dir / "__init__.py").exists():
@@ -247,9 +254,18 @@ class BuildManager:
247
254
  src: Source directory
248
255
  dst: Destination directory
249
256
  """
250
- default_patterns = ["_SS", "__SS", "_sandbox", "__sandbox", "_skip", "__skip", "_test", "__test__"]
257
+ default_patterns = [
258
+ "_SS",
259
+ "__SS",
260
+ "_sandbox",
261
+ "__sandbox",
262
+ "_skip",
263
+ "__skip",
264
+ "_test",
265
+ "__test__",
266
+ ]
251
267
  exclude_patterns = default_patterns + self.exclude_patterns
252
-
268
+
253
269
  def should_exclude(path: Path) -> bool:
254
270
  """Check if a path should be excluded."""
255
271
  # Check each component of the path
@@ -268,7 +284,7 @@ class BuildManager:
268
284
  for item in src.iterdir():
269
285
  if should_exclude(item):
270
286
  continue
271
-
287
+
272
288
  src_item = src / item.name
273
289
  dst_item = dst / item.name
274
290
 
@@ -440,7 +456,9 @@ class BuildManager:
440
456
  if is_subfolder_build and version:
441
457
  if not package_name:
442
458
  # Derive package name from subfolder
443
- package_name = self.src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
459
+ package_name = (
460
+ self.src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
461
+ )
444
462
  print(f"Building subfolder as package '{package_name}' version '{version}'...")
445
463
  subfolder_config = SubfolderBuildConfig(
446
464
  project_root=self.project_root,
@@ -465,7 +483,7 @@ class BuildManager:
465
483
  # Determine package name and version for filtering
466
484
  publish_package_name = None
467
485
  publish_version = version
468
-
486
+
469
487
  if is_subfolder_build and package_name:
470
488
  publish_package_name = package_name
471
489
  elif not is_subfolder_build:
@@ -477,7 +495,7 @@ class BuildManager:
477
495
  import tomli as tomllib
478
496
  except ImportError:
479
497
  tomllib = None
480
-
498
+
481
499
  if tomllib:
482
500
  pyproject_path = self.project_root / "pyproject.toml"
483
501
  if pyproject_path.exists():
@@ -485,7 +503,7 @@ class BuildManager:
485
503
  data = tomllib.load(f)
486
504
  if "project" in data and "name" in data["project"]:
487
505
  publish_package_name = data["project"]["name"]
488
-
506
+
489
507
  publisher = Publisher(
490
508
  repository=repository,
491
509
  dist_dir=self.project_root / "dist",
@@ -76,7 +76,9 @@ class Publisher:
76
76
  self.repository = Repository(repository.lower())
77
77
  except ValueError as err:
78
78
  valid_repos = ", ".join(r.value for r in Repository)
79
- raise ValueError(f"Invalid repository: {repository}. Must be one of: {valid_repos}") from err
79
+ raise ValueError(
80
+ f"Invalid repository: {repository}. Must be one of: {valid_repos}"
81
+ ) from err
80
82
  else:
81
83
  self.repository = repository
82
84
 
@@ -119,13 +121,17 @@ class Publisher:
119
121
  # Try to get from keyring if available
120
122
  if keyring and not username:
121
123
  try:
122
- username = keyring.get_password(f"python-package-folder-{self.repository.value}", "username")
124
+ username = keyring.get_password(
125
+ f"python-package-folder-{self.repository.value}", "username"
126
+ )
123
127
  except Exception:
124
128
  pass
125
129
 
126
130
  if keyring and not password:
127
131
  try:
128
- password = keyring.get_password(f"python-package-folder-{self.repository.value}", username or "token")
132
+ password = keyring.get_password(
133
+ f"python-package-folder-{self.repository.value}", username or "token"
134
+ )
129
135
  except Exception:
130
136
  pass
131
137
 
@@ -157,8 +163,12 @@ class Publisher:
157
163
  # Store in keyring if available
158
164
  if keyring:
159
165
  try:
160
- keyring.set_password(f"python-package-folder-{self.repository.value}", "username", username)
161
- keyring.set_password(f"python-package-folder-{self.repository.value}", username, password)
166
+ keyring.set_password(
167
+ f"python-package-folder-{self.repository.value}", "username", username
168
+ )
169
+ keyring.set_password(
170
+ f"python-package-folder-{self.repository.value}", username, password
171
+ )
162
172
  except Exception:
163
173
  # Keyring storage is optional, continue if it fails
164
174
  pass
@@ -185,15 +195,13 @@ class Publisher:
185
195
  subprocess.CalledProcessError: If publishing fails
186
196
  """
187
197
  if not self._check_twine_installed():
188
- raise ValueError(
189
- "twine is required for publishing. Install it with: pip install twine"
190
- )
198
+ raise ValueError("twine is required for publishing. Install it with: pip install twine")
191
199
 
192
200
  if not self.dist_dir.exists():
193
201
  raise ValueError(f"Distribution directory not found: {self.dist_dir}")
194
202
 
195
203
  all_dist_files = list(self.dist_dir.glob("*.whl")) + list(self.dist_dir.glob("*.tar.gz"))
196
-
204
+
197
205
  # Filter files by package name and version if provided
198
206
  if self.package_name and self.version:
199
207
  # Normalize package name - try both hyphen and underscore variants
@@ -201,11 +209,11 @@ class Publisher:
201
209
  name_hyphen = self.package_name.replace("_", "-").lower()
202
210
  name_underscore = self.package_name.replace("-", "_").lower()
203
211
  name_original = self.package_name.lower()
204
-
212
+
205
213
  # Try all name variants
206
214
  name_variants = {name_hyphen, name_underscore, name_original}
207
215
  version_str = self.version
208
-
216
+
209
217
  dist_files = []
210
218
  for f in all_dist_files:
211
219
  # Get the base filename without extension
@@ -215,7 +223,7 @@ class Publisher:
215
223
  if f.suffix == ".gz" and stem.endswith(".tar"):
216
224
  # Handle .tar.gz files
217
225
  stem = stem[:-4] # Remove .tar
218
-
226
+
219
227
  # Check if filename starts with any name variant followed by version
220
228
  matches = False
221
229
  for name_variant in name_variants:
@@ -223,12 +231,12 @@ class Publisher:
223
231
  if stem.startswith(f"{name_variant}-{version_str}"):
224
232
  matches = True
225
233
  break
226
-
234
+
227
235
  if matches:
228
236
  dist_files.append(f)
229
237
  else:
230
238
  dist_files = all_dist_files
231
-
239
+
232
240
  if not dist_files:
233
241
  if self.package_name and self.version:
234
242
  raise ValueError(
@@ -300,4 +308,3 @@ For Azure Artifacts:
300
308
  - Repository URL: Your Azure Artifacts feed URL
301
309
  Example: https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/pypi/upload
302
310
  """
303
-
@@ -183,7 +183,9 @@ def main() -> int:
183
183
  if is_subfolder:
184
184
  from .subfolder_build import SubfolderBuildConfig
185
185
 
186
- package_name = args.package_name or src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
186
+ package_name = args.package_name or src_dir.name.replace("_", "-").replace(
187
+ " ", "-"
188
+ ).lower().strip("-")
187
189
  subfolder_config = SubfolderBuildConfig(
188
190
  project_root=project_root,
189
191
  src_dir=src_dir,
@@ -71,11 +71,11 @@ class SubfolderBuildConfig:
71
71
  # Remove any leading/trailing hyphens
72
72
  name = name.strip("-")
73
73
  return name
74
-
74
+
75
75
  def _get_package_structure(self) -> tuple[str, list[str]]:
76
76
  """
77
77
  Determine the package structure for hatchling.
78
-
78
+
79
79
  Returns:
80
80
  Tuple of (packages_path, package_dirs) where:
81
81
  - packages_path: The path to the directory containing packages
@@ -83,25 +83,25 @@ class SubfolderBuildConfig:
83
83
  """
84
84
  # Check if src_dir itself is a package (has __init__.py)
85
85
  has_init = (self.src_dir / "__init__.py").exists()
86
-
86
+
87
87
  # Check for Python files directly in src_dir
88
88
  py_files = list(self.src_dir.glob("*.py"))
89
89
  has_py_files = bool(py_files)
90
-
90
+
91
91
  # Calculate relative path
92
92
  try:
93
93
  rel_path = self.src_dir.relative_to(self.project_root)
94
94
  packages_path = str(rel_path).replace("\\", "/")
95
95
  except ValueError:
96
96
  packages_path = None
97
-
97
+
98
98
  # If src_dir has Python files but no __init__.py, we need to make it a package
99
99
  # or include it as a module directory
100
100
  if has_py_files and not has_init:
101
101
  # For flat structures, we include the directory itself
102
102
  # Hatchling will treat Python files in the directory as modules
103
103
  return packages_path, [packages_path] if packages_path else []
104
-
104
+
105
105
  # If it's a package or has subpackages, return the path
106
106
  return packages_path, [packages_path] if packages_path else []
107
107
 
@@ -182,10 +182,14 @@ class SubfolderBuildConfig:
182
182
  data["dependency-groups"].update(parent_dependency_group)
183
183
 
184
184
  # For now, use string manipulation (tomli-w not in stdlib)
185
- modified_content = self._modify_pyproject_string(original_content, parent_dependency_group)
185
+ modified_content = self._modify_pyproject_string(
186
+ original_content, parent_dependency_group
187
+ )
186
188
  else:
187
189
  # Use string manipulation
188
- modified_content = self._modify_pyproject_string(original_content, parent_dependency_group)
190
+ modified_content = self._modify_pyproject_string(
191
+ original_content, parent_dependency_group
192
+ )
189
193
 
190
194
  # Write the modified content
191
195
  original_pyproject.write_text(modified_content, encoding="utf-8")
@@ -196,7 +200,9 @@ class SubfolderBuildConfig:
196
200
 
197
201
  return original_pyproject
198
202
 
199
- def _modify_pyproject_string(self, content: str, dependency_group: dict[str, list[str]] | None = None) -> str:
203
+ def _modify_pyproject_string(
204
+ self, content: str, dependency_group: dict[str, list[str]] | None = None
205
+ ) -> str:
200
206
  """Modify pyproject.toml content using string manipulation."""
201
207
  lines = content.split("\n")
202
208
  result = []
@@ -244,7 +250,7 @@ class SubfolderBuildConfig:
244
250
  result.append(line)
245
251
  elif in_hatch_build:
246
252
  # Modify packages path
247
- if re.match(r'^\s*packages\s*=', line):
253
+ if re.match(r"^\s*packages\s*=", line):
248
254
  if package_dirs:
249
255
  packages_str = ", ".join(f'"{p}"' for p in package_dirs)
250
256
  result.append(f"packages = [{packages_str}]")
@@ -268,17 +274,17 @@ class SubfolderBuildConfig:
268
274
  result.append(line)
269
275
  elif in_project:
270
276
  # Modify name
271
- if re.match(r'^\s*name\s*=', line):
277
+ if re.match(r"^\s*name\s*=", line):
272
278
  result.append(f'name = "{self.package_name}"')
273
279
  name_set = True
274
280
  continue
275
281
  # Modify version
276
- elif re.match(r'^\s*version\s*=', line):
282
+ elif re.match(r"^\s*version\s*=", line):
277
283
  result.append(f'version = "{self.version}"')
278
284
  version_set = True
279
285
  continue
280
286
  # Remove version from dynamic
281
- elif re.match(r'^\s*dynamic\s*=\s*\[', line):
287
+ elif re.match(r"^\s*dynamic\s*=\s*\[", line):
282
288
  in_dynamic = True
283
289
  # Remove "version" from the list
284
290
  line = re.sub(r'"version"', "", line)
@@ -286,7 +292,7 @@ class SubfolderBuildConfig:
286
292
  line = re.sub(r",\s*,", ",", line)
287
293
  line = re.sub(r"\[\s*,", "[", line)
288
294
  line = re.sub(r",\s*\]", "]", line)
289
- if re.match(r'^\s*dynamic\s*=\s*\[\s*\]', line):
295
+ if re.match(r"^\s*dynamic\s*=\s*\[\s*\]", line):
290
296
  continue # Skip empty dynamic list
291
297
  elif in_dynamic and "]" in line:
292
298
  in_dynamic = False
@@ -309,7 +315,7 @@ class SubfolderBuildConfig:
309
315
  if in_hatch_build and not packages_set and package_dirs:
310
316
  packages_str = ", ".join(f'"{p}"' for p in package_dirs)
311
317
  result.append(f"packages = [{packages_str}]")
312
-
318
+
313
319
  # Ensure packages is always set for subfolder builds
314
320
  if not packages_set and package_dirs:
315
321
  # Add the section if it doesn't exist
@@ -336,30 +342,32 @@ class SubfolderBuildConfig:
336
342
  break
337
343
 
338
344
  # Format dependency group
339
- if insert_index < len(result) and result[insert_index].strip().startswith("[dependency-groups]"):
345
+ if insert_index < len(result) and result[insert_index].strip().startswith(
346
+ "[dependency-groups]"
347
+ ):
340
348
  # Replace existing section
341
349
  dep_lines = ["[dependency-groups]"]
342
350
  for group_name, deps in dependency_group.items():
343
- dep_lines.append(f'{group_name} = [')
351
+ dep_lines.append(f"{group_name} = [")
344
352
  for dep in deps:
345
353
  dep_lines.append(f' "{dep}",')
346
- dep_lines.append(']')
354
+ dep_lines.append("]")
347
355
  dep_lines.append("")
348
-
356
+
349
357
  # Find end of existing dependency-groups section
350
358
  end_index = insert_index + 1
351
359
  while end_index < len(result) and not result[end_index].strip().startswith("["):
352
360
  end_index += 1
353
-
361
+
354
362
  result[insert_index:end_index] = dep_lines
355
363
  else:
356
364
  # Insert new section
357
365
  dep_lines = ["", "[dependency-groups]"]
358
366
  for group_name, deps in dependency_group.items():
359
- dep_lines.append(f'{group_name} = [')
367
+ dep_lines.append(f"{group_name} = [")
360
368
  for dep in deps:
361
369
  dep_lines.append(f' "{dep}",')
362
- dep_lines.append(']')
370
+ dep_lines.append("]")
363
371
  result[insert_index:insert_index] = dep_lines
364
372
 
365
373
  return "\n".join(result)
@@ -367,14 +375,14 @@ class SubfolderBuildConfig:
367
375
  def _handle_readme(self) -> None:
368
376
  """
369
377
  Handle README file for subfolder builds.
370
-
378
+
371
379
  - If README exists in subfolder, copy it to project root
372
380
  - If no README exists, create a minimal one with folder name
373
381
  - Backup original README if it exists in project root
374
382
  """
375
383
  # Common README file names
376
384
  readme_names = ["README.md", "README.rst", "README.txt", "README"]
377
-
385
+
378
386
  # Check for README in subfolder
379
387
  subfolder_readme = None
380
388
  for name in readme_names:
@@ -382,7 +390,7 @@ class SubfolderBuildConfig:
382
390
  if readme_path.exists():
383
391
  subfolder_readme = readme_path
384
392
  break
385
-
393
+
386
394
  # Check for existing README in project root
387
395
  project_readme = None
388
396
  for name in readme_names:
@@ -390,13 +398,13 @@ class SubfolderBuildConfig:
390
398
  if readme_path.exists():
391
399
  project_readme = readme_path
392
400
  break
393
-
401
+
394
402
  # Backup original README if it exists
395
403
  if project_readme:
396
404
  backup_path = self.project_root / f"{project_readme.name}.backup"
397
405
  shutil.copy2(project_readme, backup_path)
398
406
  self.original_readme_backup = backup_path
399
-
407
+
400
408
  # Use subfolder README if it exists
401
409
  if subfolder_readme:
402
410
  # Copy subfolder README to project root
@@ -421,7 +429,7 @@ class SubfolderBuildConfig:
421
429
  except Exception:
422
430
  pass # Ignore errors during cleanup
423
431
  self._temp_init_created = False
424
-
432
+
425
433
  # Restore original README if it was backed up
426
434
  backup_path = self.original_readme_backup
427
435
  had_backup = backup_path and backup_path.exists()
@@ -432,12 +440,16 @@ class SubfolderBuildConfig:
432
440
  shutil.copy2(backup_path, original_readme_path)
433
441
  backup_path.unlink()
434
442
  self.original_readme_backup = None
435
-
443
+
436
444
  # Remove temporary README if we created it or copied from subfolder
437
445
  # Only remove if it's different from the original we just restored
438
446
  if self.temp_readme and self.temp_readme.exists():
439
447
  # If we restored an original README and the temp is the same file, don't remove it
440
- if had_backup and original_readme_path and self.temp_readme.samefile(original_readme_path):
448
+ if (
449
+ had_backup
450
+ and original_readme_path
451
+ and self.temp_readme.samefile(original_readme_path)
452
+ ):
441
453
  # Temp README is the same as the restored original, so don't remove it
442
454
  pass
443
455
  else:
@@ -447,7 +459,7 @@ class SubfolderBuildConfig:
447
459
  except Exception:
448
460
  pass # Ignore errors during cleanup
449
461
  self.temp_readme = None
450
-
462
+
451
463
  # Restore original pyproject.toml
452
464
  if self.original_pyproject_backup and self.original_pyproject_backup.exists():
453
465
  original_pyproject = self.project_root / "pyproject.toml"
@@ -463,4 +475,3 @@ class SubfolderBuildConfig:
463
475
  def __exit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ARG002
464
476
  """Context manager exit - always restore."""
465
477
  self.restore()
466
-
@@ -104,4 +104,3 @@ def is_python_package_directory(path: Path) -> bool:
104
104
  return True
105
105
 
106
106
  return False
107
-
@@ -148,7 +148,7 @@ class VersionManager:
148
148
  continue
149
149
 
150
150
  # Remove 'version' from dynamic list if present
151
- if re.match(r'^\s*dynamic\s*=\s*\[', line):
151
+ if re.match(r"^\s*dynamic\s*=\s*\[", line):
152
152
  # Check if version is in the list
153
153
  if "version" in line:
154
154
  # Remove version from the list
@@ -158,7 +158,7 @@ class VersionManager:
158
158
  line = re.sub(r"\[\s*,", "[", line) # Remove leading comma
159
159
  line = re.sub(r",\s*\]", "]", line) # Remove trailing comma
160
160
  # If dynamic list is now empty, skip the line
161
- if re.match(r'^\s*dynamic\s*=\s*\[\s*\]', line):
161
+ if re.match(r"^\s*dynamic\s*=\s*\[\s*\]", line):
162
162
  continue
163
163
 
164
164
  result.append(line)
@@ -182,7 +182,7 @@ class VersionManager:
182
182
  result.append(f'version = "{version}"')
183
183
  in_project = False
184
184
  result.append(line)
185
- elif in_project and re.match(r'^\s*version\s*=', line):
185
+ elif in_project and re.match(r"^\s*version\s*=", line):
186
186
  # Replace existing version
187
187
  result.append(f'version = "{version}"')
188
188
  version_set = True
@@ -251,4 +251,3 @@ class VersionManager:
251
251
  result.append("bump = true")
252
252
 
253
253
  self.pyproject_path.write_text("\n".join(result), encoding="utf-8")
254
-
@@ -36,9 +36,7 @@ def test_project_root(tmp_path: Path) -> Path:
36
36
  # Create _SS subdirectory with a file that should be excluded
37
37
  ss_dir = utility_folder / "_SS"
38
38
  ss_dir.mkdir()
39
- (ss_dir / "some_superseded_file.py").write_text(
40
- "def superseded_function():\n pass"
41
- )
39
+ (ss_dir / "some_superseded_file.py").write_text("def superseded_function():\n pass")
42
40
 
43
41
  # Create subfolder_to_build (target directory)
44
42
  subfolder_to_build = folder_structure / "subfolder_to_build"
@@ -246,11 +244,16 @@ class TestBuildManager:
246
244
  # The key is that we don't want to see significant divergence
247
245
  assert len(copied_paths1) > 0, "First call should copy some files"
248
246
  assert len(copied_paths2) > 0, "Second call should have some files"
249
-
247
+
250
248
  # The paths should be similar (allowing for some variation due to idempotency checks)
251
249
  # At minimum, the unique set of paths should be consistent
252
- assert copied_paths1 == copied_paths2 or copied_paths1.issubset(copied_paths2) or copied_paths2.issubset(copied_paths1), \
250
+ assert (
251
+ copied_paths1 == copied_paths2
252
+ or copied_paths1.issubset(copied_paths2)
253
+ or copied_paths2.issubset(copied_paths1)
254
+ ), (
253
255
  f"Copied paths should be consistent between calls. First: {copied_paths1}, Second: {copied_paths2}"
256
+ )
254
257
 
255
258
  def test_cleanup_removes_copied_files(self, test_project_root: Path) -> None:
256
259
  """Test that cleanup removes all copied files."""
@@ -400,7 +403,7 @@ class TestExclusionPatterns:
400
403
  src_dir = test_project_root / "folder_structure" / "subfolder_to_build"
401
404
  manager = BuildManager(test_project_root, src_dir)
402
405
 
403
- external_deps = manager.prepare_build()
406
+ manager.prepare_build()
404
407
 
405
408
  # Verify utility_folder was copied
406
409
  copied_utility = src_dir / "utility_folder"
@@ -424,14 +427,16 @@ class TestExclusionPatterns:
424
427
  manager = BuildManager(project_root, src_dir)
425
428
 
426
429
  try:
427
- external_deps = manager.prepare_build()
430
+ manager.prepare_build()
428
431
 
429
432
  # Verify utility_folder was copied
430
433
  copied_utility = src_dir / "utility_folder"
431
434
  if copied_utility.exists():
432
435
  # Verify _SS directory was NOT copied
433
436
  copied_ss = copied_utility / "_SS"
434
- assert not copied_ss.exists(), "_SS directory should be excluded from real structure"
437
+ assert not copied_ss.exists(), (
438
+ "_SS directory should be excluded from real structure"
439
+ )
435
440
 
436
441
  # Verify the superseded file was NOT copied
437
442
  copied_superseded = copied_ss / "some_superseded_file.py"
@@ -448,11 +453,9 @@ class TestExclusionPatterns:
448
453
  (custom_excluded / "skip_file.py").write_text("def skip(): pass")
449
454
 
450
455
  src_dir = test_project_root / "folder_structure" / "subfolder_to_build"
451
- manager = BuildManager(
452
- test_project_root, src_dir, exclude_patterns=["custom_skip"]
453
- )
456
+ manager = BuildManager(test_project_root, src_dir, exclude_patterns=["custom_skip"])
454
457
 
455
- external_deps = manager.prepare_build()
458
+ manager.prepare_build()
456
459
 
457
460
  # Verify custom_skip was NOT copied
458
461
  copied_custom = src_dir / "custom_skip"
@@ -463,7 +466,7 @@ class TestExclusionPatterns:
463
466
  def test_exclude_multiple_patterns(self, test_project_root: Path) -> None:
464
467
  """Test that multiple exclusion patterns work."""
465
468
  folder_structure = test_project_root / "folder_structure"
466
-
469
+
467
470
  # Create directories with different exclusion patterns
468
471
  sandbox_dir = folder_structure / "_sandbox"
469
472
  sandbox_dir.mkdir()
@@ -476,12 +479,12 @@ class TestExclusionPatterns:
476
479
  src_dir = test_project_root / "folder_structure" / "subfolder_to_build"
477
480
  manager = BuildManager(test_project_root, src_dir)
478
481
 
479
- external_deps = manager.prepare_build()
482
+ manager.prepare_build()
480
483
 
481
484
  # Verify excluded directories were NOT copied
482
485
  copied_sandbox = src_dir / "_sandbox"
483
486
  copied_skip = src_dir / "_skip"
484
-
487
+
485
488
  assert not copied_sandbox.exists(), "_sandbox should be excluded"
486
489
  assert not copied_skip.exists(), "_skip should be excluded"
487
490
 
@@ -490,13 +493,13 @@ class TestExclusionPatterns:
490
493
  def test_exclude_nested_ss_directories(self, test_project_root: Path) -> None:
491
494
  """Test that _SS directories are excluded even when nested."""
492
495
  folder_structure = test_project_root / "folder_structure"
493
-
496
+
494
497
  # Create a nested structure with _SS
495
498
  nested_package = folder_structure / "nested_package"
496
499
  nested_package.mkdir()
497
500
  (nested_package / "__init__.py").write_text("")
498
501
  (nested_package / "module.py").write_text("def func(): pass")
499
-
502
+
500
503
  # Create nested _SS directory
501
504
  nested_ss = nested_package / "_SS"
502
505
  nested_ss.mkdir()
@@ -516,7 +519,7 @@ from some_globals import SOME_GLOBAL_VARIABLE
516
519
  src_dir = subfolder_to_build
517
520
  manager = BuildManager(test_project_root, src_dir)
518
521
 
519
- external_deps = manager.prepare_build()
522
+ manager.prepare_build()
520
523
 
521
524
  # Verify nested_package was copied
522
525
  copied_nested = src_dir / "nested_package"
@@ -544,7 +547,9 @@ from some_globals import SOME_GLOBAL_VARIABLE
544
547
  assert "_SS" not in source_str, f"Dependency should not include _SS: {dep.source_path}"
545
548
  # Check all path components
546
549
  for part in dep.source_path.parts:
547
- assert not part.startswith("_SS"), f"Path component should not start with _SS: {part}"
550
+ assert not part.startswith("_SS"), (
551
+ f"Path component should not start with _SS: {part}"
552
+ )
548
553
 
549
554
 
550
555
  class TestEdgeCases:
@@ -0,0 +1,62 @@
1
+ """Tests for code quality checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ class TestLinting:
11
+ """Tests for linting and code quality."""
12
+
13
+ def test_ruff_check_passes(self) -> None:
14
+ """Test that ruff linting passes."""
15
+ # Get the project root directory
16
+ project_root = Path(__file__).parent.parent
17
+
18
+ # Run ruff check
19
+ result = subprocess.run(
20
+ [sys.executable, "-m", "ruff", "check", "."],
21
+ cwd=project_root,
22
+ capture_output=True,
23
+ text=True,
24
+ )
25
+
26
+ # If ruff fails, print the output for debugging
27
+ if result.returncode != 0:
28
+ print("Ruff check failed with output:")
29
+ print(result.stdout)
30
+ print(result.stderr)
31
+
32
+ assert result.returncode == 0, "Ruff linting should pass without errors"
33
+
34
+ def test_ruff_format_check_passes(self) -> None:
35
+ """Test that ruff format check passes.
36
+
37
+ Note: This test may fail if files need formatting. Run `ruff format .` to fix.
38
+ """
39
+ # Get the project root directory
40
+ project_root = Path(__file__).parent.parent
41
+
42
+ # Run ruff format --check
43
+ result = subprocess.run(
44
+ [sys.executable, "-m", "ruff", "format", "--check", "."],
45
+ cwd=project_root,
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+
50
+ # If ruff format check fails, print the output for debugging
51
+ if result.returncode != 0:
52
+ print("Ruff format check failed with output:")
53
+ print(result.stdout)
54
+ print(result.stderr)
55
+ print("\nTo fix formatting issues, run: ruff format .")
56
+
57
+ # Note: We check format but don't fail the test if formatting is needed
58
+ # This allows the test to document that formatting should be checked
59
+ # In CI, the format check step will catch formatting issues
60
+ assert result.returncode == 0, (
61
+ "Ruff format check should pass. Run 'ruff format .' to fix formatting issues."
62
+ )
@@ -200,4 +200,3 @@ class TestPublisher:
200
200
 
201
201
  assert username == "__token__"
202
202
  assert password == "pypi-AgENdGVzdC5weXBpLm9yZwIk"
203
-
@@ -108,7 +108,7 @@ class TestSubfolderBuildConfig:
108
108
  assert 'version = "2.0.0"' in content
109
109
 
110
110
  # Check dynamic versioning is removed
111
- assert "dynamic = [\"version\"]" not in content
111
+ assert 'dynamic = ["version"]' not in content
112
112
  assert "[tool.hatch.version]" not in content
113
113
  assert "[tool.uv-dynamic-versioning]" not in content
114
114
 
@@ -128,7 +128,7 @@ class TestSubfolderBuildConfig:
128
128
 
129
129
  # Check dependency group is included
130
130
  assert "[dependency-groups]" in content
131
- assert 'dev = [' in content
131
+ assert "dev = [" in content
132
132
  assert '"pytest>=8.0.0"' in content
133
133
 
134
134
  def test_create_temp_pyproject_creates_init(self, test_project_with_pyproject: Path) -> None:
@@ -242,10 +242,10 @@ class TestSubfolderBuildConfig:
242
242
  version="1.0.0",
243
243
  dependency_group="nonexistent",
244
244
  )
245
-
245
+
246
246
  # Create temp pyproject - this should print a warning
247
247
  config.create_temp_pyproject()
248
-
248
+
249
249
  # The warning is printed to stderr during create_temp_pyproject
250
250
  # Since capsys might not capture it properly, we'll just verify
251
251
  # that the build still works (warning is non-fatal)
@@ -288,38 +288,38 @@ def test_readme_handling_with_existing_readme(test_project_with_pyproject: Path)
288
288
  """Test that subfolder README is used when it exists."""
289
289
  project_root = test_project_with_pyproject
290
290
  subfolder = project_root / "subfolder"
291
-
291
+
292
292
  # Create README in subfolder
293
293
  subfolder_readme = subfolder / "README.md"
294
294
  subfolder_readme.write_text("# Subfolder Package\n\nThis is the subfolder README.")
295
-
295
+
296
296
  # Create README in project root
297
297
  project_readme = project_root / "README.md"
298
298
  project_readme.write_text("# Parent Package\n\nThis is the parent README.")
299
-
299
+
300
300
  config = SubfolderBuildConfig(
301
301
  project_root=project_root,
302
302
  src_dir=subfolder,
303
303
  version="1.0.0",
304
304
  )
305
-
305
+
306
306
  try:
307
307
  config.create_temp_pyproject()
308
-
308
+
309
309
  # Check that subfolder README was copied to project root
310
310
  assert (project_root / "README.md").exists()
311
311
  content = (project_root / "README.md").read_text()
312
312
  assert "Subfolder Package" in content
313
313
  assert "This is the subfolder README" in content
314
314
  assert "Parent Package" not in content
315
-
315
+
316
316
  # Check that backup was created
317
317
  assert (project_root / "README.md.backup").exists()
318
318
  backup_content = (project_root / "README.md.backup").read_text()
319
319
  assert "Parent Package" in backup_content
320
320
  finally:
321
321
  config.restore()
322
-
322
+
323
323
  # Verify original README was restored
324
324
  assert (project_root / "README.md").exists()
325
325
  restored_content = (project_root / "README.md").read_text()
@@ -332,29 +332,31 @@ def test_readme_handling_without_readme(test_project_with_pyproject: Path):
332
332
  """Test that minimal README is created when subfolder has no README."""
333
333
  project_root = test_project_with_pyproject
334
334
  subfolder = project_root / "subfolder"
335
-
335
+
336
336
  # Ensure no README exists
337
337
  assert not (subfolder / "README.md").exists()
338
338
  assert not (subfolder / "README.rst").exists()
339
-
339
+
340
340
  config = SubfolderBuildConfig(
341
341
  project_root=project_root,
342
342
  src_dir=subfolder,
343
343
  version="1.0.0",
344
344
  )
345
-
345
+
346
346
  try:
347
347
  config.create_temp_pyproject()
348
-
348
+
349
349
  # Check that minimal README was created
350
350
  assert (project_root / "README.md").exists()
351
351
  content = (project_root / "README.md").read_text()
352
352
  assert content.strip() == f"# {subfolder.name}"
353
353
  finally:
354
354
  config.restore()
355
-
355
+
356
356
  # Verify README was removed if it didn't exist before
357
357
  if not (project_root / "README.md.backup").exists():
358
358
  # No backup means no original README, so temp should be removed
359
- assert not (project_root / "README.md").exists() or (project_root / "README.md").read_text() != f"# {subfolder.name}\n"
360
-
359
+ assert (
360
+ not (project_root / "README.md").exists()
361
+ or (project_root / "README.md").read_text() != f"# {subfolder.name}\n"
362
+ )
@@ -45,7 +45,9 @@ class TestFindProjectRoot:
45
45
 
46
46
  assert found is None
47
47
 
48
- def test_find_project_root_defaults_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
48
+ def test_find_project_root_defaults_to_cwd(
49
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
50
+ ) -> None:
49
51
  """Test that find_project_root defaults to current directory."""
50
52
  project_root = tmp_path / "project"
51
53
  project_root.mkdir()
@@ -156,4 +158,3 @@ class TestIsPythonPackageDirectory:
156
158
  pkg_dir.mkdir()
157
159
 
158
160
  assert is_python_package_directory(pkg_dir) is False
159
-
@@ -70,7 +70,7 @@ class TestVersionManager:
70
70
  # Check version was set in file
71
71
  content = (test_pyproject / "pyproject.toml").read_text()
72
72
  assert '"2.0.0"' in content or "'2.0.0'" in content
73
-
73
+
74
74
  # Check version can be read back (may need to re-read)
75
75
  # The set_version modifies the file, so get_current_version should work
76
76
  content_after = (test_pyproject / "pyproject.toml").read_text()
@@ -129,9 +129,7 @@ class TestVersionManager:
129
129
  assert "[tool.hatch.version]" in content
130
130
  assert "[tool.uv-dynamic-versioning]" in content
131
131
 
132
- def test_restore_dynamic_versioning_with_existing_version(
133
- self, test_pyproject: Path
134
- ) -> None:
132
+ def test_restore_dynamic_versioning_with_existing_version(self, test_pyproject: Path) -> None:
135
133
  """Test restoring dynamic versioning when static version exists."""
136
134
  manager = VersionManager(test_pyproject)
137
135
 
@@ -145,4 +143,3 @@ class TestVersionManager:
145
143
  content = (test_pyproject / "pyproject.toml").read_text()
146
144
  # Version should be removed or dynamic should be added
147
145
  assert "[tool.hatch.version]" in content or 'dynamic = ["version"]' in content
148
-
@@ -4,4 +4,4 @@ from folder_structure.subfolder_to_build.some_function import print_and_return_g
4
4
 
5
5
  def test_normal_execution():
6
6
  variable = print_and_return_global_variable()
7
- assert variable == SOME_GLOBAL_VARIABLE
7
+ assert variable == SOME_GLOBAL_VARIABLE