Skip to content

Expand uv workspace metadata with dependency information from the lock#18356

Open
Gankra wants to merge 3 commits intomainfrom
project-json
Open

Expand uv workspace metadata with dependency information from the lock#18356
Gankra wants to merge 3 commits intomainfrom
project-json

Conversation

@Gankra
Copy link
Contributor

@Gankra Gankra commented Mar 6, 2026

Summary

This expands uv workspace metadata with many of the fields that are found in uv.lock so that we have a format with information about the dependency graph/resolution that we're willing to call stable and have people rely upon (rather than uv.lock which we'd rather you don't try to interpret).

To a first approximation you can think of this as "uv.lock but serialized to json" but with the fields a bit more limited for now (easy to add later).

The biggest intentional divergence with uv.lock is that we favour encoding the dependency graph in a form that looks more like our internal "resolve" graph, in that hopes that it will simplify the work of anyone doing analysis on the graph (we structure our internal graph like this for a reason).

Specifically, the resolve field contains the entire dependency graph, with packages desugarred into several different nodes. There are 4 kinds of nodes (really 3, the build nodes will only be introduced when we establish build-dependency locking):

  • packages: mypackage==1.0.0 @ registry+https://pypi.org/simple
  • extras: mypackage[myextra]==1.0.0 @ registry+https://pypi.org/simple
  • groups: mypackage:mygroup==1.0.0 @ registry+https://pypi.org/simple
  • build: mypackage(build)==1.0.0 @ registry+https://pypi.org/simple

package nodes hold additional metadata about the package itself, and ids of the associated extra/group/build nodes.


A package like this:

[project]
name = "mypackage"
version = "1.0.0"

dependencies = ["httpx"]

[project.optional-dependencies]
cli = ["rich"]

[dependency-groups]
dev = ["typing-extensions"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

will get 4 nodes with the following edges (Version and Source omitted here for brevity):

  • mypackage
    • httpx
  • mypackage(build)
    • hatchling
  • mypackage[cli]
    • mypackage
    • rich
  • mypackage:dev
    • typing-extensions

Note that mypackage[cli] has a dependency edge on mypackage while mypackage:dev does not. This is because
mypackage[cli] is fundamentally an augmentation of mypackage while mypackage:dev is just a list of packages that happens to be defined by mypackage's pyproject.toml.

The resulting nodes for mypackage will look something like:

json blob
{
  "resolve": {
    "mypackage==1.0.0 @ editable+.": {
      "name": "mypackage",
      "version": "1.0.0",
      "source": {
        "editable": "."
      },
      "kind": "package",
      "dependencies": [
        {
          "id": "httpx==3.6 @ registry+https://pypi.org/simple"
          "marker": "sys_platform == 'linux'"
        },
      ],
      "optional_dependencies": [
        {
          "name": "cli",
          "id": "mypackage[cli]==1.0.0 @ editable+."
        },
      ],
      "dependency_groups": [
        {
          "name": "dev",
          "id": "mypackage:dev==1.0.0 @ editable+."
        }
      ]
      "build_system": {
        "build_backend": "hatchling.build",
        "id": "mypackage(build)==1.0.0 @ editable+."
      }
      "sdist": { ... },
      "wheels": [ ... ]
    },
    "mypackage:dev==1.0.0 @ editable+.": {
        "name": "mypackage",
        "version": "1.0.0",
        "source": {
          "editable": "."
        },
        "kind": {
          "group": "dev"
        },
        "dependencies": [
          {
            "id": "typing-extensions==1.2.3 @ registry+https://pypi.org/simple"
          },
        ]
      },
   }
   "mypackage[cli]==1.0.0 @ editable+.": {
      "name": "mypackage",
      "version": "1.0.0",
      "source": {
        "editable": "."
      },
      "kind": {
        "extra": "cli"
      },
      "dependencies": [
        {
          "id": "rich==2.2.3 @ registry+https://pypi.org/simple"
        },
        {
          "id": "mypackage==1.0.0 @ editable+."
        },
      ]
    },
    "mypackage(build)==1.0.0 @ editable+.": {
      "name": "mypackage",
      "version": "1.0.0",
      "source": {
        "editable": "."
      },
      "kind": "build",
      "dependencies": [
        {
          "id": "hatchling==3.2.3 @ registry+https://pypi.org/simple"
        },
      ]
    }
  }
}

Test Plan

Snapshots

@Gankra Gankra requested a review from konstin March 6, 2026 18:50
@Gankra Gankra added the enhancement New feature or improvement to existing functionality label Mar 6, 2026
@Gankra
Copy link
Contributor Author

Gankra commented Mar 6, 2026

Note that if the desugared "resolve" thing is too weird it's really easy for me to inline the virtual nodes back into the package nodes -- they are after all basically just an array of dependencies.

My motivation for desugarring is because similar sugaring of nodes in cargo metadata leads to confusion, as people don't understand that mypackage and mypackage(test) or mypackage(build) should be analyzed as distinct nodes. Hence this long screed I had to write in guppy.

@konstin
Copy link
Member

konstin commented Mar 10, 2026

Can you add what kinds of queries we users to be able to run against this graph? Such as "Give me all the wheels that would be installed on linux (to audit them)", or "What packages are outdated", etc.

kind: MetadataNodeKind,
}

type MetadataNodeIdFlat = String;
Copy link
Member

Choose a reason for hiding this comment

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

Can you add user-facing documentation that the format of this id is internal and not for parsing?

"members": [
{
"name": "albatross",
"path": "[TEMP_DIR]/workspace"
"path": "[TEMP_DIR]/workspace",
Copy link
Member

Choose a reason for hiding this comment

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

How do I know what the entrypoint is, just path equals workspace root?

.await
{
Ok(lock) => print_lock_as_metadata(&workspace, &lock.into_lock(), printer),
Err(err @ ProjectError::LockMismatch(..)) => {
Copy link
Member

Choose a reason for hiding this comment

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

How is that different from forwarding the error?

args.python,
args.install_mirrors,
args.settings,
client_builder.subcommand(vec!["lock".to_owned()]),
Copy link
Member

Choose a reason for hiding this comment

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

I mean it does a lock op, but is that the right string here?

@@ -319,3 +839,2610 @@ fn workspace_metadata_no_project() {
"
);
}

/// Test packse (has optional-dependencies, dev-dependencies, and build-system)
Copy link
Member

Choose a reason for hiding this comment

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

That snapshot is over 2000 lines long, can we use something shorter that's easier to check?


#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "snake_case")]
enum MetadataWheelWireSource {
Copy link
Member

Choose a reason for hiding this comment

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

The "url": { "url": "url": … } } looks strange, should make this enum untagged, similar to tool.uv.sources?

fn from_wheel(wheel: &Wheel) -> Self {
Self {
url: MetadataWheelWireSource::from_wheel(&wheel.url),
hash: wheel.hash.as_ref().map(ToString::to_string),
Copy link
Member

Choose a reason for hiding this comment

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

I know it's what we do in uv.lock, but I think we should rather follow PEP 691 here (https://peps.python.org/pep-0691/#project-detail), this avoids string parsing in the client and allows multiple hashes, should we need them in the future.

/// This is because `mypackage[cli]` is fundamentally an augmentation of `mypackage` while `mypackage:dev`
/// is just a list of packages that happens to be defined by `mypackage`'s pyproject.toml.
#[derive(Debug, Clone, serde::Serialize)]
struct MetadataNode {
Copy link
Member

Choose a reason for hiding this comment

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

Do we have a description or schema available somewhere?

struct MetadataNodeId {
/// The name of the package
name: PackageName,
/// The version of the package, if any could be found (workspace packages may have no version)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
/// The version of the package, if any could be found (workspace packages may have no version)
/// The version of the package, if any could be found (source trees may have no version)

or "directory dependencies"

registry: MetadataRegistrySource::Path(PortablePathBuf::from(path)),
},
},
Source::Git(url, _) => Self::Git { git: url },
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we record the resolved git info too?

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

Labels

enhancement New feature or improvement to existing functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants