Skip to content

feat(pypi): derive Metaflow @pypi environments from uv.lock - #32

Open
Abhishek Patil (abhishek-pattern) wants to merge 8 commits into
mainfrom
feat/pypi-packages-from-uv-lock
Open

feat(pypi): derive Metaflow @pypi environments from uv.lock#32
Abhishek Patil (abhishek-pattern) wants to merge 8 commits into
mainfrom
feat/pypi-packages-from-uv-lock

Conversation

@abhishek-pattern

Copy link
Copy Markdown
Contributor

What

Adds @uv_pypi_base / @uv_pypi — Metaflow's @pypi_base / @pypi with the Python version and packages filled in from the repo's own uv.lock, so a flow's environment cannot drift from what uv sync installs.

from metaflow import FlowSpec, step

from ds_platform_utils.metaflow import uv_pypi_base


@uv_pypi_base
class MyFlow(FlowSpec):
    @step
    def start(self):
        self.next(self.end)

    @step
    def end(self):
        pass

No python=, no packages=, nothing to keep in sync. Both decorators work bare or called: @uv_pypi_base(dependency_groups=["dev"]), python=, project_root=, disabled=.

Why versions come from uv.lock

pyproject.toml holds constraints, not versions. A >= bound, or a git dependency pinned to rev = "main", means two bakes a week apart can produce different images. uv.lock records what uv actually resolved, including the commit SHA for git dependencies, so a bake is reproducible.

Resolving a universal lockfile

uv.lock is a universal resolution — every Python version and platform in range at once, each tagged with a marker. @pypi takes a flat {name: version} map with nowhere to put a marker, so markers are resolved at decoration time:

Lock state Emitted
One entry, or several with a marker that selects one that exact version
Marker excludes this environment (sys_platform == 'darwin' on a Linux bake) omitted
Several entries, nothing to tell them apart "", left for @pypi

Concretely, from one unchanged lockfile:

python=3.10  ->  pandas 2.3.3
python=3.11  ->  pandas 3.0.5

The Python version is resolved first and handed down, so the interpreter and the packages can't disagree. Markers evaluate against Linux, which is what Metaflow builds for a remote task.

Only the root project's direct dependencies are emitted, not the transitive closure — @pypi resolves transitives itself, and per-platform wheel availability is its job.

Where the Python version comes from

  1. .python-version — the interpreter uv pinned
  2. requires-python in uv.lock, else pyproject.toml — a range, so its floor
  3. The running interpreter

An installed package can't find the flow repo from __file__, so project files are located by walking up from the launch directory; project_root= overrides.

Reviewer notes

  • The second commit changes what flows bake. Moving the floor to 3.10 is what selects pandas 2.3.3 over 3.0.5. Worth a deliberate look — requires-python is a published constraint, so raising it later would stop 3.10 consumers installing this library.
  • packaging is now a declared runtime dependency; it was only present transitively and is used for PEP 508 marker and specifier parsing.
  • Only uv_pypi and uv_pypi_base are exported. The helpers that build the map are private.
  • Pre-existing, not from this PR: the 3.10 floor moves ruff's inferred target, so ruff 0.16 now wants PEP 604 annotations across ~158 sites repo-wide. This PR keeps the existing Optional/Union style rather than mixing. Either declare requires-python and modernize in one dedicated pass, or pin target-version in [tool.ruff].

Testing

24 unit tests in tests/unit_tests/metaflow/test__pypi_packages.py; 80 pass overall. Coverage includes version pinning, git SHA references, marker resolution by Python version, platform-gated dependencies being dropped, the unpinned fallback, dependency groups, the upward project-root search, and the empty map that lets remote tasks re-import the flow module.

Decorator tests assert against a spy on Metaflow's pypi_base — the contract is "call Metaflow with this environment", and Metaflow 2.19 already moved where decorators are recorded. One test exercises the real decorator end to end.

🤖 Generated with Claude Code

Adds get_packages_from_uv_lock and get_packages_from_pyproject so a flow's
@pypi_base cannot drift from what the project actually installs.

Prefer the uv.lock variant: it emits resolved versions and exact commit SHAs
for git dependencies, so a bake is reproducible instead of tracking whatever
main points at today.

Emits the root project's direct dependencies only, not the transitive closure
-- lock entries are marker-gated per platform, so pinning the whole graph
would break a bake anywhere but the machine that resolved it. A name locked at
two versions behind different resolution markers is left unpinned for the same
reason. Returns {} when the file is not found, which keeps remote tasks working:
they re-import the flow module in a container holding only .py files, by which
point the image is already baked from the client-resolved map.

Project files are located by walking up from the launch directory, since an
installed package cannot resolve the flow repo from __file__; pass project_root
to override.

Declares packaging as a direct dependency -- get_packages_from_pyproject uses
it for PEP 508 parsing and it was only present transitively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Updated README.md to reflect changes in the decorators used for PyPI package management.
- Modified pypi_packages.md to describe the new `uv_pypi_base` and `uv_pypi` decorators, replacing the previous `get_packages_from_pyproject` and `get_packages_from_uv_lock` functions.
- Refactored the __init__.py file to import the new decorators.
- Updated pypi_packages.py to implement the new decorators and their functionality.
- Adjusted unit tests to validate the new decorators and their behavior, ensuring compatibility with existing functionality.
…ironment

uv.lock is a universal resolution: it records the answer for every python
version and platform in range at once, each tagged with the marker it applies
to. @pypi takes a flat name -> version map with nowhere to put a marker, so the
markers have to be resolved when the decorator is applied rather than passed on.

A dependency no single version covers appears once per marker region, each
naming its own version, so the lock itself says which one applies:

    { name = "pandas", version = "2.3.3", marker = "python_full_version < '3.11'" }
    { name = "pandas", version = "3.0.5", marker = "python_full_version >= '3.11'" }

Previously any name locked more than once was emitted unpinned; now the resolved
python version selects one, and unpinned is only the fallback for entries with
nothing to tell them apart. The python version is therefore resolved first and
handed down, so the two halves of the environment cannot disagree.

The same marker evaluation drops a dependency gated to another platform -- a
darwin-only package was previously pinned unconditionally and would fail a linux
bake. Markers evaluate against linux, which is what metaflow builds for a remote
task; the packages helper takes sys_platform for a local-only flow.

Renames the groups parameter to dependency_groups. At a decorator call site,
among @resources and @step, a bare "groups" reads as something about the flow;
dependency_groups is the PEP 735 term and unambiguous where it is typed.

Reworks the decorator tests around a spy on metaflow's pypi_base: the contract
under test is "call metaflow with this environment", and metaflow 2.19 moved
where the decorator is recorded (_flow_decorators -> _flow_state), breaking
tests that asserted on that internal. One test still exercises the real
decorator end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-locks against python 3.10 and raises the polars constraint to >=1.36.2.

Note for reviewers: the interpreter floor is what decides several packages in
the universal lock -- pandas resolves to 2.3.3 below 3.11 and 3.0.5 at or above
it -- so this commit changes what flows using @uv_pypi_base bake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 12, 2026 07:29

Copilot AI 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.

Pull request overview

Introduces Metaflow decorators that derive @pypi_base / @pypi configuration (Python version + direct dependency pins) from the repository’s uv.lock, aiming to prevent environment drift between local uv sync and Metaflow-baked images.

Changes:

  • Added uv_pypi_base and uv_pypi decorators that compute python= and packages= from .python-version / requires-python and uv.lock.
  • Implemented uv.lock parsing + PEP 508 marker evaluation to emit a flat name -> version/direct-reference map for Metaflow.
  • Added docs and unit tests covering version selection, marker handling, git SHA pinning, dependency groups, and project-root discovery; updated runtime deps (packaging) and bumped default Python pin to 3.10.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/unit_tests/metaflow/test__pypi_packages.py New unit tests exercising uv.lock parsing and decorator behavior.
src/ds_platform_utils/metaflow/pypi_packages.py New implementation of uv.lock → Metaflow @pypi(_base) environment derivation and wrappers.
src/ds_platform_utils/metaflow/__init__.py Exports uv_pypi and uv_pypi_base as the public API.
README.md Adds docs link for the new decorators.
pyproject.toml Adds runtime dependency on packaging and bumps the polars lower bound.
docs/metaflow/pypi_packages.md New documentation page for uv_pypi_base / uv_pypi.
.python-version Updates the repo’s pinned Python version to 3.10.
Suppressed comments (1)

src/ds_platform_utils/metaflow/pypi_packages.py:295

  • _split_lock_packages overwrites root every time it sees any local-source package (virtual/editable/directory). If a lock contains additional local directory/editable dependencies, the last one wins and the derived dependency list can come from the wrong project entry. Avoid overwriting root once it has been found.
        if any(key in source for key in _LOCAL_SOURCE_KEYS):
            # the repo itself -- it is the thing depending on everything else, not a dep.
            root = package
            continue

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

```python
from metaflow import FlowSpec, pypi_base, step

from ds_platform_utils.metaflow import _get_pypi_kwargs
Comment on lines +47 to +51
_PLATFORM_MARKERS = {
"linux": {"platform_system": "Linux", "os_name": "posix", "platform_machine": "x86_64"},
"darwin": {"platform_system": "Darwin", "os_name": "posix", "platform_machine": "arm64"},
"win32": {"platform_system": "Windows", "os_name": "nt", "platform_machine": "AMD64"},
}
Comment on lines +271 to +273
f"uv.lock source for {name!r} cannot be installed by @pypi: {source!r}. Only git and url sources "
"are fetchable from a remote task -- path and workspace sources are local-only."
)
A flow now records the environment it actually built instead of leaving you to
re-derive it from the lockfile:

    @uv_pypi_base on MyFlow: python 3.10, 10 package(s) from uv.lock
      jinja2                      3.1.6
      outerbounds                 0.12.39
      polars                      (unpinned)
      snowflake-connector-python  4.7.2

Uses print rather than a logger, matching the rest of the package -- a module
logger would be silent by default with no logging configured.

Names are sorted and versions column-aligned so two runs compare by eye. Three
things the format states outright that a raw dict would not: "(unpinned)" for a
deliberately unresolved entry, since an empty string reads as a missing value; a
[environment disabled] header flag, so it is clear the listing will not be
installed; and the decorated flow or step by name, which matters once several
steps carry their own @uv_pypi.

Prints nothing when no lockfile is found. That is the remote task re-importing
the flow module inside an already-baked image -- there is nothing resolved to
report, and printing would add noise to every task's logs.

Note this fires at import, so it appears on any command that loads the flow
module, not just run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flows depending on a private github.com/patterninc repo need Outerbounds to hold
git credentials, because Fast Bakery runs pip install inside the bake -- local
git credentials never reach it. Without the integration a bake fails at image
build time, before any step runs.

Records the current state as of 2026-08-12: the private-repo-access integration
exists in the default perimeter and covers the org root
https://github.com/patterninc, so every repo under it is already matched and a
new private dependency needs no integration change. The prod perimeter has no
GIT_PYPI_REPOSITORY integration at all, so a flow deployed there will fail to
bake until one is created -- perimeters are isolated and grant nothing to each
other.

Covers setup per perimeter, verification, adding further git hosts (update
replaces the URL list rather than appending), and token rotation. Commands read
the token from an environment variable so it stays out of shell history; no
token values appear in the doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants