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

Skip namespace package enforcement for PEP 723 scripts #13974

Merged
merged 1 commit into from
Oct 29, 2024
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///

import requests
from rich.pretty import pprint

resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
5 changes: 3 additions & 2 deletions crates/ruff_linter/src/rules/flake8_no_pep420/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ mod tests {
use crate::settings::LinterSettings;
use crate::test::{test_path, test_resource_path};

#[test_case(Path::new("test_pass_init"), Path::new("example.py"))]
#[test_case(Path::new("test_fail_empty"), Path::new("example.py"))]
#[test_case(Path::new("test_fail_nonempty"), Path::new("example.py"))]
#[test_case(Path::new("test_pass_shebang"), Path::new("example.py"))]
#[test_case(Path::new("test_ignored"), Path::new("example.py"))]
#[test_case(Path::new("test_pass_init"), Path::new("example.py"))]
#[test_case(Path::new("test_pass_namespace_package"), Path::new("example.py"))]
#[test_case(Path::new("test_pass_pep723"), Path::new("script.py"))]
#[test_case(Path::new("test_pass_pyi"), Path::new("example.pyi"))]
#[test_case(Path::new("test_pass_script"), Path::new("script"))]
#[test_case(Path::new("test_pass_shebang"), Path::new("example.py"))]
fn test_flake8_no_pep420(path: &Path, filename: &Path) -> Result<()> {
let snapshot = format!("{}", path.to_string_lossy());
let p = PathBuf::from(format!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};

use ruff_diagnostics::{Diagnostic, Violation};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::script::ScriptTag;
use ruff_python_ast::PySourceType;
use ruff_python_trivia::CommentRanges;
use ruff_text_size::{TextRange, TextSize};
Expand Down Expand Up @@ -65,6 +66,8 @@ pub(crate) fn implicit_namespace_package(
&& !comment_ranges
.first().filter(|range| range.start() == TextSize::from(0))
.is_some_and(|range| ShebangDirective::try_extract(locator.slice(*range)).is_some())
// Ignore PEP 723 scripts.
&& ScriptTag::parse(locator.contents().as_bytes()).is_none()
{
#[cfg(all(test, windows))]
let path = path
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/flake8_no_pep420/mod.rs
---

3 changes: 2 additions & 1 deletion crates/ruff_python_ast/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ ruff_text_size = { workspace = true }

aho-corasick = { workspace = true }
bitflags = { workspace = true }
compact_str = { workspace = true }
is-macro = { workspace = true }
itertools = { workspace = true }
memchr = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
compact_str = { workspace = true }

[features]
schemars = ["dep:schemars"]
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_python_ast/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod node;
mod nodes;
pub mod parenthesize;
pub mod relocate;
pub mod script;
pub mod statement_visitor;
pub mod stmt_if;
pub mod str;
Expand Down
149 changes: 149 additions & 0 deletions crates/ruff_python_ast/src/script.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
use std::sync::LazyLock;

use memchr::memmem::Finder;

static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"));

/// PEP 723 metadata as parsed from a `script` comment block.
///
/// See: <https://peps.python.org/pep-0723/>
///
/// Vendored from: <https://github.com/astral-sh/uv/blob/debe67ffdb0cd7835734100e909b2d8f79613743/crates/uv-scripts/src/lib.rs#L283>
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ScriptTag {
/// The content of the script before the metadata block.
prelude: String,
/// The metadata block.
metadata: String,
/// The content of the script after the metadata block.
postlude: String,
}

impl ScriptTag {
/// Given the contents of a Python file, extract the `script` metadata block with leading
/// comment hashes removed, any preceding shebang or content (prelude), and the remaining Python
/// script.
///
/// Given the following input string representing the contents of a Python script:
///
/// ```python
/// #!/usr/bin/env python3
/// # /// script
/// # requires-python = '>=3.11'
/// # dependencies = [
/// # 'requests<3',
/// # 'rich',
/// # ]
/// # ///
///
/// import requests
///
/// print("Hello, World!")
/// ```
///
/// This function would return:
///
/// - Preamble: `#!/usr/bin/env python3\n`
/// - Metadata: `requires-python = '>=3.11'\ndependencies = [\n 'requests<3',\n 'rich',\n]`
/// - Postlude: `import requests\n\nprint("Hello, World!")\n`
///
/// See: <https://peps.python.org/pep-0723/>
pub fn parse(contents: &[u8]) -> Option<Self> {
// Identify the opening pragma.
let index = FINDER.find(contents)?;

// The opening pragma must be the first line, or immediately preceded by a newline.
if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) {
return None;
}

// Extract the preceding content.
let prelude = std::str::from_utf8(&contents[..index]).ok()?;

// Decode as UTF-8.
let contents = &contents[index..];
let contents = std::str::from_utf8(contents).ok()?;

let mut lines = contents.lines();

// Ensure that the first line is exactly `# /// script`.
if !lines.next().is_some_and(|line| line == "# /// script") {
return None;
}

// > Every line between these two lines (# /// TYPE and # ///) MUST be a comment starting
// > with #. If there are characters after the # then the first character MUST be a space. The
// > embedded content is formed by taking away the first two characters of each line if the
// > second character is a space, otherwise just the first character (which means the line
// > consists of only a single #).
let mut toml = vec![];

// Extract the content that follows the metadata block.
let mut python_script = vec![];

while let Some(line) = lines.next() {
// Remove the leading `#`.
let Some(line) = line.strip_prefix('#') else {
python_script.push(line);
python_script.extend(lines);
break;
};

// If the line is empty, continue.
if line.is_empty() {
toml.push("");
continue;
}

// Otherwise, the line _must_ start with ` `.
let Some(line) = line.strip_prefix(' ') else {
python_script.push(line);
python_script.extend(lines);
break;
};

toml.push(line);
}

// Find the closing `# ///`. The precedence is such that we need to identify the _last_ such
// line.
//
// For example, given:
// ```python
// # /// script
// #
// # ///
// #
// # ///
// ```
//
// The latter `///` is the closing pragma
let index = toml.iter().rev().position(|line| *line == "///")?;
let index = toml.len() - index;

// Discard any lines after the closing `# ///`.
//
// For example, given:
// ```python
// # /// script
// #
// # ///
// #
// #
// ```
//
// We need to discard the last two lines.
toml.truncate(index - 1);

// Join the lines into a single string.
let prelude = prelude.to_string();
let metadata = toml.join("\n") + "\n";
let postlude = python_script.join("\n") + "\n";

Some(Self {
prelude,
metadata,
postlude,
})
}
}
Loading