Repository: astral-sh/uv (Rust)

Issue:
Add first-class support for virtual projects
A lot of users don't want to structure their projects as valid Python packages -- perhaps they're creating a web application, or a collection of scripts, etc. These all _can_ be structured as valid Python packages, but users tend not to do this. Requiring that projects are valid Python packages makes it harder for existing users to adopt uv.

Today, we allow "virtual projects" (i.e., without a build system) for workspace roots, but only for workspace roots.

This has several issues...

1. It _has_ to be a workspace, you need `[tool.uv.workspace]` which is confusing for users.
2. It only supports dev dependencies, also confusing for users.
3. There's no way to define project metadata which is confusing for users since they expect a `[project]` table. For example, there's no way to define a required python version in a "virtual project" (i.e., a virtual workspace root with no members).
4. There's (easy) no way to transition between the two. To convert a non-virtual `pyproject.toml` to virtual, you have to remove basically everything, move things around, etc.

What I want to do is, allow users to set:

```toml
[tool.uv]
virtual = true
```

If set, they can have a `[project]` table, they don't need a workspace definition, etc. And in this case, we don't build or install the project, just like a virtual workspace root today.

In the future, we'd like to write our own build system that handles these cases better by default. But for now, this will help a lot of users.


Discussion: There's an argument to be made that this should be the _default_ if you don't define a build system. I'm also open to that.
I am going to prototype this later today.
Thanks Charlie. This will definitely help users working in monorepos and legacy codebases that contain many nested packages/scripts without clear boundaries for various components of their applications/libraries.

It’s an architectural issue for sure, and outside of massively refactoring products/projects, not really solvable in the short term, so it is nice to see `uv` bridging the gap.
Yeah I think this is something we missed on slightly in the initial release. I'm gonna prioritize it.
Rather than the venv-in-project approach (which I personally prefer), lot of users just set up various OCI images for various “environments” that have the python dependencies pre-installed in a custom virtual environment location.

These are effectively virtual projects, and it goes hand in hand with being able to define custom virtual environment locations (several issues about it #1495 / #5229 / #6504).

[Conda](https://conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html) is strong enabler of this type of workflow so having compatibility with it would ease migration for thousands of users. 
Please see #1495 for that — let's try to avoid expanding the scope of this discussion :)
Highly unlikely that this ships today, but I'm starting to work on it.
Coming together in https://github.com/astral-sh/uv/pull/6585.
Using uv to "just manage my environment of dependencies" sounds natural and useful.

There was one kind of friction seen with Rye users with virtual projects, where they enable virtual project to achieve some goal (like not installing the root package, or not having to define how to create a proper wheel) but other things they want (pytest? editor support?) would benefit from installing the project as a python package in an environment.

I'm just wondering how to make it very clear what's happening so that the trade-offs are apparent to the user?

Are pipx and uv tool good points for why uv virtual project is not <=> "application", because you need applications distributed through pipx/uv tool to be "pip-installable"?



Fix (patch):
diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs
index b68666f2c04ae..ea67833743f9a 100644
--- a/crates/uv-cli/src/lib.rs
+++ b/crates/uv-cli/src/lib.rs
@@ -2099,11 +2099,12 @@ pub struct InitArgs {
     #[arg(long)]
     pub name: Option<PackageName>,
 
-    /// Create a virtual workspace instead of a project.
+    /// Create a virtual project, rather than a package.
     ///
-    /// A virtual workspace does not define project dependencies and cannot be
-    /// published. Instead, workspace members declare project dependencies.
-    /// Development dependencies may still be declared.
+    /// A virtual project is a project that is not intended to be built as a Python package,
+    /// such as a project that only contains scripts or other application code.
+    ///
+    /// Virtual projects themselves are not installed into the Python environment.
     #[arg(long)]
     pub r#virtual: bool,
 
diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs
index 273a1d8c0d6d2..53ae970ae41ea 100644
--- a/crates/uv-settings/src/settings.rs
+++ b/crates/uv-settings/src/settings.rs
@@ -70,6 +70,10 @@ pub struct Options {
     #[serde(default, skip_serializing)]
     #[cfg_attr(feature = "schemars", schemars(skip))]
     managed: serde::de::IgnoredAny,
+
+    #[serde(default, skip_serializing)]
+    #[cfg_attr(feature = "schemars", schemars(skip))]
+    r#package: serde::de::IgnoredAny,
 }
 
 impl Options {
diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs
index c2eaee29652f2..89c4af68200ea 100644
--- a/crates/uv-workspace/src/pyproject.rs
+++ b/crates/uv-workspace/src/pyproject.rs
@@ -33,6 +33,10 @@ pub struct PyProjectToml {
     /// The raw unserialized document.
     #[serde(skip)]
     pub raw: String,
+
+    /// Used to determine whether a `build-system` is present.
+    #[serde(default, skip_serializing)]
+    build_system: Option<serde::de::IgnoredAny>,
 }
 
 impl PyProjectToml {
@@ -41,6 +45,23 @@ impl PyProjectToml {
         let pyproject = toml::from_str(&raw)?;
         Ok(PyProjectToml { raw, ..pyproject })
     }
+
+    /// Returns `true` if the project should be considered a Python package, as opposed to a
+    /// non-package ("virtual") project.
+    pub fn is_package(&self) -> bool {
+        // If `tool.uv.package` is set, defer to that explicit setting.
+        if let Some(is_package) = self
+            .tool
+            .as_ref()
+            .and_then(|tool| tool.uv.as_ref())
+            .and_then(|uv| uv.package)
+        {
+            return is_package;
+        }
+
+        // Otherwise, a project is assumed to be a package if `build-system` is present.
+        self.build_system.is_some()
+    }
 }
 
 // Ignore raw document in comparison.
@@ -100,6 +121,24 @@ pub struct ToolUv {
         "#
     )]
     pub managed: Option<bool>,
+    /// Whether the project should be considered a Python package, or a non-package ("virtual")
+    /// project.
+    ///
+    /// Packages are built and installed into the virtual environment in editable mode and thus
+    /// require a build backend, while virtual projects are _not_ built or installed; instead, only
+    /// their dependencies are included in the virtual environment.
+    ///
+    /// Creating a package requires that a `build-system` is present in the `pyproject.toml`, and
+    /// that the project adheres to a structure that adheres to the build backend's expectations
+    /// (e.g., a `src` layout).
+    #[option(
+        default = r#"true"#,
+        value_type = "bool",
+        example = r#"
+            package = false
+        "#
+    )]
+    pub package: Option<bool>,
     /// The project's development dependencies. Development dependencies will be installed by
     /// default in `uv run` and `uv sync`, but will not appear in the project's published metadata.
     #[cfg_attr(
diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs
index 4e8f3d34aa097..1fef22d8d2e73 100644
--- a/crates/uv-workspace/src/workspace.rs
+++ b/crates/uv-workspace/src/workspace.rs
@@ -1339,7 +1339,7 @@ impl VirtualProject {
         }
     }
 
-    /// Return the [`PackageName`] of the project, if it's not a virtual workspace.
+    /// Return the [`PackageName`] of the project, if it's not a virtual workspace root.
     pub fn project_name(&self) -> Option<&PackageName> {
         match self {
             VirtualProject::Project(project) => Some(project.project_name()),
@@ -1347,7 +1347,7 @@ impl VirtualProject {
         }
     }
 
-    /// Returns `true` if the project is a virtual workspace.
+    /// Returns `true` if the project is a virtual workspace root.
     pub fn is_virtual(&self) -> bool {
         matches!(self, VirtualProject::Virtual(_))
     }
@@ -1535,6 +1535,7 @@ mod tests {
                         "exclude": null
                       },
                       "managed": null,
+                      "package": null,
                       "dev-dependencies": null,
                       "environments": null,
                       "override-dependencies": null,
@@ -1607,6 +1608,7 @@ mod tests {
                         "exclude": null
                       },
                       "managed": null,
+                      "package": null,
                       "dev-dependencies": null,
                       "environments": null,
                       "override-dependencies": null,
diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs
index 7180d9157b3a6..914abc1ac440b 100644
--- a/crates/uv/src/commands/project/init.rs
+++ b/crates/uv/src/commands/project/init.rs
@@ -15,7 +15,7 @@ use uv_python::{
 };
 use uv_resolver::RequiresPython;
 use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
-use uv_workspace::{check_nested_workspaces, DiscoveryOptions, Workspace, WorkspaceError};
+use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceError};
 
 use crate::commands::project::find_requires_python;
 use crate::commands::reporters::PythonDownloadReporter;
@@ -69,24 +69,21 @@ pub(crate) async fn init(
         }
     };
 
-    if r#virtual {
-        init_virtual_workspace(&path, no_workspace)?;
-    } else {
-        init_project(
-            &path,
-            &name,
-            no_readme,
-            python,
-            no_workspace,
-            python_preference,
-            python_downloads,
-            connectivity,
-            native_tls,
-            cache,
-            printer,
-        )
-        .await?;
-    }
+    init_project(
+        &path,
+        &name,
+        r#virtual,
+        no_readme,
+        python,
+        no_workspace,
+        python_preference,
+        python_downloads,
+        connectivity,
+        native_tls,
+        cache,
+        printer,
+    )
+    .await?;
 
     // Create the `README.md` if it does not already exist.
     if !no_readme {
@@ -126,29 +123,12 @@ pub(crate) async fn init(
     Ok(ExitStatus::Success)
 }
 
-/// Initialize a virtual workspace at the given path.
-fn init_virtual_workspace(path: &Path, no_workspace: bool) -> Result<()> {
-    // Ensure that we aren't creating a nested workspace.
-    if !no_workspace {
-        check_nested_workspaces(path, &DiscoveryOptions::default());
-    }
-
-    // Create the `pyproject.toml`.
-    let pyproject = indoc::indoc! {r"
-        [tool.uv.workspace]
-        members = []
-    "};
-
-    fs_err::create_dir_all(path)?;
-    fs_err::write(path.join("pyproject.toml"), pyproject)?;
-
-    Ok(())
-}
-
 /// Initialize a project (and, implicitly, a workspace root) at the given path.
+#[allow(clippy::fn_params_excessive_bools)]
 async fn init_project(
     path: &Path,
     name: &PackageName,
+    r#virtual: bool,
     no_readme: bool,
     python: Option<String>,
     no_workspace: bool,
@@ -265,38 +245,56 @@ async fn init_project(
         RequiresPython::greater_than_equal_version(&interpreter.python_minor_version())
     };
 
-    // Create the `pyproject.toml`.
-    let pyproject = indoc::formatdoc! {r#"
-        [project]
-        name = "{name}"
-        version = "0.1.0"
-        description = "Add your description here"{readme}
-        requires-python = "{requires_python}"
-        dependencies = []
+    if r#virtual {
+        // Create the `pyproject.toml`, but omit `[build-system]`.
+        let pyproject = indoc::formatdoc! {r#"
+            [project]
+            name = "{name}"
+            version = "0.1.0"
+            description = "Add your description here"{readme}
+            requires-python = "{requires_python}"
+            dependencies = []
+            "#,
+            readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
+            requires_python = requires_python.specifiers(),
+        };
 
-        [build-system]
-        requires = ["hatchling"]
-        build-backend = "hatchling.build"
-        "#,
-        readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
-        requires_python = requires_python.specifiers(),
-    };
+        fs_err::create_dir_all(path)?;
+        fs_err::write(path.join("pyproject.toml"), pyproject)?;
+    } else {
+        // Create the `pyproject.toml`.
+        let pyproject = indoc::formatdoc! {r#"
+            [project]
+            name = "{name}"
+            version = "0.1.0"
+            description = "Add your description here"{readme}
+            requires-python = "{requires_python}"
+            dependencies = []
+
+            [build-system]
+            requires = ["hatchling"]
+            build-backend = "hatchling.build"
+            "#,
+            readme = if no_readme { "" } else { "\nreadme = \"README.md\"" },
+            requires_python = requires_python.specifiers(),
+        };
 
-    fs_err::create_dir_all(path)?;
-    fs_err::write(path.join("pyproject.toml"), pyproject)?;
+        fs_err::create_dir_all(path)?;
+        fs_err::write(path.join("pyproject.toml"), pyproject)?;
 
-    // Create `src/{name}/__init__.py`, if it doesn't exist already.
-    let src_dir = path.join("src").join(&*name.as_dist_info_name());
-    let init_py = src_dir.join("__init__.py");
-    if !init_py.try_exists()? {
-        fs_err::create_dir_all(&src_dir)?;
-        fs_err::write(
-            init_py,
-            indoc::formatdoc! {r#"
+        // Create `src/{name}/__init__.py`, if it doesn't exist already.
+        let src_dir = path.join("src").join(&*name.as_dist_info_name());
+        let init_py = src_dir.join("__init__.py");
+        if !init_py.try_exists()? {
+            fs_err::create_dir_all(&src_dir)?;
+            fs_err::write(
+                init_py,
+                indoc::formatdoc! {r#"
             def hello() -> str:
                 return "Hello from {name}!"
             "#},
-        )?;
+            )?;
+        }
     }
 
     if let Some(workspace) = workspace {
diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs
index ef97e30fe3029..645d128879e9c 100644
--- a/crates/uv/src/commands/project/sync.rs
+++ b/crates/uv/src/commands/project/sync.rs
@@ -1,5 +1,8 @@
 use anyhow::{Context, Result};
 use itertools::Itertools;
+use rustc_hash::FxHashSet;
+
+use distribution_types::Name;
 use pep508_rs::MarkerTree;
 use uv_auth::store_credentials_from_url;
 use uv_cache::Cache;
@@ -195,6 +198,9 @@ pub(super) async fn do_sync(
     // Read the lockfile.
     let resolution = lock.to_resolution(project, &markers, tags, extras, &dev)?;
 
+    // Always skip virtual projects, which shouldn't be built or installed.
+    let resolution = apply_no_virtual_project(resolution, project);
+
     // Filter resolution based on install-specific options.
     let resolution = install_options.filter_resolution(resolution, project);
 
@@ -289,3 +295,32 @@ pub(super) async fn do_sync(
 
     Ok(())
 }
+
+/// Filter out any virtual workspace members.
+fn apply_no_virtual_project(
+    resolution: distribution_types::Resolution,
+    project: &VirtualProject,
+) -> distribution_types::Resolution {
+    let VirtualProject::Project(project) = project else {
+        // If the project is _only_ a virtual workspace root, we don't need to filter it out.
+        return resolution;
+    };
+
+    let virtual_members = project
+        .workspace()
+        .packages()
+        .iter()
+        .filter_map(|(name, package)| {
+            // A project is a package if it's explicitly marked as such, _or_ if a build system is
+            // present.
+            if package.pyproject_toml().is_package() {
+                None
+            } else {
+                Some(name)
+            }
+        })
+        .collect::<FxHashSet<_>>();
+
+    // Remove any virtual members from the resolution.
+    resolution.filter(|dist| !virtual_members.contains(dist.name()))
+}
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index 7d9644f6fc652..f05390cbe24e5 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -455,9 +455,11 @@ uv init [OPTIONS] [PATH]
 
 </dd><dt><code>--version</code>, <code>-V</code></dt><dd><p>Display the uv version</p>
 
-</dd><dt><code>--virtual</code></dt><dd><p>Create a virtual workspace instead of a project.</p>
+</dd><dt><code>--virtual</code></dt><dd><p>Create a virtual project, rather than a package.</p>
 
-<p>A virtual workspace does not define project dependencies and cannot be published. Instead, workspace members declare project dependencies. Development dependencies may still be declared.</p>
+<p>A virtual project is a project that is not intended to be built as a Python package, such as a project that only contains scripts or other application code.</p>
+
+<p>Virtual projects themselves are not installed into the Python environment.</p>
 
 </dd></dl>
 
diff --git a/docs/reference/settings.md b/docs/reference/settings.md
index fc9b11a629f55..1f6dd3f668beb 100644
--- a/docs/reference/settings.md
+++ b/docs/reference/settings.md
@@ -890,6 +890,40 @@ requirements of any constituent packages.
 
 ---
 
+#### [`package`](#package) {: #package }
+
+Whether the project should be considered a Python package, or a non-package ("virtual")
+project.
+
+Packages are built and installed into the virtual environment in editable mode and thus
+require a build backend, while virtual projects are _not_ built or installed; instead, only
+their dependencies are included in the virtual environment.
+
+Creating a package requires that a `build-system` is present in the `pyproject.toml`, and
+that the project adheres to a structure that adheres to the build backend's expectations
+(e.g., a `src` layout).
+
+**Default value**: `true`
+
+**Type**: `bool`
+
+**Example usage**:
+
+=== "pyproject.toml"
+
+    ```toml
+    [tool.uv]
+    package = false
+    ```
+=== "uv.toml"
+
+    ```toml
+    
+    package = false
+    ```
+
+---
+
 #### [`prerelease`](#prerelease) {: #prerelease }
 
 The strategy to use when considering pre-release versions.
diff --git a/uv.schema.json b/uv.schema.json
index 2ba30d6e9d5ff..da3e127c8c38c 100644
--- a/uv.schema.json
+++ b/uv.schema.json
@@ -274,6 +274,13 @@
         "type": "string"
       }
     },
+    "package": {
+      "description": "Whether the project should be considered a Python package, or a non-package (\"virtual\") project.\n\nPackages are built and installed into the virtual environment in editable mode and thus require a build backend, while virtual projects are _not_ built or installed; instead, only their dependencies are included in the virtual environment.\n\nCreating a package requires that a `build-system` is present in the `pyproject.toml`, and that the project adheres to a structure that adheres to the build backend's expectations (e.g., a `src` layout).",
+      "type": [
+        "boolean",
+        "null"
+      ]
+    },
     "pip": {
       "anyOf": [
         {