python-package-folder 5.1.6__tar.gz → 5.2.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 (60) hide show
  1. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/PKG-INFO +1 -1
  2. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/pyproject.toml +1 -1
  3. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/publisher.py +50 -2
  4. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/version_calculator.py +160 -17
  5. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_version_calculator.py +54 -2
  6. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.copier-answers.yml +0 -0
  7. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  8. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.cursor/plans/replace_node.js_semantic-release_with_custom_python_implementation_64e05e1a.plan.md +0 -0
  9. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.cursor/rules/general.mdc +0 -0
  10. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.cursor/rules/python.mdc +0 -0
  11. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.github/workflows/ci.yml +0 -0
  12. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.github/workflows/publish.yml +0 -0
  13. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.gitignore +0 -0
  14. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/.vscode/settings.json +0 -0
  15. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/LICENSE +0 -0
  16. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/MANIFEST.in +0 -0
  17. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/Makefile +0 -0
  18. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/README.md +0 -0
  19. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/coverage.svg +0 -0
  20. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/development.md +0 -0
  21. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/docs/DEVELOPMENT.md +0 -0
  22. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/docs/INSTALLATION.md +0 -0
  23. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/docs/PUBLISHING.md +0 -0
  24. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/docs/REFERENCE.md +0 -0
  25. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/docs/USAGE.md +0 -0
  26. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/docs/VERSION_RESOLUTION.md +0 -0
  27. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/installation.md +0 -0
  28. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/publishing.md +0 -0
  29. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/__init__.py +0 -0
  30. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/__main__.py +0 -0
  31. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/analyzer.py +0 -0
  32. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/finder.py +0 -0
  33. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/manager.py +0 -0
  34. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/py.typed +0 -0
  35. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/python_package_folder.py +0 -0
  36. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/subfolder_build.py +0 -0
  37. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/types.py +0 -0
  38. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/utils.py +0 -0
  39. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/src/python_package_folder/version.py +0 -0
  40. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/conftest.py +0 -0
  41. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/folder_structure/some_globals.py +0 -0
  42. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  43. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  44. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  45. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  46. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  47. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  48. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_build_with_external_deps.py +0 -0
  49. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_exclude_patterns.py +0 -0
  50. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_linting.py +0 -0
  51. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_preserve_directory_structure.py +0 -0
  52. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_publisher.py +0 -0
  53. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_shared_subdirectory_imports.py +0 -0
  54. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_spreadsheet_creation_imports.py +0 -0
  55. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_subfolder_build.py +0 -0
  56. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_third_party_dependencies.py +0 -0
  57. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_utils.py +0 -0
  58. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/test_version_manager.py +0 -0
  59. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/tests/tests.py +0 -0
  60. {python_package_folder-5.1.6 → python_package_folder-5.2.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 5.1.6
3
+ Version: 5.2.1
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>
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  # ---- Dev dependencies ----
45
45
 
46
- version = "5.1.6"
46
+ version = "5.2.1"
47
47
  [dependency-groups]
48
48
  dev = [
49
49
  "pytest>=8.3.5",
@@ -310,11 +310,59 @@ class Publisher:
310
310
  print(f"Files to upload: {len(dist_files)}")
311
311
 
312
312
  try:
313
- subprocess.run(cmd, check=True, text=True)
313
+ result = subprocess.run(
314
+ cmd,
315
+ check=True,
316
+ text=True,
317
+ capture_output=True,
318
+ )
319
+ # Print twine output if available
320
+ if result.stdout:
321
+ print(result.stdout)
322
+ if result.stderr:
323
+ print(result.stderr, file=sys.stderr)
314
324
  print(f"\n✓ Successfully published to {self.repository.value}")
315
325
  except subprocess.CalledProcessError as e:
316
326
  print(f"\n✗ Failed to publish to {self.repository.value}", file=sys.stderr)
317
- print(f"Error: {e}", file=sys.stderr)
327
+
328
+ # Extract and display twine's actual error message
329
+ error_details = []
330
+ if e.stdout:
331
+ error_details.append(f"stdout: {e.stdout}")
332
+ if e.stderr:
333
+ error_details.append(f"stderr: {e.stderr}")
334
+ if e.returncode is not None:
335
+ error_details.append(f"exit code: {e.returncode}")
336
+
337
+ if error_details:
338
+ print("Twine error details:", file=sys.stderr)
339
+ for detail in error_details:
340
+ print(f" {detail}", file=sys.stderr)
341
+ else:
342
+ # Fallback to generic error if no output captured
343
+ print(f"Command failed: {' '.join(cmd)}", file=sys.stderr)
344
+ print(f"Return code: {e.returncode}", file=sys.stderr)
345
+
346
+ # Provide helpful hints based on common errors
347
+ if e.returncode == 1:
348
+ if e.stderr and ("already exists" in e.stderr.lower() or "409" in e.stderr or "conflict" in e.stderr.lower()):
349
+ print(
350
+ "\nHint: This version may already exist on the repository. "
351
+ "Use --skip-existing to skip files that already exist, "
352
+ "or publish a new version.",
353
+ file=sys.stderr,
354
+ )
355
+ elif e.stderr and ("401" in e.stderr or "unauthorized" in e.stderr.lower()):
356
+ print(
357
+ "\nHint: Authentication failed. Check your credentials.",
358
+ file=sys.stderr,
359
+ )
360
+ elif e.stderr and ("403" in e.stderr or "forbidden" in e.stderr.lower()):
361
+ print(
362
+ "\nHint: Access forbidden. Check your permissions for this repository.",
363
+ file=sys.stderr,
364
+ )
365
+
318
366
  raise
319
367
 
320
368
  def publish_interactive(self, skip_existing: bool = False) -> None:
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  import logging
14
14
  import re
15
15
  import subprocess
16
+ from html.parser import HTMLParser
16
17
  from pathlib import Path
17
18
 
18
19
  import requests
@@ -110,6 +111,76 @@ def _query_pypi_version(package_name: str, registry: str) -> str | None:
110
111
  return None
111
112
 
112
113
 
114
+ class SimpleIndexParser(HTMLParser):
115
+ """Parser for PEP 503 simple index HTML to extract package versions."""
116
+
117
+ def __init__(self, package_name: str):
118
+ super().__init__()
119
+ self.package_name = package_name
120
+ self.versions: set[str] = set()
121
+ self.in_anchor = False
122
+ self.current_href = ""
123
+ self.links_processed = 0
124
+
125
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
126
+ if tag == "a":
127
+ self.in_anchor = True
128
+ # Extract href attribute
129
+ for attr_name, attr_value in attrs:
130
+ if attr_name == "href" and attr_value:
131
+ self.current_href = attr_value
132
+ break
133
+
134
+ def handle_data(self, data: str) -> None:
135
+ if self.in_anchor:
136
+ # Extract version from link text or href
137
+ # Format: package-name-version-... or package-name-version.tar.gz
138
+ link_text = data.strip()
139
+ if link_text:
140
+ logger.debug(f"Processing link text: '{link_text}'")
141
+ version = self._extract_version_from_filename(link_text)
142
+ if version:
143
+ logger.debug(f"Extracted version '{version}' from link text: '{link_text}'")
144
+ self.versions.add(version)
145
+ # Also check href if it contains version info
146
+ if self.current_href:
147
+ logger.debug(f"Processing href: '{self.current_href}'")
148
+ version = self._extract_version_from_filename(self.current_href)
149
+ if version:
150
+ logger.debug(f"Extracted version '{version}' from href: '{self.current_href}'")
151
+ self.versions.add(version)
152
+
153
+ def handle_endtag(self, tag: str) -> None:
154
+ if tag == "a":
155
+ self.links_processed += 1
156
+ self.in_anchor = False
157
+ self.current_href = ""
158
+
159
+ def _extract_version_from_filename(self, filename: str) -> str | None:
160
+ """Extract version number from package filename."""
161
+ # Pattern: package-name-version-... or package-name-version.tar.gz
162
+ # Examples: data-0.1.0-py3-none-any.whl, data-0.1.0.tar.gz
163
+ # The version is between the package name and the next separator
164
+
165
+ # Normalize package name (replace - with _ for matching)
166
+ normalized_package = self.package_name.replace("-", "_").replace(".", "_")
167
+
168
+ # Try to match: package-name-version- or package-name-version.
169
+ # Version format: X.Y.Z (semantic versioning)
170
+ pattern = rf"{re.escape(self.package_name)}-(\d+\.\d+\.\d+(?:\.\d+)?(?:[a-zA-Z0-9]+)?)"
171
+ match = re.search(pattern, filename, re.IGNORECASE)
172
+ if match:
173
+ return match.group(1)
174
+
175
+ # Fallback: try with normalized package name
176
+ pattern = rf"{re.escape(normalized_package)}-(\d+\.\d+\.\d+(?:\.\d+)?(?:[a-zA-Z0-9]+)?)"
177
+ match = re.search(pattern, filename, re.IGNORECASE)
178
+ if match:
179
+ return match.group(1)
180
+
181
+ return None
182
+
183
+
113
184
  def _query_azure_artifacts_version(
114
185
  package_name: str,
115
186
  repository_url: str,
@@ -117,8 +188,8 @@ def _query_azure_artifacts_version(
117
188
  """
118
189
  Query Azure Artifacts for the latest version.
119
190
 
120
- Azure Artifacts uses a simple index format (HTML) which is more complex to parse.
121
- For now, we'll attempt to query but fall back gracefully if it fails.
191
+ Azure Artifacts uses a simple index format (HTML) following PEP 503.
192
+ Parses the HTML to extract version numbers from package filenames.
122
193
 
123
194
  Args:
124
195
  package_name: Package name to query
@@ -135,35 +206,107 @@ def _query_azure_artifacts_version(
135
206
  simple_index_url = repository_url.replace("/upload", f"/simple/{package_name}/")
136
207
  else:
137
208
  simple_index_url = repository_url.rstrip("/") + f"/simple/{package_name}/"
138
- logger.debug(f"Constructed Azure Artifacts simple index URL: {simple_index_url}")
209
+ logger.info(f"Constructed Azure Artifacts simple index URL: {simple_index_url}")
139
210
  except Exception as e:
140
211
  logger.warning(f"Error constructing Azure Artifacts URL for '{package_name}': {e}")
141
212
  return None
142
213
 
143
214
  try:
144
- response = requests.get(simple_index_url, timeout=5)
145
- logger.debug(f"Azure Artifacts response status: {response.status_code}")
215
+ logger.info(f"Fetching Azure Artifacts simple index for '{package_name}'...")
216
+ response = requests.get(simple_index_url, timeout=10)
217
+ logger.info(f"Azure Artifacts response: status={response.status_code}, content_length={len(response.text)} bytes")
146
218
 
147
219
  if response.status_code == 401:
148
- logger.warning(f"Authentication required for Azure Artifacts (401). Package '{package_name}' may require authentication to query.")
220
+ logger.warning(
221
+ f"Authentication required for Azure Artifacts (401). "
222
+ f"Package '{package_name}' may require authentication to query. "
223
+ f"URL: {simple_index_url}"
224
+ )
225
+ return None
149
226
  elif response.status_code == 403:
150
- logger.warning(f"Access forbidden for Azure Artifacts (403). Package '{package_name}' may not be accessible or requires different permissions.")
227
+ logger.warning(
228
+ f"Access forbidden for Azure Artifacts (403). "
229
+ f"Package '{package_name}' may not be accessible or requires different permissions. "
230
+ f"URL: {simple_index_url}"
231
+ )
232
+ return None
151
233
  elif response.status_code == 404:
152
- logger.debug(f"Package '{package_name}' not found on Azure Artifacts (404) - first release")
234
+ logger.info(
235
+ f"Package '{package_name}' not found on Azure Artifacts (404) - this appears to be the first release. "
236
+ f"URL: {simple_index_url}"
237
+ )
238
+ return None
153
239
  elif response.status_code != 200:
154
- logger.warning(f"Unexpected status code {response.status_code} from Azure Artifacts for '{package_name}'")
240
+ logger.warning(
241
+ f"Unexpected status code {response.status_code} from Azure Artifacts for '{package_name}'. "
242
+ f"URL: {simple_index_url}, Response preview: {response.text[:200]}"
243
+ )
244
+ return None
155
245
 
156
- # Azure Artifacts simple index returns HTML, not JSON
157
- # Parsing HTML is complex and may require authentication
158
- # For now, we'll return None to fall back to git tags
159
- # This can be enhanced later with proper HTML parsing or API endpoint discovery
160
- logger.info(f"Azure Artifacts version query not fully implemented (HTML parsing required). Falling back to git tags.")
161
- return None
246
+ # Parse HTML to extract versions
247
+ logger.info(f"Parsing HTML response to extract versions for '{package_name}'...")
248
+ parser = SimpleIndexParser(package_name)
249
+ try:
250
+ parser.feed(response.text)
251
+ logger.info(
252
+ f"HTML parsing completed: processed {parser.links_processed} link(s), "
253
+ f"found {len(parser.versions)} unique version(s)"
254
+ )
255
+ except Exception as e:
256
+ logger.warning(
257
+ f"Error parsing Azure Artifacts HTML for '{package_name}': {e}. "
258
+ f"Response length: {len(response.text)} bytes, "
259
+ f"Response preview: {response.text[:500]}"
260
+ )
261
+ return None
262
+
263
+ if not parser.versions:
264
+ if parser.links_processed == 0:
265
+ logger.info(
266
+ f"No links found in Azure Artifacts HTML for '{package_name}'. "
267
+ f"This may indicate: (1) HTML structure differs from PEP 503 format, "
268
+ f"(2) package doesn't exist, or (3) authentication required. "
269
+ f"Response preview: {response.text[:500]}"
270
+ )
271
+ else:
272
+ logger.info(
273
+ f"Found {parser.links_processed} link(s) but no versions extracted for '{package_name}'. "
274
+ f"This may indicate: (1) package name mismatch (expected '{package_name}'), "
275
+ f"(2) filename format differs from expected pattern, or (3) first release. "
276
+ f"Response preview: {response.text[:500]}"
277
+ )
278
+ return None
279
+
280
+ # Find the latest version
281
+ versions = list(parser.versions)
282
+ logger.info(f"Found {len(versions)} version(s) in Azure Artifacts HTML: {versions}")
283
+
284
+ # Sort versions to find the latest
285
+ try:
286
+ sorted_versions = sorted(versions, key=_parse_version_for_sort, reverse=True)
287
+ latest_version = sorted_versions[0]
288
+ logger.info(f"Latest version on Azure Artifacts for '{package_name}': {latest_version}")
289
+ return latest_version
290
+ except Exception as e:
291
+ logger.warning(
292
+ f"Error sorting versions for '{package_name}': {e}. "
293
+ f"Versions found: {versions}. Using first version as fallback."
294
+ )
295
+ # Fallback: return the first version found
296
+ return versions[0]
297
+
162
298
  except requests.RequestException as e:
163
- logger.warning(f"Network error querying Azure Artifacts for '{package_name}': {e}")
299
+ logger.warning(
300
+ f"Network error querying Azure Artifacts for '{package_name}': {e}. "
301
+ f"URL: {simple_index_url}"
302
+ )
164
303
  return None
165
304
  except Exception as e:
166
- logger.warning(f"Unexpected error querying Azure Artifacts for '{package_name}': {e}", exc_info=True)
305
+ logger.warning(
306
+ f"Unexpected error querying Azure Artifacts for '{package_name}': {e}. "
307
+ f"URL: {simple_index_url}",
308
+ exc_info=True
309
+ )
167
310
  return None
168
311
 
169
312
 
@@ -86,9 +86,22 @@ class TestQueryRegistryVersion:
86
86
 
87
87
  @patch("python_package_folder.version_calculator.requests.get")
88
88
  def test_query_azure_artifacts_version(self, mock_get: MagicMock) -> None:
89
- """Test querying Azure Artifacts (basic support, returns None for now)."""
89
+ """Test querying Azure Artifacts with HTML parsing."""
90
90
  mock_response = Mock()
91
91
  mock_response.status_code = 200
92
+ # Simulate PEP 503 simple index HTML response
93
+ mock_response.text = """<!DOCTYPE html>
94
+ <html>
95
+ <head>
96
+ <title>Links for test-package</title>
97
+ </head>
98
+ <body>
99
+ <h1>Links for test-package</h1>
100
+ <a href="test-package-0.1.0-py3-none-any.whl">test-package-0.1.0-py3-none-any.whl</a>
101
+ <a href="test-package-0.2.0-py3-none-any.whl">test-package-0.2.0-py3-none-any.whl</a>
102
+ <a href="test-package-0.1.5.tar.gz">test-package-0.1.5.tar.gz</a>
103
+ </body>
104
+ </html>"""
92
105
  mock_get.return_value = mock_response
93
106
 
94
107
  version = query_registry_version(
@@ -96,7 +109,46 @@ class TestQueryRegistryVersion:
96
109
  "azure",
97
110
  repository_url="https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/pypi/upload",
98
111
  )
99
- # Azure Artifacts parsing not fully implemented, returns None
112
+ # Should parse HTML and return the latest version
113
+ assert version == "0.2.0"
114
+
115
+ @patch("python_package_folder.version_calculator.requests.get")
116
+ def test_query_azure_artifacts_version_not_found(self, mock_get: MagicMock) -> None:
117
+ """Test querying Azure Artifacts when package doesn't exist (404)."""
118
+ mock_response = Mock()
119
+ mock_response.status_code = 404
120
+ mock_get.return_value = mock_response
121
+
122
+ version = query_registry_version(
123
+ "test-package",
124
+ "azure",
125
+ repository_url="https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/pypi/upload",
126
+ )
127
+ # Should return None for 404 (first release)
128
+ assert version is None
129
+
130
+ @patch("python_package_folder.version_calculator.requests.get")
131
+ def test_query_azure_artifacts_version_empty_html(self, mock_get: MagicMock) -> None:
132
+ """Test querying Azure Artifacts with empty HTML (no versions)."""
133
+ mock_response = Mock()
134
+ mock_response.status_code = 200
135
+ mock_response.text = """<!DOCTYPE html>
136
+ <html>
137
+ <head>
138
+ <title>Links for test-package</title>
139
+ </head>
140
+ <body>
141
+ <h1>Links for test-package</h1>
142
+ </body>
143
+ </html>"""
144
+ mock_get.return_value = mock_response
145
+
146
+ version = query_registry_version(
147
+ "test-package",
148
+ "azure",
149
+ repository_url="https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/pypi/upload",
150
+ )
151
+ # Should return None when no versions found in HTML
100
152
  assert version is None
101
153
 
102
154
  @patch("python_package_folder.version_calculator.requests.get")