Skip to content

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

Open
Sanjays2402 wants to merge 4 commits into
aws:developfrom
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 4 commits into
aws:developfrom
Sanjays2402:fix/pyproject-metadata-fallback-675

Conversation

@Sanjays2402

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
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("\"'")

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.

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:

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.

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")

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")

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)

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))

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:

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.

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))

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.

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)

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.

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

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

Development

Successfully merging this pull request may close these issues.

1 participant