python-package-folder 5.1.5__tar.gz → 5.2.0__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.5 → python_package_folder-5.2.0}/PKG-INFO +1 -1
  2. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/pyproject.toml +1 -1
  3. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/publisher.py +60 -14
  4. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/version_calculator.py +97 -9
  5. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_version_calculator.py +54 -2
  6. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.copier-answers.yml +0 -0
  7. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  8. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.cursor/plans/replace_node.js_semantic-release_with_custom_python_implementation_64e05e1a.plan.md +0 -0
  9. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.cursor/rules/general.mdc +0 -0
  10. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.cursor/rules/python.mdc +0 -0
  11. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.github/workflows/ci.yml +0 -0
  12. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.github/workflows/publish.yml +0 -0
  13. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.gitignore +0 -0
  14. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/.vscode/settings.json +0 -0
  15. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/LICENSE +0 -0
  16. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/MANIFEST.in +0 -0
  17. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/Makefile +0 -0
  18. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/README.md +0 -0
  19. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/coverage.svg +0 -0
  20. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/development.md +0 -0
  21. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/docs/DEVELOPMENT.md +0 -0
  22. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/docs/INSTALLATION.md +0 -0
  23. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/docs/PUBLISHING.md +0 -0
  24. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/docs/REFERENCE.md +0 -0
  25. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/docs/USAGE.md +0 -0
  26. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/docs/VERSION_RESOLUTION.md +0 -0
  27. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/installation.md +0 -0
  28. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/publishing.md +0 -0
  29. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/__init__.py +0 -0
  30. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/__main__.py +0 -0
  31. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/analyzer.py +0 -0
  32. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/finder.py +0 -0
  33. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/manager.py +0 -0
  34. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/py.typed +0 -0
  35. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/python_package_folder.py +0 -0
  36. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/subfolder_build.py +0 -0
  37. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/types.py +0 -0
  38. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/utils.py +0 -0
  39. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/src/python_package_folder/version.py +0 -0
  40. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/conftest.py +0 -0
  41. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/folder_structure/some_globals.py +0 -0
  42. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  43. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  44. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  45. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  46. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  47. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  48. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_build_with_external_deps.py +0 -0
  49. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_exclude_patterns.py +0 -0
  50. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_linting.py +0 -0
  51. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_preserve_directory_structure.py +0 -0
  52. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_publisher.py +0 -0
  53. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_shared_subdirectory_imports.py +0 -0
  54. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_spreadsheet_creation_imports.py +0 -0
  55. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_subfolder_build.py +0 -0
  56. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_third_party_dependencies.py +0 -0
  57. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_utils.py +0 -0
  58. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/test_version_manager.py +0 -0
  59. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/tests/tests.py +0 -0
  60. {python_package_folder-5.1.5 → python_package_folder-5.2.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 5.1.5
3
+ Version: 5.2.0
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.5"
46
+ version = "5.2.0"
47
47
  [dependency-groups]
48
48
  dev = [
49
49
  "pytest>=8.3.5",
@@ -8,6 +8,7 @@ repositories including PyPI, PyPI Test, and Azure Artifacts.
8
8
  from __future__ import annotations
9
9
 
10
10
  import getpass
11
+ import os
11
12
  import subprocess
12
13
  import sys
13
14
  from enum import Enum
@@ -19,6 +20,18 @@ except ImportError:
19
20
  keyring = None
20
21
 
21
22
 
23
+ def _is_non_interactive() -> bool:
24
+ """Check if running in a non-interactive environment (CI/CD)."""
25
+ # Check for common CI environment variables
26
+ ci_vars = ["GITHUB_ACTIONS", "CI", "CONTINUOUS_INTEGRATION", "TF_BUILD"]
27
+ if any(os.getenv(var) for var in ci_vars):
28
+ return True
29
+ # Check if stdin is not a TTY (non-interactive)
30
+ if not sys.stdin.isatty():
31
+ return True
32
+ return False
33
+
34
+
22
35
  class Repository(Enum):
23
36
  """
24
37
  Supported package repositories.
@@ -112,9 +125,9 @@ class Publisher:
112
125
  """
113
126
  Get credentials for publishing.
114
127
 
115
- Always prompts for username and password/token if not already provided.
116
- Does not use keyring to store/retrieve credentials - credentials must be
117
- provided via command-line arguments or will be prompted each time.
128
+ Prompts for username and password/token if not already provided.
129
+ In non-interactive environments (CI/CD), checks environment variables
130
+ or raises an error if credentials are missing.
118
131
 
119
132
  Returns:
120
133
  Tuple of (username, password/token)
@@ -122,21 +135,54 @@ class Publisher:
122
135
  username = self.username
123
136
  password = self.password
124
137
 
125
- # Always prompt if not provided via command-line arguments
126
- # We don't use keyring to avoid storing credentials
138
+ is_non_interactive_env = _is_non_interactive()
139
+
140
+ # Get username
127
141
  if not username:
128
- username = input(f"Enter username for {self.repository.value}: ").strip()
129
- if not username:
130
- raise ValueError("Username is required")
142
+ if is_non_interactive_env:
143
+ # Check environment variables
144
+ username = os.getenv("TWINE_USERNAME") or os.getenv("PYPI_USERNAME")
145
+ if not username:
146
+ raise ValueError(
147
+ f"Username is required for publishing to {self.repository.value} in CI/CD. "
148
+ "Please provide --username argument or set TWINE_USERNAME/PYPI_USERNAME environment variable."
149
+ )
150
+ else:
151
+ username = input(f"Enter username for {self.repository.value}: ").strip()
152
+ if not username:
153
+ raise ValueError("Username is required")
131
154
 
155
+ # Get password
132
156
  if not password:
133
- if self.repository == Repository.AZURE:
134
- prompt = f"Enter Azure Artifacts token for {username}: "
157
+ if is_non_interactive_env:
158
+ # Check environment variables (common names used by twine and CI/CD)
159
+ password = (
160
+ os.getenv("TWINE_PASSWORD")
161
+ or os.getenv("PYPI_PASSWORD")
162
+ or os.getenv("AZURE_ARTIFACTS_TOKEN") # For Azure
163
+ )
164
+ if not password:
165
+ raise ValueError(
166
+ f"Password/token is required for publishing to {self.repository.value} in CI/CD. "
167
+ "Please provide --password argument or set one of: "
168
+ "TWINE_PASSWORD, PYPI_PASSWORD, or AZURE_ARTIFACTS_TOKEN environment variable."
169
+ )
135
170
  else:
136
- prompt = f"Enter PyPI token for {username} (or __token__ for API token): "
137
- password = getpass.getpass(prompt)
138
- if not password:
139
- raise ValueError("Password/token is required")
171
+ if self.repository == Repository.AZURE:
172
+ prompt = f"Enter Azure Artifacts token for {username}: "
173
+ else:
174
+ prompt = f"Enter PyPI token for {username} (or __token__ for API token): "
175
+ try:
176
+ password = getpass.getpass(prompt)
177
+ except (EOFError, OSError):
178
+ # Handle non-interactive environments gracefully
179
+ raise ValueError(
180
+ f"Password/token is required for publishing to {self.repository.value}. "
181
+ "Cannot prompt for password in non-interactive environment. "
182
+ "Please provide --password argument or set TWINE_PASSWORD/PYPI_PASSWORD environment variable."
183
+ )
184
+ if not password:
185
+ raise ValueError("Password/token is required")
140
186
 
141
187
  # Auto-detect if password is an API token and adjust username
142
188
  if password.startswith("pypi-") or password.startswith("pypi_Ag"):
@@ -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,68 @@ 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
+
124
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
125
+ if tag == "a":
126
+ self.in_anchor = True
127
+ # Extract href attribute
128
+ for attr_name, attr_value in attrs:
129
+ if attr_name == "href" and attr_value:
130
+ self.current_href = attr_value
131
+ break
132
+
133
+ def handle_data(self, data: str) -> None:
134
+ if self.in_anchor:
135
+ # Extract version from link text or href
136
+ # Format: package-name-version-... or package-name-version.tar.gz
137
+ version = self._extract_version_from_filename(data.strip())
138
+ if version:
139
+ self.versions.add(version)
140
+ # Also check href if it contains version info
141
+ if self.current_href:
142
+ version = self._extract_version_from_filename(self.current_href)
143
+ if version:
144
+ self.versions.add(version)
145
+
146
+ def handle_endtag(self, tag: str) -> None:
147
+ if tag == "a":
148
+ self.in_anchor = False
149
+ self.current_href = ""
150
+
151
+ def _extract_version_from_filename(self, filename: str) -> str | None:
152
+ """Extract version number from package filename."""
153
+ # Pattern: package-name-version-... or package-name-version.tar.gz
154
+ # Examples: data-0.1.0-py3-none-any.whl, data-0.1.0.tar.gz
155
+ # The version is between the package name and the next separator
156
+
157
+ # Normalize package name (replace - with _ for matching)
158
+ normalized_package = self.package_name.replace("-", "_").replace(".", "_")
159
+
160
+ # Try to match: package-name-version- or package-name-version.
161
+ # Version format: X.Y.Z (semantic versioning)
162
+ pattern = rf"{re.escape(self.package_name)}-(\d+\.\d+\.\d+(?:\.\d+)?(?:[a-zA-Z0-9]+)?)"
163
+ match = re.search(pattern, filename, re.IGNORECASE)
164
+ if match:
165
+ return match.group(1)
166
+
167
+ # Fallback: try with normalized package name
168
+ pattern = rf"{re.escape(normalized_package)}-(\d+\.\d+\.\d+(?:\.\d+)?(?:[a-zA-Z0-9]+)?)"
169
+ match = re.search(pattern, filename, re.IGNORECASE)
170
+ if match:
171
+ return match.group(1)
172
+
173
+ return None
174
+
175
+
113
176
  def _query_azure_artifacts_version(
114
177
  package_name: str,
115
178
  repository_url: str,
@@ -117,8 +180,8 @@ def _query_azure_artifacts_version(
117
180
  """
118
181
  Query Azure Artifacts for the latest version.
119
182
 
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.
183
+ Azure Artifacts uses a simple index format (HTML) following PEP 503.
184
+ Parses the HTML to extract version numbers from package filenames.
122
185
 
123
186
  Args:
124
187
  package_name: Package name to query
@@ -141,24 +204,49 @@ def _query_azure_artifacts_version(
141
204
  return None
142
205
 
143
206
  try:
144
- response = requests.get(simple_index_url, timeout=5)
207
+ response = requests.get(simple_index_url, timeout=10)
145
208
  logger.debug(f"Azure Artifacts response status: {response.status_code}")
146
209
 
147
210
  if response.status_code == 401:
148
211
  logger.warning(f"Authentication required for Azure Artifacts (401). Package '{package_name}' may require authentication to query.")
212
+ return None
149
213
  elif response.status_code == 403:
150
214
  logger.warning(f"Access forbidden for Azure Artifacts (403). Package '{package_name}' may not be accessible or requires different permissions.")
215
+ return None
151
216
  elif response.status_code == 404:
152
217
  logger.debug(f"Package '{package_name}' not found on Azure Artifacts (404) - first release")
218
+ return None
153
219
  elif response.status_code != 200:
154
220
  logger.warning(f"Unexpected status code {response.status_code} from Azure Artifacts for '{package_name}'")
221
+ return None
155
222
 
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
223
+ # Parse HTML to extract versions
224
+ parser = SimpleIndexParser(package_name)
225
+ try:
226
+ parser.feed(response.text)
227
+ except Exception as e:
228
+ logger.warning(f"Error parsing Azure Artifacts HTML for '{package_name}': {e}")
229
+ return None
230
+
231
+ if not parser.versions:
232
+ logger.debug(f"No versions found in Azure Artifacts HTML for '{package_name}'")
233
+ return None
234
+
235
+ # Find the latest version
236
+ versions = list(parser.versions)
237
+ logger.debug(f"Found {len(versions)} versions in Azure Artifacts: {versions}")
238
+
239
+ # Sort versions to find the latest
240
+ try:
241
+ sorted_versions = sorted(versions, key=_parse_version_for_sort, reverse=True)
242
+ latest_version = sorted_versions[0]
243
+ logger.info(f"Found latest version {latest_version} on Azure Artifacts for '{package_name}'")
244
+ return latest_version
245
+ except Exception as e:
246
+ logger.warning(f"Error sorting versions for '{package_name}': {e}")
247
+ # Fallback: return the first version found
248
+ return versions[0]
249
+
162
250
  except requests.RequestException as e:
163
251
  logger.warning(f"Network error querying Azure Artifacts for '{package_name}': {e}")
164
252
  return None
@@ -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")