Skip to content

Commit

Permalink
Add support for uv init --script (#7565)
Browse files Browse the repository at this point in the history
This PR adds support for ```uv init --script```, as defined in issue
#7402 (started working on this before I saw jbvsmo's PR). Wanted to
highlight a few decisions I made that differ from the existing PR:

1. ```--script``` takes a path, instead of a path/name. This potentially
leads to a little ambiguity (I can certainly elaborate in the docs,
lmk!), but strictly allowing ```uv init --script path/to/script.py```
felt a little more natural than allowing for ```uv init --script path/to
--name script.py``` (which I also thought would prompt more questions
for users, such as should the name include the .py extension?)
2. The request is processed immediately in the ```init``` method,
sharing logic in resolving which python version to use with ```uv add
--script```. This made more sense to me — since scripts are meant to
operate in isolation, they shouldn't consider the context of an
encompassing package should one exist (I also think this decision makes
the relative codepaths for scripts/packages easier to follow).
3. No readme — readme felt a little excessive for a script, but I can of
course add it in!

---------

Co-authored-by: João Bernardo Oliveira <[email protected]>
  • Loading branch information
tfsingh and jbvsmo committed Sep 25, 2024
1 parent a3abd89 commit 6e9ecde
Show file tree
Hide file tree
Showing 10 changed files with 552 additions and 123 deletions.
27 changes: 20 additions & 7 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2301,20 +2301,21 @@ impl ExternalCommand {
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct InitArgs {
/// The path to use for the project.
/// The path to use for the project/script.
///
/// Defaults to the current working directory. Accepts relative and absolute
/// paths.
/// Defaults to the current working directory when initializing an app or library;
/// required when initializing a script. Accepts relative and absolute paths.
///
/// If a `pyproject.toml` is found in any of the parent directories of the
/// target path, the project will be added as a workspace member of the
/// parent, unless `--no-workspace` is provided.
pub path: Option<String>,
#[arg(required_if_eq("script", "true"))]
pub path: Option<PathBuf>,

/// The name of the project.
///
/// Defaults to the name of the directory.
#[arg(long)]
#[arg(long, conflicts_with = "script")]
pub name: Option<PackageName>,

/// Create a virtual project, rather than a package.
Expand Down Expand Up @@ -2351,15 +2352,27 @@ 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_all = ["lib", "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_all=["app", "script"])]
pub r#lib: bool,

/// Create a script.
///
/// A script is a standalone file with embedded metadata enumerating its dependencies, along
/// with any Python version requirements, as defined in the PEP 723 specification.
///
/// PEP 723 scripts can be executed directly with `uv run`.
///
/// By default, adds a requirement on the system Python version; use `--python` to specify an
/// alternative Python version requirement.
#[arg(long, alias="script", conflicts_with_all=["app", "lib", "package"])]
pub r#script: bool,

/// Do not create a `README.md` file.
#[arg(long)]
pub no_readme: bool,
Expand Down
53 changes: 50 additions & 3 deletions crates/uv-scripts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ impl Pep723Script {
/// Reads a Python script and generates a default PEP 723 metadata table.
///
/// See: <https://peps.python.org/pep-0723/>
pub async fn create(
pub async fn init(
file: impl AsRef<Path>,
requires_python: &VersionSpecifiers,
) -> Result<Self, Pep723Error> {
Expand Down Expand Up @@ -95,6 +95,51 @@ impl Pep723Script {
})
}

/// Create a PEP 723 script at the given path.
pub async fn create(
file: impl AsRef<Path>,
requires_python: &VersionSpecifiers,
existing_contents: Option<Vec<u8>>,
) -> Result<(), Pep723Error> {
let file = file.as_ref();

let script_name = file
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| Pep723Error::InvalidFilename(file.to_string_lossy().to_string()))?;

let default_metadata = indoc::formatdoc! {r#"
requires-python = "{requires_python}"
dependencies = []
"#,
};
let metadata = serialize_metadata(&default_metadata);

let script = if let Some(existing_contents) = existing_contents {
indoc::formatdoc! {r#"
{metadata}
{content}
"#,
content = String::from_utf8(existing_contents).map_err(|err| Pep723Error::Utf8(err.utf8_error()))?}
} else {
indoc::formatdoc! {r#"
{metadata}
def main() -> None:
print("Hello from {name}!")
if __name__ == "__main__":
main()
"#,
metadata = metadata,
name = script_name,
}
};

Ok(fs_err::tokio::write(file, script).await?)
}

/// Replace the existing metadata in the file with new metadata and write the updated content.
pub async fn write(&self, metadata: &str) -> Result<(), Pep723Error> {
let content = format!(
Expand Down Expand Up @@ -161,10 +206,12 @@ pub enum Pep723Error {
Utf8(#[from] std::str::Utf8Error),
#[error(transparent)]
Toml(#[from] toml::de::Error),
#[error("Invalid filename `{0}` supplied")]
InvalidFilename(String),
}

#[derive(Debug, Clone, Eq, PartialEq)]
struct ScriptTag {
pub struct ScriptTag {
/// The content of the script before the metadata block.
prelude: String,
/// The metadata block.
Expand Down Expand Up @@ -202,7 +249,7 @@ impl ScriptTag {
/// - Postlude: `import requests\n\nprint("Hello, World!")\n`
///
/// See: <https://peps.python.org/pep-0723/>
fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
pub fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
// Identify the opening pragma.
let Some(index) = FINDER.find(contents) else {
return Ok(None);
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub(crate) use pip::tree::pip_tree;
pub(crate) use pip::uninstall::pip_uninstall;
pub(crate) use project::add::add;
pub(crate) use project::export::export;
pub(crate) use project::init::{init, InitProjectKind};
pub(crate) use project::init::{init, InitKind, InitProjectKind};
pub(crate) use project::lock::lock;
pub(crate) use project::remove::remove;
pub(crate) use project::run::{run, RunCommand};
Expand Down
35 changes: 9 additions & 26 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use uv_python::{
PythonPreference, PythonRequest, PythonVersionFile, VersionRequest,
};
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
use uv_resolver::{FlatIndex, RequiresPython};
use uv_resolver::FlatIndex;
use uv_scripts::Pep723Script;
use uv_types::{BuildIsolation, HashStrategy};
use uv_warnings::warn_user_once;
Expand All @@ -42,7 +42,7 @@ use crate::commands::pip::loggers::{
};
use crate::commands::pip::operations::Modifications;
use crate::commands::pip::resolution_environment;
use crate::commands::project::ProjectError;
use crate::commands::project::{script_python_requirement, ProjectError};
use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
use crate::commands::{pip, project, ExitStatus, SharedState};
use crate::printer::Printer;
Expand Down Expand Up @@ -129,35 +129,18 @@ pub(crate) async fn add(
let script = if let Some(script) = Pep723Script::read(&script).await? {
script
} else {
let python_request = if let Some(request) = python.as_deref() {
// (1) Explicit request from user
PythonRequest::parse(request)
} else if let Some(request) = PythonVersionFile::discover(project_dir, false, false)
.await?
.and_then(PythonVersionFile::into_version)
{
// (2) Request from `.python-version`
request
} else {
// (3) Assume any Python version
PythonRequest::Default
};

let interpreter = PythonInstallation::find_or_download(
Some(&python_request),
EnvironmentPreference::Any,
let requires_python = script_python_requirement(
python.as_deref(),
project_dir,
false,
python_preference,
python_downloads,
&client_builder,
cache,
Some(&reporter),
&reporter,
)
.await?
.into_interpreter();

let requires_python =
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version());
Pep723Script::create(&script, requires_python.specifiers()).await?
.await?;
Pep723Script::init(&script, requires_python.specifiers()).await?
};

let python_request = if let Some(request) = python.as_deref() {
Expand Down
Loading

0 comments on commit 6e9ecde

Please sign in to comment.