Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for uv init --script #7565

Merged
merged 23 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
23 changes: 17 additions & 6 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2270,20 +2270,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.
#[arg(required_if_eq("script", "true"))]
pub path: Option<String>,

/// 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 @@ -2320,15 +2321,25 @@ 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 which adheres to the PEP-723 specification.
///
/// By default, the Python version the script depends on is the system version; can be
/// manually specified with the --python argument (takes absolute precedence) or a
/// .python-version file (ignore with --no_pin_python).
#[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
35 changes: 35 additions & 0 deletions crates/uv-scripts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,39 @@ impl Pep723Script {
})
}

pub async fn create_new_script(
script_path: impl AsRef<Path>,
requires_python: &VersionSpecifiers,
) -> Result<(), Pep723Error> {
let script_name = script_path
.as_ref()
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| Pep723Error::InvalidFilename)?;

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

let script = indoc::formatdoc! {r#"
{metadata}
def main():
print("Hello from {name}!")

if __name__ == "__main__":
main()
"#,
metadata = metadata,
name = script_name,
};

Ok(fs_err::tokio::write(script_path, 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,6 +194,8 @@ pub enum Pep723Error {
Utf8(#[from] std::str::Utf8Error),
#[error(transparent)]
Toml(#[from] toml::de::Error),
#[error("Invalid filename supplied")]
InvalidFilename,
}

#[derive(Debug, Clone, Eq, PartialEq)]
Expand Down
32 changes: 8 additions & 24 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 @@ -48,6 +48,8 @@ use crate::commands::{pip, project, ExitStatus, SharedState};
use crate::printer::Printer;
use crate::settings::{ResolverInstallerSettings, ResolverInstallerSettingsRef};

use super::init::get_python_requirement_for_new_script;

/// Add one or more packages to the project requirements.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn add(
Expand Down Expand Up @@ -128,34 +130,16 @@ 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(&*CWD, 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 = get_python_requirement_for_new_script(
&python,
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());
.await?;
Pep723Script::create(&script, requires_python.specifiers()).await?
};

Expand Down
142 changes: 141 additions & 1 deletion crates/uv/src/commands/project/init.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::fmt::Write;
use std::path::Path;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use owo_colors::OwoColorize;
Expand All @@ -14,6 +14,8 @@ use uv_python::{
PythonVersionFile, VersionRequest,
};
use uv_resolver::RequiresPython;
use uv_scripts::Pep723Script;
use uv_warnings::warn_user_once;
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
use uv_workspace::{DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceError};

Expand All @@ -40,6 +42,37 @@ pub(crate) async fn init(
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
// If user seeks to initialize a new script, process the request immediately
if let InitProjectKind::Script = project_kind {
if let Some(script_path) = explicit_path {
project_kind
.init_script(
&PathBuf::from(&script_path),
python,
connectivity,
python_preference,
python_downloads,
cache,
printer,
no_workspace,
no_readme,
no_pin_python,
package,
native_tls,
)
.await?;

writeln!(
printer.stderr(),
"Initialized script at `{}`",
script_path.cyan()
)?;
return Ok(ExitStatus::Success);
} else {
anyhow::bail!("Filename not provided for script");
}
}

// Default to the current directory if a path was not provided.
let path = match explicit_path {
None => CWD.to_path_buf(),
Expand Down Expand Up @@ -392,6 +425,7 @@ pub(crate) enum InitProjectKind {
#[default]
Application,
Library,
Script,
}

impl InitProjectKind {
Expand Down Expand Up @@ -428,6 +462,10 @@ impl InitProjectKind {
)
.await
}
InitProjectKind::Script => {
dbg!("Script should be initialized directly via init_script");
anyhow::bail!("Error during script initialization")
}
}
}

Expand Down Expand Up @@ -571,6 +609,108 @@ impl InitProjectKind {

Ok(())
}

async fn init_script(
self,
script_path: &PathBuf,
python: Option<String>,
connectivity: Connectivity,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
cache: &Cache,
printer: Printer,
no_workspace: bool,
no_readme: bool,
no_pin_python: bool,
package: bool,
native_tls: bool,
) -> Result<()> {
if no_workspace {
warn_user_once!("`--no_workspace` is a no-op for Python scripts, which are standalone");
}
if no_readme {
warn_user_once!("`--no_readme` is a no-op for Python scripts, which are standalone");
}
if package {
warn_user_once!("`--package` is a no-op for Python scripts, which are standalone");
}

if let Some(path) = script_path.to_str() {
if !path.ends_with(".py") {
anyhow::bail!("Script name must end in .py extension");
}
}

let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
.native_tls(native_tls);

let reporter = PythonDownloadReporter::single(printer);

if script_path.try_exists()? {
anyhow::bail!("Script already exists at {}", script_path.to_str().unwrap());
}

let requires_python = get_python_requirement_for_new_script(
&python,
no_pin_python,
python_preference,
python_downloads,
&client_builder,
cache,
&reporter,
)
.await?;

if let Some(path) = script_path.parent() {
fs_err::tokio::create_dir_all(path).await?;
}
Pep723Script::create_new_script(script_path, requires_python.specifiers()).await?;

Ok(())
}
}

pub(crate) async fn get_python_requirement_for_new_script(
python: &Option<String>,
no_pin_python: bool,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
client_builder: &BaseClientBuilder<'_>,
cache: &Cache,
reporter: &PythonDownloadReporter,
) -> Result<RequiresPython> {
let python_request = if let Some(request) = python.as_deref() {
// (1) Explicit request from user
PythonRequest::parse(request)
} else if let (false, Some(request)) = (
no_pin_python,
PythonVersionFile::discover(&*CWD, false, false)
.await?
.and_then(PythonVersionFile::into_version),
) {
// (2) Request from `.python-version`
request
} else {
// (3) Assume any Python version
PythonRequest::Any
};

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

Ok(RequiresPython::greater_than_equal_version(
&interpreter.python_minor_version(),
))
}

/// Generate the `[project]` section of a `pyproject.toml`.
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
Loading
Loading