Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2313,15 +2313,36 @@ pub struct InitArgs {
/// By default, an application is not intended to be built and distributed as a Python package.
/// The `--package` option can be used to create an application that is distributable, e.g., if
/// you want to distribute a command-line interface via PyPI.
#[arg(long, alias = "application", conflicts_with = "lib")]
#[arg(
long,
alias = "application",
conflicts_with = "lib",
conflicts_with = "script"
)]
pub r#app: bool,

/// Create a project for a library.
///
/// A library is a project that is intended to be built and distributed as a Python package.
#[arg(long, alias = "library", conflicts_with = "app")]
#[arg(
long,
alias = "library",
conflicts_with = "app",
conflicts_with = "script"
)]
pub r#lib: bool,

/// Create a script with inline metadata.
///
/// A Python script is a file intended for standalone execution with or without dependencies.
#[arg(
long,
conflicts_with = "app",
conflicts_with = "lib",
conflicts_with = "package"
)]
pub r#script: bool,

/// Do not create a `README.md` file.
#[arg(long)]
pub no_readme: bool,
Expand Down
80 changes: 77 additions & 3 deletions crates/uv/src/commands/project/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@ pub(crate) async fn init(
};

// Make sure a project does not already exist in the given directory.
if path.join("pyproject.toml").exists() {
if project_kind == InitProjectKind::Script {
if explicit_path.is_none() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Best practice is to do something like let Some(path) = explicit_path else { bail }. However, similar to https://github.com/astral-sh/uv/pull/7404/files#r1761072383, you may want to just match on project_kind above on L44 when determining the path variable.

anyhow::bail!("Missing script name to initialize",);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
anyhow::bail!("Missing script name to initialize",);
anyhow::bail!("Missing script name to initialize");

}
if path.exists() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we use try_exists here?

Should we add metadata to the front of the script if it doesn't exist? Or should we tackle that in a separate pull request? I think we already have machinery for this for uv add

anyhow::bail!(
"Script is already initialized in `{}`",
Copy link
Copy Markdown
Member

@zanieb zanieb Sep 16, 2024

Choose a reason for hiding this comment

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

Suggested change
"Script is already initialized in `{}`",
"Script already exists at `{}`",

path.display().cyan()
);
}
} else if path.join("pyproject.toml").exists() {
let path = std::path::absolute(&path).unwrap_or_else(|_| path.simplified().to_path_buf());
anyhow::bail!(
"Project is already initialized in `{}` (`pyproject.toml` file exists)",
Expand Down Expand Up @@ -87,7 +97,7 @@ pub(crate) async fn init(
.await?;

// Create the `README.md` if it does not already exist.
if !no_readme {
if !no_readme && project_kind != InitProjectKind::Script {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Generally, I'd use matches! instead of != here. I'm not sure why. cc @BurntSushi

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

matches! is strictly more flexible since you don't need a PartialEq impl to have it work. But for this specific instance, I don't think there's any meaningful advantage to one over the other. I don't mind the != here personally, especially if you aren't adding PartialEq impls just to make it work.

let readme = path.join("README.md");
if !readme.exists() {
fs_err::write(readme, String::new())?;
Expand All @@ -99,6 +109,16 @@ pub(crate) async fn init(
None => {
writeln!(printer.stderr(), "Initialized project `{}`", name.cyan())?;
}
// Initialized script
Some(path) if project_kind == InitProjectKind::Script => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps we should have a match on project_kind above this? If we change something around path handling in the future, we could easily miss the Script case with this structure.

let path =
std::path::absolute(&path).unwrap_or_else(|_| path.simplified().to_path_buf());
writeln!(
printer.stderr(),
"Initialized script at `{}`",
path.display().cyan()
)?;
}
// Initialized a project in the given directory.
Some(path) => {
let path =
Expand Down Expand Up @@ -387,11 +407,12 @@ async fn init_project(
Ok(())
}

#[derive(Debug, Copy, Clone, Default)]
#[derive(Debug, Copy, Clone, Default, PartialEq)]
pub(crate) enum InitProjectKind {
#[default]
Application,
Library,
Script,
}

impl InitProjectKind {
Expand Down Expand Up @@ -428,6 +449,9 @@ impl InitProjectKind {
)
.await
}
InitProjectKind::Script => {
InitProjectKind::init_script(path, requires_python, no_readme)
}
}
}

Expand Down Expand Up @@ -571,6 +595,35 @@ impl InitProjectKind {

Ok(())
}

fn init_script(path: &Path, requires_python: &RequiresPython, no_readme: bool) -> Result<()> {
let script_name = path
.file_name()
.and_then(|path| path.to_str())
.context("Missing directory name")?;

// Create the embedded `pyproject.toml`
let pyproject = pyproject_script(script_name, requires_python, no_readme);

// Create the script
if !path.try_exists()? {
fs_err::write(
path,
indoc::formatdoc! {r#"
{pyproject}

def main():
print("Hello from {script_name}!")


if __name__ == "__main__":
main()
"#},
)?;
}

Ok(())
}
}

/// Generate the `[project]` section of a `pyproject.toml`.
Expand All @@ -592,6 +645,27 @@ fn pyproject_project(
}
}

fn pyproject_script(name: &str, requires_python: &RequiresPython, no_readme: bool) -> String {
let readme_txt = indoc::formatdoc! {r#"
# /// readme
# You can execute this file with any tool compliant with inline script metadata. E.g.:
# $ uv run {name}
# ///

"#,
name = name,
};

indoc::formatdoc! {r#"{readme}# /// script
# requires-python = "{requires_python}"
# dependencies = []
# ///
"#,
readme = if no_readme { "" } else { &readme_txt },
requires_python = requires_python.specifiers(),
}
}

/// Generate the `[build-system]` section of a `pyproject.toml`.
fn pyproject_build_system() -> &'static str {
indoc::indoc! {r#"
Expand Down
12 changes: 7 additions & 5 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,17 +174,19 @@ impl InitSettings {
no_package,
app,
lib,
script,
no_readme,
no_pin_python,
no_workspace,
python,
} = args;

let kind = match (app, lib) {
(true, false) => InitProjectKind::Application,
(false, true) => InitProjectKind::Library,
(false, false) => InitProjectKind::default(),
(true, true) => unreachable!("`app` and `lib` are mutually exclusive"),
let kind = match (app, lib, script) {
(true, false, false) => InitProjectKind::Application,
(false, true, false) => InitProjectKind::Library,
(false, false, true) => InitProjectKind::Script,
(false, false, false) => InitProjectKind::default(),
_ => unreachable!("`app`, `lib` and `script` are mutually exclusive"),
};

let package = flag(package || r#virtual, no_package).unwrap_or(kind.packaged_by_default());
Expand Down
148 changes: 148 additions & 0 deletions crates/uv/tests/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,154 @@ fn init_library_no_package() -> Result<()> {
Ok(())
}

/// Run `uv init --script myapp.py` to create an application project
#[test]
fn init_script() -> Result<()> {
let context = TestContext::new("3.12");

let child = context.temp_dir.child("foo");
child.create_dir_all()?;

let myapp_py = child.join("myapp.py");

uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("myapp.py"), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Initialized script at `[TEMP_DIR]/foo/myapp.py`
"###);

let myapp = fs_err::read_to_string(myapp_py)?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
myapp, @r###"
# /// readme
# You can execute this file with any tool compliant with inline script metadata. E.g.:
# $ uv run myapp.py
# ///

# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///


def main():
print("Hello from myapp.py!")


if __name__ == "__main__":
main()
"###
);
});

uv_snapshot!(context.filters(), context.run().current_dir(&child).arg("myapp.py"), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from myapp.py!

----- stderr -----
Reading inline script metadata from: myapp.py
Resolved in [TIME]
Audited in [TIME]
"###);

Ok(())
}

/// Run `uv init --no-readme --script myapp.py` to create an application project
#[test]
fn init_script_no_readme() -> Result<()> {
let context = TestContext::new("3.12");

let child = context.temp_dir.child("foo");
child.create_dir_all()?;

let myapp_py = child.join("myapp.py");

uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--no-readme").arg("--script").arg("myapp.py"), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Initialized script at `[TEMP_DIR]/foo/myapp.py`
"###);

let myapp = fs_err::read_to_string(myapp_py)?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
myapp, @r###"
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///


def main():
print("Hello from myapp.py!")


if __name__ == "__main__":
main()
"###
);
});

Ok(())
}

/// When `myapp.py` already exists, we don't create it again
#[test]
fn init_script_exists() -> Result<()> {
let context = TestContext::new("3.12");

let child = context.temp_dir.child("foo");
child.create_dir_all()?;

let myapp = child.child("myapp.py");
myapp.touch()?;

uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script").arg("myapp.py"), @r###"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
error: Script is already initialized in `[TEMP_DIR]/foo/myapp.py`
"###);

Ok(())
}

/// When no app name passed, do not attempt to create a script with the CWD
#[test]
fn init_script_no_name() -> Result<()> {
let context = TestContext::new("3.12");

let child = context.temp_dir.child("foo");
child.create_dir_all()?;

uv_snapshot!(context.filters(), context.init().current_dir(&child).arg("--script"), @r###"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
error: Missing script name to initialize
"###);

Ok(())
}

/// Ensure that `uv init` initializes the cache.
#[test]
fn init_cache() -> Result<()> {
Expand Down
13 changes: 11 additions & 2 deletions docs/guides/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,20 @@ Multiple dependencies can be requested by repeating with `--with` option.
Note that if `uv run` is used in a _project_, these dependencies will be included _in addition_ to
the project's dependencies. To opt-out of this behavior, use the `--no-project` flag.

## Declaring script dependencies
## Creating a python script

Python recently added a standard format for
[inline script metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata).
This allows the dependencies for a script to be declared in the script itself.
It allows for selecting python versions and defining dependencies. Use `uv init --script` to
initialize scripts with the inline metadata:

```console
$ uv init --script example.py --python 3.12
```

## Declaring script dependencies

The inline metadata format allows the dependencies for a script to be declared in the script itself.

uv supports adding and updating inline script metadata for you. Use `uv add --script` to declare the
dependencies for the script:
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,10 @@ uv init [OPTIONS] [PATH]
</ul>
</dd><dt><code>--quiet</code>, <code>-q</code></dt><dd><p>Do not print any output</p>

</dd><dt><code>--script</code></dt><dd><p>Create a script with inline metadata.</p>

<p>A Python script is a file intended for standalone execution with or without dependencies.</p>

</dd><dt><code>--verbose</code>, <code>-v</code></dt><dd><p>Use verbose output.</p>

<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (&lt;https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives&gt;)</p>
Expand Down