Skip to content

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

Merged
Abhishek Patil (abhishek-pattern) merged 13 commits into
mainfrom
feat/pypi-packages-from-uv-lock
Aug 17, 2026
Merged

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

Conversation

@abhishek-pattern

@abhishek-pattern Abhishek Patil (abhishek-pattern) commented Aug 12, 2026

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(flow=None, *, dependency_groups=None, python=None, project_root=None)
uv_pypi(step=None,      *, dependency_groups=None, python=None, project_root=None)

Docs: docs/metaflow/pypi_packages.md.

Why uv.lock rather than pyproject.toml

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 cannot disagree. Markers evaluate against Linux, which is what Metaflow builds for a remote task. Only the root project's direct dependencies are emitted — @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 cannot find the flow repo from __file__, so project files are located by walking up from the launch directory; project_root= overrides with an exact directory.

Output, once per run

Each decorator prints what it resolved:

@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

Getting this to appear once per run rather than once per step took two attempts, and the reason is worth knowing for anyone touching it. Metaflow re-imports the flow module — re-evaluating every decorator — in each process that touches a task: the local worker it spawns per task, and the container. current.is_running_flow cannot gate that, because current is populated by the task runtime, which starts after the module is imported and the decorators ran. So three checks share one _is_running_task() helper:

  • MF_PATHSPEC — exported by Metaflow's mflog helper into every remote task command (Kubernetes, Batch, Argo, Step Functions).
  • step / spin-step in sys.argv — covers the local worker, whose environment is only a copy of the client's and so carries no marker variable.
  • current.is_running_flow — catches what neither of the others can see: a flow module re-imported mid-run, e.g. one flow triggering another.

Verified on a real local run: one block at the top, nothing under any task.

Failing loudly on a missing lockfile

A missing uv.lock used to yield an empty packages map, so a flow launched from the wrong directory baked an empty environment and failed later somewhere less informative. It now raises FileNotFoundError at import, naming the directory searched and suggesting the fixes.

This cannot be unconditional: Metaflow's code package carries only .py files, so uv.lock never reaches the container, and the task re-imports the flow module — every remote run would die at import. A process already running a task therefore still gets an empty map, which is correct there.

pyproject.toml is deliberately not required; it is only the middle tier of the Python-version fallback, and plenty of projects declare no requires-python.

Reviewer notes

  • d105d2f changes what flows bake. Moving the Python floor 3.9 → 3.10 is what selects pandas 2.3.3 over 3.0.5. requires-python is a published constraint, so this affects consumers of the library, not just this repo. Worth a deliberate look.
  • fb42cc9 bumps the version to 0.6.0.
  • packaging is now a declared runtime dependency — it was only present transitively, and is used for PEP 508 marker and specifier parsing.
  • Public surface is just the two decorators. The helpers that build the map are private and tested through the module.
  • docs/metaflow/private_repo_access.md (be9ffc5) is unrelated to the code: it documents the Fast Bakery Git credentials needed for private patterninc git dependencies, and records that the prod perimeter has no GIT_PYPI_REPOSITORY integration — so a flow deployed there with a private dependency will fail to bake until one is created.
  • 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]. Expect CI lint to flag it.

Testing

34 unit tests in tests/unit_tests/metaflow/test__pypi_packages.py. Coverage: version pinning, git SHA references, marker resolution by Python version, platform-gated dependencies being dropped, the unpinned fallback, all three Python-version tiers, dependency groups, the upward project-root search, the raise and its task-process exemption, and the print suppression — parametrized over each signal in isolation, including a remote task whose argv looks like the client's.

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

Not covered: a real Kubernetes run with an actual Fast Bakery image build. Suppression happens at decoration time and is independent of baking, but that path has only been exercised locally.

🤖 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.

Comment thread src/ds_platform_utils/metaflow/pypi_packages.py Outdated
Comment thread src/ds_platform_utils/metaflow/pypi_packages.py
Comment thread src/ds_platform_utils/metaflow/pypi_packages.py
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>
Moves the check added in b78d516 into a named _should_log_environment helper,
adds the test it was missing, and documents the two conditions that suppress the
summary.

Behaviour is unchanged: the environment prints unless current.is_running_flow.
What that catches is a flow module re-imported while a run is already in
progress, such as one flow triggering another.

It does not catch the per-task `step` subprocess metaflow spawns. `current` is
populated by the task runtime, which starts after the flow module is imported
and these decorators run, so is_running_flow reads False there and the summary
still prints once per step. The helper's docstring records that so the next
reader does not have to rediscover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Metaflow re-imports the flow module -- so re-evaluates these decorators -- in
every process that touches a task, so the summary was printed once per step on
both Kubernetes and non-Kubernetes setups.

is_running_flow alone could not fix that. current is populated by the task
runtime, which starts after the flow module is imported and the decorators ran,
so it reads False in the very processes that needed silencing. Two checks that do
exist that early are added beside it:

- MF_PATHSPEC, which metaflow's mflog helper exports into every remote task
  command, marking a Kubernetes pod, Batch job, Argo or Step Functions container.
- The task subcommand in sys.argv. Every backend builds its task command around
  the same subcommand, so this covers the local worker metaflow spawns per task
  -- whose environment is only a copy of the client's, and so carries no marker
  variable -- as well as the container it launches.

is_running_flow stays: it catches a case neither of the others can see, a flow
module re-imported while a run is already in progress, such as one flow
triggering another.

None of the three is load-bearing. If a future metaflow renames a subcommand or
drops a variable the failure is a duplicated log block, not a broken run.

Verified on a real local run: one block at the top, nothing under any of the
three tasks. Unit tests cover each signal in isolation, including a remote task
whose argv looks like the client's, which is what makes keeping both worthwhile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
disabled only ever forwarded a value straight through to metaflow, and asking
for it meant "derive the whole environment from the lock, then throw it away".
Metaflow's own @pypi_base(disabled=True) / @pypi(disabled=True) express that
without the pointless lockfile read, so the parameter earned nothing.

Removes it from both decorators rather than one, since nothing about the flow
and step cases made them differ here.

The [environment disabled] header flag goes too. Nothing sets the key any more,
so it could never fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A missing lockfile silently produced an empty packages map, so a flow launched
from the wrong directory baked an environment with nothing in it and only failed
later, somewhere less informative. It now raises FileNotFoundError naming the
directory searched -- or saying it walked upwards -- and suggesting the three
fixes: run `uv lock`, launch from inside the repo, or pass project_root=.
Decoration is import time, so this lands before a run is scheduled.

Raising unconditionally is not an option: metaflow's code package carries only
.py files, so uv.lock never reaches the container, and the task re-imports the
flow module. Every remote run would die at import. A process already running a
task therefore still gets an empty map, which is correct there -- the image was
baked from the environment resolved on the client.

Extracts the MF_PATHSPEC / argv / is_running_flow checks into _is_running_task,
now shared by this and the logging. Worth noting the risk profile differs between
the two callers: a false positive costs the logging a missing line, but would
cost this a silent empty environment. All three signals only become true in
processes metaflow itself started, so a false positive means metaflow
misreported its own context.

pyproject.toml is deliberately not required. It is only consulted as the middle
tier of the python-version fallback, and plenty of projects declare no
requires-python at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/ds_platform_utils/metaflow/pypi_packages.py:453

  • The docstring example imports _get_pypi_kwargs from ds_platform_utils.metaflow, but it is not exported there (only uv_pypi/uv_pypi_base are). As written, the example will raise ImportError.
    from ds_platform_utils.metaflow import _get_pypi_kwargs

src/ds_platform_utils/metaflow/pypi_packages.py:570

  • Docstring references _should_log_environment, but that helper doesn't exist; the actual gating logic uses _is_running_task(). This makes the doc misleading for future maintainers.
    The resolved environment is printed, so a run records what it actually built rather than
    leaving you to re-derive it -- but only from a process that is not itself executing a task,
    per `_should_log_environment`. Nothing is printed when the map came back empty either, that
    being the remote task re-importing the flow module inside an already-baked image, where
    there is no lockfile to read and nothing worth reporting.

_get_pypi_kwargs carried a usage example importing itself from
ds_platform_utils.metaflow, where it is not exported -- pasting it raised
ImportError. Removed rather than repointed at the module path: it was the only
private function in the file with a copy-paste example, and showing users
@pypi_base(**_get_pypi_kwargs()) contradicts the module's own statement that the
two decorators are its entire public surface. Replaced with a pointer to them.

_apply_uv_pypi's docstring still named _should_log_environment, left over from
extracting _is_running_task for the fail-fast change -- the helper was renamed
and its polarity inverted, but this reference was missed. It now names the real
helper and says why the gate exists.

Audited the rest of the file the same way: every mkdocs cross-reference resolves
to an actual export, every `from ds_platform_utils...` line in a docstring
imports, and every backticked private name exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@vinay79n Vinay Shende (vinay79n) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks great, will add real value for ds-platfprm-utils users

@abhishek-pattern
Abhishek Patil (abhishek-pattern) merged commit 2f52dea into main Aug 17, 2026
11 checks passed
@abhishek-pattern
Abhishek Patil (abhishek-pattern) deleted the feat/pypi-packages-from-uv-lock branch August 17, 2026 12:06
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.

3 participants