python-package-folder 4.0.0__tar.gz → 4.1.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 (52) hide show
  1. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/PKG-INFO +18 -1
  2. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/README.md +17 -0
  3. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/coverage.svg +2 -2
  4. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/pyproject.toml +1 -1
  5. {python_package_folder-4.0.0 → python_package_folder-4.1.1/python_package_folder}/scripts/get-next-version.cjs +224 -54
  6. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/python_package_folder.py +172 -33
  7. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/.copier-answers.yml +0 -0
  8. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  9. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/.cursor/rules/general.mdc +0 -0
  10. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/.cursor/rules/python.mdc +0 -0
  11. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/.github/workflows/ci.yml +0 -0
  12. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/.github/workflows/publish.yml +0 -0
  13. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/.gitignore +0 -0
  14. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/.vscode/settings.json +0 -0
  15. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/LICENSE +0 -0
  16. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/Makefile +0 -0
  17. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/development.md +0 -0
  18. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/installation.md +0 -0
  19. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/publishing.md +0 -0
  20. {python_package_folder-4.0.0/python_package_folder → python_package_folder-4.1.1}/scripts/get-next-version.cjs +0 -0
  21. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/__init__.py +0 -0
  22. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/__main__.py +0 -0
  23. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/_hatch_build.py +0 -0
  24. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/analyzer.py +0 -0
  25. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/finder.py +0 -0
  26. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/manager.py +0 -0
  27. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/publisher.py +0 -0
  28. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/py.typed +0 -0
  29. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/subfolder_build.py +0 -0
  30. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/types.py +0 -0
  31. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/utils.py +0 -0
  32. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/src/python_package_folder/version.py +0 -0
  33. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/conftest.py +0 -0
  34. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/folder_structure/some_globals.py +0 -0
  35. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  36. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  37. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  38. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  39. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  40. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  41. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_build_with_external_deps.py +0 -0
  42. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_linting.py +0 -0
  43. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_preserve_directory_structure.py +0 -0
  44. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_publisher.py +0 -0
  45. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_shared_subdirectory_imports.py +0 -0
  46. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_spreadsheet_creation_imports.py +0 -0
  47. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_subfolder_build.py +0 -0
  48. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_third_party_dependencies.py +0 -0
  49. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_utils.py +0 -0
  50. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/test_version_manager.py +0 -0
  51. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/tests/tests.py +0 -0
  52. {python_package_folder-4.0.0 → python_package_folder-4.1.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 4.0.0
3
+ Version: 4.1.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>
@@ -475,15 +475,32 @@ The `--version` option:
475
475
 
476
476
  When `--version` is not provided, the tool can automatically determine the next version using semantic-release. This requires Node.js, npm, and semantic-release to be installed.
477
477
 
478
+ **Version Detection:**
479
+ - **Baseline version**:
480
+ - **Registry Query (Preferred)**: When publishing to a repository (PyPI, TestPyPI, or Azure Artifacts), the tool queries the target registry for the latest published version and uses it as the baseline for version calculation. This ensures version calculations are based on what's actually published, not just git tags.
481
+ - **Git Tags (Fallback)**: If the package doesn't exist on the registry yet (first release) or if registry query fails, the tool falls back to using git tags to determine the starting version.
482
+ - **New version to publish**: After determining the baseline version, [`semantic-release`](https://semantic-release.gitbook.io/semantic-release/) analyzes commits since that version to calculate the next version bump (major, minor, or patch) based on [_conventional commit_](https://www.conventionalcommits.org/en/v1.0.0/) messages.
483
+
478
484
  **For subfolder builds (Workflow 1):**
479
485
  - Uses per-package tags: `{package-name}-v{version}` (e.g., `my-package-v1.2.3`)
486
+ - Queries the target registry for the latest published version of the subfolder package
480
487
  - Filters commits to only those affecting the subfolder path
488
+ - **Commit filtering behavior**: Only commits that modify files within the subfolder path are considered for version calculation. Commits that only target files outside the subfolder are excluded. For example:
489
+ - `fix: update my_subfolder/foo.py` → **Included** (affects subfolder)
490
+ - `feat: add feature to other_package/bar.py` → **Excluded** (doesn't affect subfolder)
491
+ - `fix: update my_subfolder/baz.py and shared/utils.py` → **Included** (affects subfolder, even if it also touches files outside)
481
492
  - Requires `semantic-release-commit-filter` plugin
482
493
 
483
494
  **For main package builds (Workflow 2):**
484
495
  - Uses repo-level tags: `v{version}` (e.g., `v1.2.3`)
496
+ - Queries the target registry for the latest published version when publishing
485
497
  - Analyzes all commits in the repository
486
498
 
499
+ **Registry Support:**
500
+ - **PyPI**: Fully supported via JSON API (`https://pypi.org/pypi/{package-name}/json`)
501
+ - **TestPyPI**: Fully supported via JSON API (`https://test.pypi.org/pypi/{package-name}/json`)
502
+ - **Azure Artifacts**: Basic support with fallback to git tags. Azure Artifacts uses a different API format and may require authentication, so if the query fails, the tool automatically falls back to git tags.
503
+
487
504
  **Setup:**
488
505
  ```bash
489
506
  # Install semantic-release globally
@@ -455,15 +455,32 @@ The `--version` option:
455
455
 
456
456
  When `--version` is not provided, the tool can automatically determine the next version using semantic-release. This requires Node.js, npm, and semantic-release to be installed.
457
457
 
458
+ **Version Detection:**
459
+ - **Baseline version**:
460
+ - **Registry Query (Preferred)**: When publishing to a repository (PyPI, TestPyPI, or Azure Artifacts), the tool queries the target registry for the latest published version and uses it as the baseline for version calculation. This ensures version calculations are based on what's actually published, not just git tags.
461
+ - **Git Tags (Fallback)**: If the package doesn't exist on the registry yet (first release) or if registry query fails, the tool falls back to using git tags to determine the starting version.
462
+ - **New version to publish**: After determining the baseline version, [`semantic-release`](https://semantic-release.gitbook.io/semantic-release/) analyzes commits since that version to calculate the next version bump (major, minor, or patch) based on [_conventional commit_](https://www.conventionalcommits.org/en/v1.0.0/) messages.
463
+
458
464
  **For subfolder builds (Workflow 1):**
459
465
  - Uses per-package tags: `{package-name}-v{version}` (e.g., `my-package-v1.2.3`)
466
+ - Queries the target registry for the latest published version of the subfolder package
460
467
  - Filters commits to only those affecting the subfolder path
468
+ - **Commit filtering behavior**: Only commits that modify files within the subfolder path are considered for version calculation. Commits that only target files outside the subfolder are excluded. For example:
469
+ - `fix: update my_subfolder/foo.py` → **Included** (affects subfolder)
470
+ - `feat: add feature to other_package/bar.py` → **Excluded** (doesn't affect subfolder)
471
+ - `fix: update my_subfolder/baz.py and shared/utils.py` → **Included** (affects subfolder, even if it also touches files outside)
461
472
  - Requires `semantic-release-commit-filter` plugin
462
473
 
463
474
  **For main package builds (Workflow 2):**
464
475
  - Uses repo-level tags: `v{version}` (e.g., `v1.2.3`)
476
+ - Queries the target registry for the latest published version when publishing
465
477
  - Analyzes all commits in the repository
466
478
 
479
+ **Registry Support:**
480
+ - **PyPI**: Fully supported via JSON API (`https://pypi.org/pypi/{package-name}/json`)
481
+ - **TestPyPI**: Fully supported via JSON API (`https://test.pypi.org/pypi/{package-name}/json`)
482
+ - **Azure Artifacts**: Basic support with fallback to git tags. Azure Artifacts uses a different API format and may require authentication, so if the query fails, the tool automatically falls back to git tags.
483
+
467
484
  **Setup:**
468
485
  ```bash
469
486
  # Install semantic-release globally
@@ -14,7 +14,7 @@
14
14
  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
15
15
  <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
16
16
  <text x="31.5" y="14">coverage</text>
17
- <text x="81" y="15" fill="#010101" fill-opacity=".3">66%</text>
18
- <text x="81" y="14">66%</text>
17
+ <text x="81" y="15" fill="#010101" fill-opacity=".3">64%</text>
18
+ <text x="81" y="14">64%</text>
19
19
  </g>
20
20
  </svg>
@@ -42,7 +42,7 @@ dependencies = [
42
42
 
43
43
  # ---- Dev dependencies ----
44
44
 
45
- version = "4.0.0"
45
+ version = "4.1.1"
46
46
  [dependency-groups]
47
47
  dev = [
48
48
  "pytest>=8.3.5",
@@ -7,12 +7,14 @@
7
7
  * package builds (repo-level tags).
8
8
  *
9
9
  * Usage:
10
- * node scripts/get-next-version.cjs <project_root> [subfolder_path] [package_name]
10
+ * node scripts/get-next-version.cjs <project_root> [subfolder_path] [package_name] [repository] [repository_url]
11
11
  *
12
12
  * Args:
13
13
  * - project_root: Root directory of the project (absolute or relative path)
14
14
  * - subfolder_path: Optional. Path to subfolder relative to project_root (for Workflow 1)
15
15
  * - package_name: Optional. Package name for subfolder builds (for per-package tags)
16
+ * - repository: Optional. Target repository ('pypi', 'testpypi', or 'azure')
17
+ * - repository_url: Optional. Repository URL (required for Azure Artifacts)
16
18
  *
17
19
  * Output:
18
20
  * - Version string (e.g., "1.2.3") if a release is determined
@@ -22,25 +24,32 @@
22
24
 
23
25
  const path = require('path');
24
26
  const fs = require('fs');
27
+ const https = require('https');
28
+ const http = require('http');
25
29
 
26
30
  // Parse command line arguments
27
31
  const args = process.argv.slice(2);
28
32
  if (args.length < 1) {
29
33
  console.error('Error: project_root is required');
30
- console.error('Usage: node get-next-version.cjs <project_root> [subfolder_path] [package_name]');
34
+ console.error('Usage: node get-next-version.cjs <project_root> [subfolder_path] [package_name] [repository] [repository_url]');
31
35
  process.exit(1);
32
36
  }
33
37
 
34
38
  const projectRoot = path.resolve(args[0]);
35
- const subfolderPath = args[1] || null;
36
- const packageName = args[2] || null;
39
+ const subfolderPath = args[1] && args[1] !== 'null' && args[1] !== '' ? args[1] : null;
40
+ const packageName = args[2] && args[2] !== 'null' && args[2] !== '' ? args[2] : null;
41
+ const repository = args[3] && args[3] !== 'null' && args[3] !== '' ? args[3] : null;
42
+ const repositoryUrl = args[4] && args[4] !== 'null' && args[4] !== '' ? args[4] : null;
37
43
 
38
- // Validate argument combination: both-or-neither for subfolder builds
39
- if ((subfolderPath !== null && packageName === null) || (subfolderPath === null && packageName !== null)) {
40
- console.error('Error: subfolder_path and package_name must be provided together (both or neither).');
41
- console.error('Usage: node get-next-version.cjs <project_root> [subfolder_path] [package_name]');
44
+ // Validate argument combination
45
+ // - For subfolder builds: both subfolder_path and package_name are required
46
+ // - For main package builds: package_name can be provided alone (for registry queries)
47
+ if (subfolderPath !== null && packageName === null) {
48
+ console.error('Error: package_name is required when subfolder_path is provided.');
49
+ console.error('Usage: node get-next-version.cjs <project_root> [subfolder_path] [package_name] [repository] [repository_url]');
42
50
  process.exit(1);
43
51
  }
52
+ // Note: package_name can be provided without subfolder_path for main package registry queries
44
53
 
45
54
  // Check if project root exists
46
55
  if (!fs.existsSync(projectRoot)) {
@@ -49,6 +58,8 @@ if (!fs.existsSync(projectRoot)) {
49
58
  }
50
59
 
51
60
  // Determine if this is a subfolder build
61
+ // A subfolder build requires both subfolder_path and package_name
62
+ // package_name alone (without subfolder_path) indicates a main package build with registry query
52
63
  const isSubfolderBuild = subfolderPath !== null && packageName !== null;
53
64
  const workingDir = isSubfolderBuild
54
65
  ? path.resolve(projectRoot, subfolderPath)
@@ -173,10 +184,138 @@ if (isSubfolderBuild) {
173
184
  }
174
185
  }
175
186
 
176
- try {
177
- // Try to require semantic-release
178
- // First try resolving from project root (for devDependencies), then fall back to global
179
- let semanticRelease;
187
+ /**
188
+ * Query PyPI or TestPyPI JSON API for the latest version of a package.
189
+ * @param {string} packageName - Package name to query
190
+ * @param {string} registry - 'pypi' or 'testpypi'
191
+ * @returns {Promise<string|null>} Latest version string or null if not found
192
+ */
193
+ async function queryPyPIVersion(packageName, registry) {
194
+ const baseUrl = registry === 'testpypi'
195
+ ? 'https://test.pypi.org'
196
+ : 'https://pypi.org';
197
+ const url = `${baseUrl}/pypi/${packageName}/json`;
198
+
199
+ return new Promise((resolve, reject) => {
200
+ https.get(url, (res) => {
201
+ let data = '';
202
+
203
+ res.on('data', (chunk) => {
204
+ data += chunk;
205
+ });
206
+
207
+ res.on('end', () => {
208
+ if (res.statusCode === 404) {
209
+ // Package doesn't exist yet (first release)
210
+ resolve(null);
211
+ } else if (res.statusCode === 200) {
212
+ try {
213
+ const json = JSON.parse(data);
214
+ // Get latest version from info.version or releases
215
+ const version = json.info?.version || Object.keys(json.releases || {}).pop() || null;
216
+ resolve(version);
217
+ } catch (e) {
218
+ reject(new Error(`Failed to parse PyPI response: ${e.message}`));
219
+ }
220
+ } else {
221
+ reject(new Error(`PyPI API returned status ${res.statusCode}`));
222
+ }
223
+ });
224
+ }).on('error', (err) => {
225
+ reject(err);
226
+ });
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Query Azure Artifacts for the latest version of a package.
232
+ * Azure Artifacts uses a simple index format (HTML) which is more complex to parse.
233
+ * For now, we'll attempt to query but fall back gracefully if it fails.
234
+ * @param {string} packageName - Package name to query
235
+ * @param {string} repositoryUrl - Azure Artifacts repository URL
236
+ * @returns {Promise<string|null>} Latest version string or null if not found/unsupported
237
+ */
238
+ async function queryAzureArtifactsVersion(packageName, repositoryUrl) {
239
+ // Convert upload URL to simple index URL
240
+ // Upload: https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/pypi/upload
241
+ // Simple: https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/pypi/simple/{package}/
242
+ let simpleIndexUrl;
243
+ try {
244
+ const url = new URL(repositoryUrl);
245
+ if (url.pathname.endsWith('/upload')) {
246
+ simpleIndexUrl = repositoryUrl.replace('/upload', `/simple/${packageName}/`);
247
+ } else {
248
+ // Try to construct from common patterns
249
+ simpleIndexUrl = repositoryUrl.replace(/\/upload$/, `/simple/${packageName}/`);
250
+ }
251
+ } catch (e) {
252
+ // Invalid URL format, return null to fall back to git tags
253
+ return null;
254
+ }
255
+
256
+ return new Promise((resolve) => {
257
+ // Azure Artifacts may require authentication and returns HTML
258
+ // For now, we'll attempt the request but gracefully fall back if it fails
259
+ // This is a limitation - Azure Artifacts API is more complex than PyPI
260
+ const url = new URL(simpleIndexUrl);
261
+ const client = url.protocol === 'https:' ? https : http;
262
+
263
+ const req = client.get(simpleIndexUrl, (res) => {
264
+ // Azure Artifacts simple index returns HTML, not JSON
265
+ // Parsing HTML is complex and may require authentication
266
+ // For now, we'll return null to fall back to git tags
267
+ // This can be enhanced later with proper HTML parsing or API endpoint discovery
268
+ resolve(null);
269
+ });
270
+
271
+ req.on('error', () => {
272
+ // Network error or authentication required - fall back to git tags
273
+ resolve(null);
274
+ });
275
+
276
+ req.setTimeout(5000, () => {
277
+ req.destroy();
278
+ resolve(null);
279
+ });
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Query the package registry for the latest published version.
285
+ * @param {string} packageName - Package name to query
286
+ * @param {string|null} repository - Repository type ('pypi', 'testpypi', 'azure', or null)
287
+ * @param {string|null} repositoryUrl - Repository URL (for Azure)
288
+ * @returns {Promise<string|null>} Latest version or null if not found/unsupported
289
+ */
290
+ async function queryRegistryVersion(packageName, repository, repositoryUrl) {
291
+ if (!repository || !packageName) {
292
+ return null;
293
+ }
294
+
295
+ try {
296
+ if (repository === 'pypi' || repository === 'testpypi') {
297
+ return await queryPyPIVersion(packageName, repository);
298
+ } else if (repository === 'azure') {
299
+ if (!repositoryUrl) {
300
+ return null;
301
+ }
302
+ return await queryAzureArtifactsVersion(packageName, repositoryUrl);
303
+ }
304
+ } catch (error) {
305
+ // Log error but don't fail - fall back to git tags
306
+ console.error(`Warning: Failed to query ${repository} for latest version: ${error.message}`);
307
+ console.error('Falling back to git tags for version detection.');
308
+ }
309
+
310
+ return null;
311
+ }
312
+
313
+ // Main execution
314
+ (async () => {
315
+ try {
316
+ // Try to require semantic-release
317
+ // First try resolving from project root (for devDependencies), then fall back to global
318
+ let semanticRelease;
180
319
  try {
181
320
  const semanticReleasePath = require.resolve('semantic-release', { paths: [projectRoot] });
182
321
  semanticRelease = require(semanticReleasePath);
@@ -214,11 +353,41 @@ try {
214
353
  }
215
354
  }
216
355
 
356
+ // Query registry for latest version if repository info is provided
357
+ let registryVersion = null;
358
+ if (repository && packageName) {
359
+ try {
360
+ registryVersion = await queryRegistryVersion(packageName, repository, repositoryUrl);
361
+ if (registryVersion) {
362
+ console.error(`Found latest version on ${repository}: ${registryVersion}`);
363
+ } else {
364
+ console.error(`Package not found on ${repository} or query failed, using git tags as fallback`);
365
+ }
366
+ } catch (error) {
367
+ console.error(`Warning: Registry query failed: ${error.message}`);
368
+ console.error('Falling back to git tags for version detection.');
369
+ }
370
+ }
371
+
217
372
  // Configure semantic-release options
218
373
  const options = {
219
374
  dryRun: true,
220
375
  ci: false,
221
376
  };
377
+
378
+ // If we have a registry version, we can use it to set lastRelease in semantic-release context
379
+ // This ensures semantic-release uses the registry version as baseline instead of git tags
380
+ if (registryVersion) {
381
+ // Set lastRelease in options to use registry version as baseline
382
+ // This tells semantic-release to analyze commits since this version
383
+ options.lastRelease = {
384
+ version: registryVersion,
385
+ gitTag: isSubfolderBuild
386
+ ? `${packageName}-v${registryVersion}`
387
+ : `v${registryVersion}`,
388
+ gitHead: null, // We don't know the commit, but semantic-release will find it
389
+ };
390
+ }
222
391
 
223
392
  // For subfolder builds, configure commit filter and per-package tags
224
393
  if (isSubfolderBuild) {
@@ -334,50 +503,51 @@ try {
334
503
  }
335
504
  process.exit(1);
336
505
  });
337
- } catch (error) {
338
- // Clean up temporary package.json on error
339
- if (tempPackageJson && fs.existsSync(tempPackageJson)) {
340
- const backup = tempPackageJson + '.backup';
341
- if (backupCreatedByScript && fs.existsSync(backup)) {
342
- try {
343
- // Restore original (only if we created the backup)
344
- fs.copyFileSync(backup, tempPackageJson);
345
- fs.unlinkSync(backup);
346
- } catch (e) {
347
- // Ignore cleanup errors
348
- }
349
- } else if (fileCreatedByScript) {
350
- try {
351
- // Remove temporary file (only if we created it, not if it existed before)
352
- fs.unlinkSync(tempPackageJson);
353
- } catch (e) {
354
- // Ignore cleanup errors
355
- }
356
- } else if (originalPackageJsonContent !== null) {
357
- // We modified an existing file but didn't create a backup (user's backup exists)
358
- // Restore from the original content we stored, but don't delete user's backup
359
- try {
360
- fs.writeFileSync(tempPackageJson, originalPackageJsonContent, 'utf8');
361
- } catch (e) {
362
- // Ignore cleanup errors
506
+ } catch (error) {
507
+ // Clean up temporary package.json on error
508
+ if (tempPackageJson && fs.existsSync(tempPackageJson)) {
509
+ const backup = tempPackageJson + '.backup';
510
+ if (backupCreatedByScript && fs.existsSync(backup)) {
511
+ try {
512
+ // Restore original (only if we created the backup)
513
+ fs.copyFileSync(backup, tempPackageJson);
514
+ fs.unlinkSync(backup);
515
+ } catch (e) {
516
+ // Ignore cleanup errors
517
+ }
518
+ } else if (fileCreatedByScript) {
519
+ try {
520
+ // Remove temporary file (only if we created it, not if it existed before)
521
+ fs.unlinkSync(tempPackageJson);
522
+ } catch (e) {
523
+ // Ignore cleanup errors
524
+ }
525
+ } else if (originalPackageJsonContent !== null) {
526
+ // We modified an existing file but didn't create a backup (user's backup exists)
527
+ // Restore from the original content we stored, but don't delete user's backup
528
+ try {
529
+ fs.writeFileSync(tempPackageJson, originalPackageJsonContent, 'utf8');
530
+ } catch (e) {
531
+ // Ignore cleanup errors
532
+ }
363
533
  }
364
534
  }
365
- }
366
535
 
367
- // Check if it's a "no release" case (common, not an error)
368
- if (error.message && (
369
- error.message.includes('no release') ||
370
- error.message.includes('No release') ||
371
- error.code === 'ENOCHANGE'
372
- )) {
373
- console.log('none');
374
- process.exit(0);
375
- }
536
+ // Check if it's a "no release" case (common, not an error)
537
+ if (error.message && (
538
+ error.message.includes('no release') ||
539
+ error.message.includes('No release') ||
540
+ error.code === 'ENOCHANGE'
541
+ )) {
542
+ console.log('none');
543
+ process.exit(0);
544
+ }
376
545
 
377
- // Other errors
378
- console.error(`Error: ${error.message}`);
379
- if (error.stack) {
380
- console.error(error.stack);
546
+ // Other errors
547
+ console.error(`Error: ${error.message}`);
548
+ if (error.stack) {
549
+ console.error(error.stack);
550
+ }
551
+ process.exit(1);
381
552
  }
382
- process.exit(1);
383
- }
553
+ })();
@@ -10,6 +10,8 @@ It can be invoked via:
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import os
14
+ import shutil
13
15
  import subprocess
14
16
  import sys
15
17
  from pathlib import Path
@@ -23,11 +25,23 @@ from .manager import BuildManager
23
25
  from .utils import find_project_root, find_source_directory
24
26
 
25
27
 
28
+ def is_github_actions() -> bool:
29
+ """Check if running in GitHub Actions."""
30
+ return os.getenv("GITHUB_ACTIONS") == "true"
31
+
32
+
33
+ def check_node_available() -> bool:
34
+ """Check if Node.js is available."""
35
+ return shutil.which("node") is not None
36
+
37
+
26
38
  def resolve_version_via_semantic_release(
27
39
  project_root: Path,
28
40
  subfolder_path: Path | None = None,
29
41
  package_name: str | None = None,
30
- ) -> str | None:
42
+ repository: str | None = None,
43
+ repository_url: str | None = None,
44
+ ) -> tuple[str | None, str | None]:
31
45
  """
32
46
  Resolve the next version using semantic-release via Node.js script.
33
47
 
@@ -35,10 +49,38 @@ def resolve_version_via_semantic_release(
35
49
  project_root: Root directory of the project
36
50
  subfolder_path: Optional path to subfolder (relative to project_root) for Workflow 1
37
51
  package_name: Optional package name for subfolder builds
52
+ repository: Optional target repository ('pypi', 'testpypi', or 'azure')
53
+ repository_url: Optional repository URL (required for Azure Artifacts)
38
54
 
39
55
  Returns:
40
- Version string if a release is determined, None if no release or error
56
+ Tuple of (version string if a release is determined, error message if any)
57
+ Returns (None, None) if no release or no error, (None, error_msg) on error
41
58
  """
59
+ # Note: Node.js availability should be checked before calling this function
60
+ # This check is a safety fallback
61
+ if not check_node_available():
62
+ if is_github_actions():
63
+ error_msg = """Node.js is not available in this GitHub Actions workflow.
64
+
65
+ To fix this, add the following steps BEFORE running python-package-folder:
66
+
67
+ - name: Setup Node.js
68
+ uses: actions/setup-node@v4
69
+ with:
70
+ node-version: '20'
71
+
72
+ - name: Install semantic-release
73
+ run: |
74
+ npm install -g semantic-release semantic-release-commit-filter
75
+
76
+ Alternatively, provide --version explicitly to skip automatic version resolution."""
77
+ print(f"Error: {error_msg}", file=sys.stderr)
78
+ return None, error_msg
79
+ else:
80
+ error_msg = "Node.js not found. Cannot resolve version via semantic-release."
81
+ print(f"Error: {error_msg}", file=sys.stderr)
82
+ return None, error_msg
83
+
42
84
  # Try to find the script in multiple locations:
43
85
  # 1. Project root / scripts (for development or when script is in repo)
44
86
  # 2. Package installation directory / scripts (for installed package)
@@ -86,7 +128,9 @@ def resolve_version_via_semantic_release(
86
128
  script_path = fallback_script
87
129
 
88
130
  if not script_path:
89
- return None
131
+ error_msg = "Could not locate get-next-version.cjs script"
132
+ print(f"Error: {error_msg}", file=sys.stderr)
133
+ return None, error_msg
90
134
 
91
135
  # Build command arguments
92
136
  cmd = ["node", str(script_path), str(project_root)]
@@ -98,7 +142,17 @@ def resolve_version_via_semantic_release(
98
142
  else subfolder_path
99
143
  )
100
144
  cmd.extend([str(rel_path), package_name])
101
- # Workflow 2: main package (no additional args needed)
145
+ elif package_name:
146
+ # Main package build with package_name (for registry queries)
147
+ # Pass null for subfolder_path, then package_name
148
+ cmd.extend(["", package_name])
149
+ # Workflow 2: main package without package_name (no additional args needed)
150
+
151
+ # Add repository information if provided
152
+ if repository:
153
+ cmd.append(repository)
154
+ if repository_url:
155
+ cmd.append(repository_url)
102
156
 
103
157
  result = subprocess.run(
104
158
  cmd,
@@ -109,38 +163,53 @@ def resolve_version_via_semantic_release(
109
163
  )
110
164
 
111
165
  if result.returncode != 0:
112
- # Log error details for debugging
166
+ # Collect error details
167
+ error_details = []
113
168
  if result.stderr:
114
- print(
115
- f"Warning: semantic-release version resolution failed: {result.stderr}",
116
- file=sys.stderr,
117
- )
118
- elif result.stdout:
119
- print(
120
- f"Warning: semantic-release version resolution failed: {result.stdout}",
121
- file=sys.stderr,
122
- )
123
- return None
169
+ error_details.append(f"stderr: {result.stderr}")
170
+ if result.stdout:
171
+ error_details.append(f"stdout: {result.stdout}")
172
+
173
+ error_msg = "semantic-release version resolution failed"
174
+ if error_details:
175
+ error_msg += f": {'; '.join(error_details)}"
176
+
177
+ print(f"Error: {error_msg}", file=sys.stderr)
178
+ return None, error_msg
124
179
 
125
180
  version = result.stdout.strip()
126
181
  if version and version != "none":
127
- return version
182
+ return version, None
128
183
 
129
- return None
184
+ return None, None
130
185
  except FileNotFoundError:
131
- # Node.js not found
132
- print(
133
- "Warning: Node.js not found. Cannot resolve version via semantic-release.",
134
- file=sys.stderr,
135
- )
136
- return None
186
+ # Node.js not found (shouldn't happen if check_node_available() passed, but handle gracefully)
187
+ if is_github_actions():
188
+ error_msg = """Node.js is not available in this GitHub Actions workflow.
189
+
190
+ To fix this, add the following steps BEFORE running python-package-folder:
191
+
192
+ - name: Setup Node.js
193
+ uses: actions/setup-node@v4
194
+ with:
195
+ node-version: '20'
196
+
197
+ - name: Install semantic-release
198
+ run: |
199
+ npm install -g semantic-release semantic-release-commit-filter
200
+
201
+ Alternatively, provide --version explicitly to skip automatic version resolution."""
202
+ print(f"Error: {error_msg}", file=sys.stderr)
203
+ return None, error_msg
204
+ else:
205
+ error_msg = "Node.js not found. Cannot resolve version via semantic-release."
206
+ print(f"Error: {error_msg}", file=sys.stderr)
207
+ return None, error_msg
137
208
  except Exception as e:
138
209
  # Other errors (e.g., permission issues, script not found)
139
- print(
140
- f"Warning: Error resolving version via semantic-release: {e}",
141
- file=sys.stderr,
142
- )
143
- return None
210
+ error_msg = f"Error resolving version via semantic-release: {e}"
211
+ print(f"Error: {error_msg}", file=sys.stderr)
212
+ return None, error_msg
144
213
  finally:
145
214
  # Clean up temporary file if we extracted from zip/pex
146
215
  # This must be at function level to ensure cleanup even on early return
@@ -297,6 +366,50 @@ def main() -> int:
297
366
  # Version is needed for subfolder builds or when publishing main package
298
367
  if is_subfolder or args.publish:
299
368
  print("No --version provided, attempting to resolve via semantic-release...")
369
+
370
+ # Check Node.js availability upfront
371
+ if not check_node_available():
372
+ if is_github_actions():
373
+ error_msg = """Node.js is not available in this GitHub Actions workflow.
374
+
375
+ To fix this, add the following steps BEFORE running python-package-folder:
376
+
377
+ - name: Setup Node.js
378
+ uses: actions/setup-node@v4
379
+ with:
380
+ node-version: '20'
381
+
382
+ - name: Install semantic-release
383
+ run: |
384
+ npm install -g semantic-release semantic-release-commit-filter
385
+
386
+ Alternatively, provide --version explicitly to skip automatic version resolution."""
387
+ print(f"Error: {error_msg}", file=sys.stderr)
388
+ else:
389
+ print(
390
+ "Error: Node.js is not available. Cannot resolve version via semantic-release.",
391
+ file=sys.stderr,
392
+ )
393
+ print(
394
+ "Please install Node.js or provide --version explicitly.",
395
+ file=sys.stderr,
396
+ )
397
+ return 1
398
+
399
+ # Log that Node.js is available (for debugging)
400
+ node_version = subprocess.run(
401
+ ["node", "--version"],
402
+ capture_output=True,
403
+ text=True,
404
+ check=False,
405
+ )
406
+ if node_version.returncode == 0:
407
+ print(f"Node.js detected: {node_version.stdout.strip()}")
408
+
409
+ # Get repository info if publishing
410
+ repository = args.publish if args.publish else None
411
+ repository_url = args.repository_url if args.publish else None
412
+
300
413
  if is_subfolder:
301
414
  # Workflow 1: subfolder build
302
415
  # src_dir is guaranteed to be relative to project_root due to is_subfolder check
@@ -304,12 +417,35 @@ def main() -> int:
304
417
  " ", "-"
305
418
  ).lower().strip("-")
306
419
  subfolder_rel_path = src_dir.relative_to(project_root)
307
- resolved_version = resolve_version_via_semantic_release(
308
- project_root, subfolder_rel_path, package_name
420
+ resolved_version, error_details = resolve_version_via_semantic_release(
421
+ project_root,
422
+ subfolder_rel_path,
423
+ package_name,
424
+ repository=repository,
425
+ repository_url=repository_url,
309
426
  )
310
427
  else:
311
428
  # Workflow 2: main package
312
- resolved_version = resolve_version_via_semantic_release(project_root)
429
+ # For main package, we need package_name from pyproject.toml for registry queries
430
+ package_name_for_registry = None
431
+ if repository:
432
+ try:
433
+ import tomllib
434
+ pyproject_path = project_root / "pyproject.toml"
435
+ if pyproject_path.exists():
436
+ with open(pyproject_path, "rb") as f:
437
+ data = tomllib.load(f)
438
+ package_name_for_registry = data.get("project", {}).get("name")
439
+ except Exception:
440
+ pass
441
+
442
+ resolved_version, error_details = resolve_version_via_semantic_release(
443
+ project_root,
444
+ subfolder_path=None,
445
+ package_name=package_name_for_registry,
446
+ repository=repository,
447
+ repository_url=repository_url,
448
+ )
313
449
 
314
450
  if resolved_version:
315
451
  print(f"Resolved version via semantic-release: {resolved_version}")
@@ -319,8 +455,11 @@ def main() -> int:
319
455
  "This could mean:\n"
320
456
  " - No release is needed (no relevant commits)\n"
321
457
  " - semantic-release is not installed or configured\n"
322
- " - Node.js is not available\n\n"
323
- "Please either:\n"
458
+ )
459
+ if error_details:
460
+ error_msg += f"\nDetails: {error_details}\n"
461
+ error_msg += (
462
+ "\nPlease either:\n"
324
463
  " - Install semantic-release: npm install -g semantic-release"
325
464
  )
326
465
  if is_subfolder: