Skip to content

Navigation Menu

Sign in
Sign up

fix: use pyproject.toml metadata when sdist has no setup.py or PKG-INFO - #920

Open
Sanjays2402 wants to merge 6 commits into
aws:develop from
Sanjays2402:fix/pyproject-metadata-fallback-675
Open

fix: use pyproject.toml metadata when sdist has no setup.py or PKG-INFO #920
Sanjays2402 wants to merge 6 commits into
aws:develop from
Sanjays2402:fix/pyproject-metadata-fallback-675

Conversation

@Sanjays2402

@Sanjays2402 Sanjays2402 commented Sep 9, 2026

Copy link
Copy Markdown

Issue #, if available:

#675

Description of changes

sam build fails with UnsupportedPackageError: Unable to retrieve name/version for package when requirements.txt contains a git+https dependency that is a PEP 517-only project (no setup.py, no pre-generated PKG-INFO). This happens on Python 3.12+ build environments where setuptools is not installed, so the setup.py egg_info metadata probe fails and there is nothing to fall back to — even though pip download itself resolved and built the package metadata successfully.

When the existing metadata retrieval raises UnsupportedPackageError, SDistMetadataFetcher now falls back to reading the static PEP 621 [project] name/version from the sdist's pyproject.toml before giving up. Parsing uses stdlib tomllib where available (Python 3.11+) with a minimal line-based parse of the [project] section as a fallback so this keeps working on Python 3.10. Dynamic versions or missing metadata still raise UnsupportedPackageError as before.

Description of how you validated changes

  • Reproduced the exact failure path from Bug: git+https requirements fail to install when using python3.12 container #675 locally: a PEP 517-only sdist zip (pyproject.toml only, like the zip pip download saves for a git+https requirement) raised UnsupportedPackageError before the fix and now resolves to the correct name/version.
  • New unit tests covering the pyproject fallback, the still-raises path, dynamic/missing metadata, and the parser (including the no-tomllib path).
  • Full unit suite: 841 passed, 6 subtests passed. black --check clean; ruff shows only the pre-existing baseline findings (verified identical count on unmodified code).

Checklist

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Sanjays2402 requested a review from a team as a code owner September 9, 2026 18:19

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 587257c..a4980a1
Files: 2
Comments: 2

if not in_project_section or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
value = value.strip().strip("\"'")

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The line-based fallback parser can silently return a corrupted version, and this path is live in production: setup.py declares python_requires=">=3.10", and Python 3.10 has no stdlib tomllib, so it is the only parse path there.

Two concrete defects:

  1. Inline comments are not stripped. value.strip().strip("\"'") only removes quote characters at the ends of the string, so:
[project]
name = "foo"
version = "1.2.3" # x-release-please-version

yields version == '1.2.3" # x-release-please-version' (the leading quote is stripped, the trailing one is not, because the last character is n). Version-bumping tools (release-please, bump-my-version, tbump) commonly add exactly this kind of trailing marker comment. The bad value propagates into Package.identifier ("%s==%s" % (name, version)) and data_dir, so the sdist no longer matches its built wheel during dependency reconciliation — a wrong-metadata failure that is harder to diagnose than the UnsupportedPackageError this PR is fixing.

  1. The table header comparison is exact (stripped == "[project]"), so a valid header with a trailing comment ([project] # main table) leaves in_project_section false and the fallback reports "no metadata".

Suggested tightening — require a properly quoted scalar and tolerate comments:

QUOTEDVALUE = re.compile(r"""^(["'])(.*?)1円\s*(?:#.*)?$""")
...
 if stripped.startswith("["):
 in_project_section = stripped.split("#")[0].strip() == "[project]"
 continue
...
 key, , value = stripped.partition("=")
 match = QUOTED_VALUE.match(value.strip())
 if not match:
 continue
 value = match.group(2)

Rejecting anything that is not a quoted scalar also avoids picking up array/inline-table values (dynamic = ["version"]-style lines) as a name or version.

@Sanjays2402 Sanjays2402 Sep 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The line-based parser now requires properly quoted scalars via a QUOTED_VALUE regex, so inline comments outside quotes are stripped, trailing comments on the [project] header are tolerated, and non-scalar values like dynamic = ["version"] are skipped instead of being picked up. Added tests covering the release-please trailing-comment version, a commented header, and array-value rejection — all passing.

candidate_name, candidate_version = project.get("name"), project.get("version")
if isinstance(candidate_name, str) and isinstance(candidate_version, str):
return candidate_name, candidate_version
except Exception:

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ERROR_HANDLING] except Exception: pass here is broader than intended and produces no diagnostic. Two consequences:

  • Real bugs (an AttributeError if project is not a table, an unexpected type error) are indistinguishable from a TOML syntax error, and nothing is logged, so the user only sees the eventual UnsupportedPackageError with no clue why. The surrounding code consistently logs in this situation — e.g. _get_pkg_info_filepath uses LOG.debug("Could not check setuptools availability: %s", e).
  • On Python 3.11+, a tomllib failure falls through to the hand-rolled parser, which will happily extract values from a file that a real TOML parser rejected as invalid. Accepting metadata from a malformed pyproject.toml is worse than reporting the package as unsupported, since no PEP 517 backend would build it with those values.

Narrow the catch and gate the line-based parse on tomllib actually being unavailable:

if tomllib is not None:
 try:
 project = tomllib.loads(contents).get("project") or {}
 except tomllib.TOMLDecodeError as ex:
 LOG.debug("Unable to parse pyproject.toml: %s", ex)
 return None, None
 candidate_name, candidate_version = project.get("name"), project.get("version")
 if isinstance(candidate_name, str) and isinstance(candidate_version, str):
 return candidate_name, candidate_version
 return None, None

That keeps the stdlib parser authoritative when it exists and leaves the line-based parse as the Python 3.10-only path it is documented to be.

@Sanjays2402 Sanjays2402 Sep 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The tomllib path now catches TOMLDecodeError specifically with a LOG.debug message and returns (None, None), and the hand-rolled parser only runs when tomllib is None — the documented 3.10-only path. Added a test asserting a malformed pyproject.toml yields (None, None) on 3.11+ rather than falling through to the line-based parse.

@github-actions github-actions Bot added pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at. area/workflow/python_pip labels Sep 9, 2026
- Line-based fallback (3.10-only): require quoted scalars via QUOTED_VALUE regex, tolerate trailing comments on the [project] header, strip inline comments outside quotes, skip non-scalar values like dynamic = ["version"]
- Narrow except Exception to tomllib.TOMLDecodeError with LOG.debug; malformed TOML on 3.11+ now returns (None, None) instead of falling through to the hand-rolled parser
- Add tests: trailing-comment version, commented header, non-scalar rejection, malformed-TOML-on-3.11+ behavior

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 587257c..15df641
Files: 2
Comments: 3

except tomllib.TOMLDecodeError as ex:
LOG.debug("Unable to parse pyproject.toml with tomllib: %s", ex)
return None, None
candidate_name, candidate_version = project.get("name"), project.get("version")

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ERROR_HANDLING] project is not guaranteed to be a table. A top-level project = "something" is valid TOML, so tomllib.loads(contents).get("project") can return a str (or any scalar/list), and the following project.get("name") raises AttributeError.

This was previously masked by the broad except Exception: pass; now that the except is correctly narrowed to TOMLDecodeError, the AttributeError escapes get_package_name_and_version and surfaces as a raw traceback in sam build instead of the actionable UnsupportedPackageError. Since the input is a third-party sdist, the parser should not assume shape — note the function already type-checks name/version with isinstance, so the table itself is the one unchecked value.

try:
 parsed = tomllib.loads(contents)
except tomllib.TOMLDecodeError as ex:
 LOG.debug("Unable to parse pyproject.toml with tomllib: %s", ex)
 return None, None
project = parsed.get("project")
if not isinstance(project, dict):
 return None, None
candidate_name, candidate_version = project.get("name"), project.get("version")

@Sanjays2402 Sanjays2402 Sep 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the tomllib path now checks isinstance(project, dict) before touching .get, so a scalar like project = "something" returns (None, None) instead of raising AttributeError. Added a test for the non-table case.

pyproject_path = self._osutils.joinpath(package_dir, "pyproject.toml")
if not self._osutils.file_exists(pyproject_path):
raise UnsupportedPackageError(self._osutils.basename(package_dir))
contents = self._osutils.get_file_contents(pyproject_path, binary=False)

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ERROR_HANDLING] The file read is unguarded, so it can raise out of this "last resort" helper instead of degrading to UnsupportedPackageError. OSUtils.get_file_contents(..., binary=False) decodes with encoding="utf-8", so any sdist whose pyproject.toml is not valid UTF-8 (or has a UTF-8 BOM) raises UnicodeDecodeError. That exception propagates through get_package_name_and_versionPackage._calculate_name_and_version, turning a package that previously failed with the clear "Unable to retrieve name/version for package" message into an unhandled internal error.

Using utf-8-sig also lets BOM-prefixed files parse successfully rather than failing in tomllib and silently discarding usable metadata:

try:
 contents = self._osutils.get_file_contents(pyproject_path, binary=False, encoding="utf-8-sig")
except (OSError, UnicodeDecodeError) as ex:
 LOG.debug("Unable to read %s: %s", pyproject_path, ex)
 raise UnsupportedPackageError(self._osutils.basename(package_dir))

@Sanjays2402 Sanjays2402 Sep 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the read now uses utf-8-sig (so BOM-prefixed files still parse) and is wrapped in try/except (OSError, UnicodeDecodeError) that logs at debug and raises UnsupportedPackageError instead of leaking the raw decode error. Added tests for a non-UTF-8 file and a BOM-prefixed file.

# get the name and version from the result setup.py
pkg_info_filepath = self._get_pkg_info_filepath(package_dir)
name, version = self._get_name_version(pkg_info_filepath)
except UnsupportedPackageError:

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GENERAL] Recovering here leaves a misleading WARNING in the build output on the success path. _get_pkg_info_filepath emits LOG.warning("Unable to find PKG-INFO file for package in %s. This may be due to missing setuptools/distutils in Python 3.12+ or an incomplete sdist package.", ...) immediately before raising UnsupportedPackageError. That warning was previously followed by a hard failure, so it was accurate; after this change, every PEP 517-only git+https requirement — the exact case this PR fixes — will print it and then build successfully.

Users will reasonably read that as a broken build. Since _get_pkg_info_filepath now has a caller that treats the condition as recoverable, the "we could not find metadata here" message belongs at LOG.debug, with the warning (or nothing at all) decided by the caller once all fallbacks are exhausted. At minimum, log an explicit recovery message in this except branch so the preceding warning is not the last thing the user sees about this package.

@Sanjays2402 Sanjays2402 Sep 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — demoted that warning to debug since the caller can now recover, and added an explicit LOG.info in the recovery branch so a successful pyproject fallback is visible without looking like a failure.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 587257c..17dfd52
Files: 2
Comments: 1

raise UnsupportedPackageError(self._osutils.basename(package_dir)) from ex
name, version = _parse_pyproject_name_version(contents)
if not name or not version:
raise UnsupportedPackageError(self._osutils.basename(package_dir))

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GENERAL] Downgrading the _get_pkg_info_filepath message to LOG.debug was correct for the recoverable case, but it leaves the terminal failure path with no user-visible diagnostic at all. Both remaining messages on that path are debug-level:

  • _get_pkg_info_filepath: LOG.debug("Unable to find PKG-INFO file ... missing setuptools/distutils in Python 3.12+ ...")
  • this method: LOG.debug("Unable to read %s: %s", ...)

So when the pyproject fallback does not apply — no pyproject.toml, dynamic = ["version"], or metadata under [tool.poetry] instead of [project] — the user sees only UnsupportedPackageError: Unable to retrieve name/version for package: <dir>. That is exactly the opaque message reported in #675, and before this PR it was at least preceded by a WARNING naming the likely cause. Default sam build output does not include debug logs, so the actionable hint is lost.

Emitting the warning at the point where recovery definitively fails keeps the success path clean while preserving the diagnostic:

name, version = _parse_pyproject_name_version(contents)
 if not name or not version:
 LOG.warning(
 "Unable to determine a static name/version for the package in %s. "
 "No PKG-INFO metadata was available (this may be due to missing "
 "setuptools/distutils in Python 3.12+) and pyproject.toml has no "
 "static [project] name/version.",
 package_dir,
 )
 raise UnsupportedPackageError(self._osutils.basename(package_dir))

The same treatment would apply to the file_exists and read-failure branches above. Note that tests/unit/workflows/python_pip/test_packager.py::test_get_pkg_info_filepath_no_warning_when_missing stays valid, since it asserts no warning from _get_pkg_info_filepath specifically, not from this method.

@Sanjays2402 Sanjays2402 Sep 9, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I overcorrected — the terminal path was left with no user-visible diagnostic at all. There's now a single WARNING emitted at the point where recovery definitively fails (in _get_name_version_from_pyproject, covering the missing-file, read-failure, and no-metadata branches), naming that no PKG-INFO was found, the possible 3.12+ setuptools/distutils cause, and that pyproject.toml has no static [project] name/version. The success path stays clean — the recovery case still logs only at info/debug. Added tests asserting the warning fires on terminal failure and stays silent on success; all 112 pass.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 587257c..4532db0
Files: 2
Comments: 1

# `setup.py egg_info` to read, which fails outright in Python
# 3.12+ build environments where setuptools is not installed.
# Fall back to the PEP 621 [project] metadata in pyproject.toml.
name, version = self._get_name_version_from_pyproject(package_dir)

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The version recovered from pyproject.toml is the raw author-written string, but every other producer of a version in this module supplies the PEP 440 canonical form. That asymmetry can turn a clear failure into a confusing one.

Package.__eq__/__hash__ compare on identifier ("{normalized_name}=={version}"), and the wheel side of that comparison comes from the wheel filename:

# Package._calculate_name_and_version
if self.dist_type == "wheel":
 name, version = self.filename.split("-")[:2] # canonical PEP 440 version

DependencyBuilder._download_dependencies then relies on exact string equality:

missing_wheels = sdists - compatible_wheels
...
missing_wheels = deps - compatible_wheels
return compatible_wheels, missing_wheels

and build_site_packages raises MissingDependencyError for anything left in that set.

Concretely, for a git+https requirement whose pyproject.toml declares version = "2024.01.15" (or "1.0.0-rc1", "v1.2.3"), the build backend emits foo-2024年1月15日-py3-none-any.whl. The sdist Package is foo==2024年01月15日, the wheel Package is foo==2024年1月15日, so the sdist never leaves missing_wheels: it gets rebuilt on both _build_sdists passes and the build ends in MissingDependencyError even though the wheel built fine. That is strictly harder to diagnose than the UnsupportedPackageError this PR replaces. This path is unreachable today because PKG-INFO Version is already normalized by the build backend, so it is introduced by the new fallback.

Normalizing the recovered version before returning it keeps the identity comparison consistent — e.g. via packaging.version.Version if you're willing to add packaging to requirements/python_pip.txt. If you'd rather not take the dependency, treating a non-canonical version as unrecoverable (return (None, None) so the existing UnsupportedPackageError path is used) at least preserves the actionable error message instead of surfacing a spurious missing dependency.

@Sanjays2402 Sanjays2402 Sep 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the raw author-written string (e.g. 2024年01月15日, 1.0.0-rc1) would never match the wheel filename the backend produces. Versions from both parser paths are now normalized to PEP 440 canonical form via packaging.Version, and anything that isn't valid PEP 440 falls back to (None, None) as before. Added packaging to requirements/python_pip.txt (which setup.py already includes in install_requires).

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 587257c..8b5ab48
Files: 3
Comments: 2

from aws_lambda_builders.architecture import ARM64, X86_64
from aws_lambda_builders.utils import extract_tarfile

from packaging.version import InvalidVersion, Version

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GENERAL] This import placement will fail CI lint. pyproject.toml enables the isort rules for ruff:

[tool.ruff.lint]
select = [
 "E", # Pycodestyle
 "F", # Pyflakes
 "PL", # pylint
 "I", # isort
]

and make pr (run in .github/workflows/build.yml) chains to make lint → ruff check aws_lambda_builders.

The try/except ImportError for tomllib terminates the first import block, so the following block is:

from aws_lambda_builders.architecture import ARM64, X86_64
from aws_lambda_builders.utils import extract_tarfile
from packaging.version import InvalidVersion, Version
from .compat import pip_import_string, pip_no_compile_c_env_vars, pip_no_compile_c_shim
from .utils import OSUtils

packaging is third-party and aws_lambda_builders is first-party (inferred from the package directory at the repo root), so the third-party section appears after the first-party section — I001 (un-sorted-imports). Move it above the first-party group:

from packaging.version import InvalidVersion, Version
from aws_lambda_builders.architecture import ARM64, X86_64
from aws_lambda_builders.utils import extract_tarfile
from .compat import pip_import_string, pip_no_compile_c_env_vars, pip_no_compile_c_shim
from .utils import OSUtils

@Sanjays2402 Sanjays2402 Sep 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — packaging is now imported lazily inside _canonicalize_version, and ruff check confirms CI lint is clean.

unrecoverable.
"""
try:
return str(Version(version))

@aws-sam-tooling-bot aws-sam-tooling-bot Bot Sep 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GENERAL] Importing packaging at module scope makes a rare fallback path a hard import-time requirement for the entire python_pip workflow.

requirements/base.txt is empty today, and requirements/python_pip.txt listed only setuptools/wheel — which are needed by the setup.py egg_info subprocess, not imported by this module. from packaging.version import InvalidVersion, Version is therefore the first import-time third-party dependency of the library itself. If packaging is missing or incompatible in any environment that embeds this library (vendored copies, frozen/PyInstaller builds where hidden imports must be declared explicitly), import ...python_pip.packager fails outright and every Python build breaks — not just the pyproject.toml metadata recovery that actually needs it.

Since PLC0415 (import-outside-top-level) is already in the ruff ignore list, a locally scoped, degradable import is idiomatic for this codebase:

def canonicalizeversion(version):
 try:
 from packaging.version import InvalidVersion, Version
 except ImportError:
 LOG.debug("packaging is unavailable; using the pyproject.toml version as written")
 return version
 try:
 return str(Version(version))
 except InvalidVersion:
 LOG.debug("pyproject.toml version %r is not a valid PEP 440 version", version)
 return None

This keeps the canonicalization behavior when packaging is installed (the declared case) while confining the failure mode to the fallback path instead of the whole workflow.

@Sanjays2402 Sanjays2402 Sep 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the import moved inside _canonicalize_version, so the module no longer hard-depends on packaging at import time. If packaging is missing, it logs at debug and returns the version as-is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@aws-sam-tooling-bot aws-sam-tooling-bot[bot] aws-sam-tooling-bot[bot] left review comments

At least 2 approving reviews are required to merge this pull request.

Assignees

No one assigned

Labels

area/workflow/python_pip pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at.

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /