feat(pypi): derive Metaflow @pypi environments from uv.lock - #32
Conversation
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>
There was a problem hiding this comment.
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_baseanduv_pypidecorators that computepython=andpackages=from.python-version/requires-pythonanduv.lock. - Implemented uv.lock parsing + PEP 508 marker evaluation to emit a flat
name -> version/direct-referencemap 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_packagesoverwritesrootevery 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 overwritingrootonce 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.
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>
There was a problem hiding this comment.
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_kwargsfromds_platform_utils.metaflow, but it is not exported there (onlyuv_pypi/uv_pypi_baseare). As written, the example will raiseImportError.
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>
Vinay Shende (vinay79n)
left a comment
There was a problem hiding this comment.
Looks great, will add real value for ds-platfprm-utils users
What
Adds
@uv_pypi_base/@uv_pypi— Metaflow's@pypi_base/@pypiwith the Python version and packages filled in from the repo's ownuv.lock, so a flow's environment cannot drift from whatuv syncinstalls.No
python=, nopackages=, nothing to keep in sync. Both decorators work bare or called:Docs:
docs/metaflow/pypi_packages.md.Why
uv.lockrather thanpyproject.tomlpyproject.tomlholds constraints, not versions. A>=bound, or a git dependency pinned torev = "main", means two bakes a week apart can produce different images.uv.lockrecords what uv actually resolved, including the commit SHA for git dependencies, so a bake is reproducible.Resolving a universal lockfile
uv.lockis a universal resolution — every Python version and platform in range at once, each tagged with a marker.@pypitakes a flat{name: version}map with nowhere to put a marker, so markers are resolved at decoration time:sys_platform == 'darwin'on a Linux bake)"", left for@pypiConcretely, from one unchanged lockfile:
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 —
@pypiresolves transitives itself, and per-platform wheel availability is its job.Where the Python version comes from
.python-version— the interpreter uv pinnedrequires-pythoninuv.lock, elsepyproject.toml— a range, so its floorAn 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:
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_flowcannot gate that, becausecurrentis 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-stepinsys.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.lockused 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 raisesFileNotFoundErrorat import, naming the directory searched and suggesting the fixes.This cannot be unconditional: Metaflow's code package carries only
.pyfiles, souv.locknever 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.tomlis deliberately not required; it is only the middle tier of the Python-version fallback, and plenty of projects declare norequires-python.Reviewer notes
d105d2fchanges what flows bake. Moving the Python floor3.9 → 3.10is what selectspandas 2.3.3over3.0.5.requires-pythonis a published constraint, so this affects consumers of the library, not just this repo. Worth a deliberate look.fb42cc9bumps the version to0.6.0.packagingis now a declared runtime dependency — it was only present transitively, and is used for PEP 508 marker and specifier parsing.docs/metaflow/private_repo_access.md(be9ffc5) is unrelated to the code: it documents the Fast Bakery Git credentials needed for privatepatternincgit dependencies, and records that theprodperimeter has noGIT_PYPI_REPOSITORYintegration — so a flow deployed there with a private dependency will fail to bake until one is created.Optional/Unionstyle rather than mixing. Either declarerequires-pythonand modernize in one dedicated pass, or pintarget-versionin[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