2024-08-09 23:11:10 -04:00
use std ::collections ::BTreeMap ;
2024-07-26 21:49:47 -04:00
use std ::io ;
2024-08-09 23:11:10 -04:00
use std ::path ::{ Path , PathBuf } ;
2024-08-11 03:40:59 +02:00
use std ::str ::FromStr ;
2024-07-26 21:49:47 -04:00
use std ::sync ::LazyLock ;
2024-08-09 23:11:10 -04:00
use memchr ::memmem ::Finder ;
use serde ::Deserialize ;
2024-07-01 08:20:24 -04:00
use thiserror ::Error ;
2024-08-27 13:47:11 -04:00
use pep440_rs ::VersionSpecifiers ;
2024-08-09 23:11:10 -04:00
use pep508_rs ::PackageName ;
use pypi_types ::VerbatimParsedUrl ;
use uv_settings ::{ GlobalOptions , ResolverInstallerOptions } ;
use uv_workspace ::pyproject ::Source ;
2024-07-26 21:49:47 -04:00
static FINDER : LazyLock < Finder > = LazyLock ::new ( | | Finder ::new ( b " # /// script " ) ) ;
2024-07-01 08:20:24 -04:00
2024-08-09 23:11:10 -04:00
/// A PEP 723 script, including its [`Pep723Metadata`].
#[ derive(Debug) ]
pub struct Pep723Script {
2024-08-11 03:40:59 +02:00
/// The path to the Python script.
2024-08-09 23:11:10 -04:00
pub path : PathBuf ,
2024-08-11 03:40:59 +02:00
/// The parsed [`Pep723Metadata`] table from the script.
2024-08-09 23:11:10 -04:00
pub metadata : Pep723Metadata ,
2024-08-11 03:40:59 +02:00
/// The content of the script before the metadata table.
pub prelude : String ,
2024-08-10 22:07:05 -04:00
/// The content of the script after the metadata table.
pub postlude : String ,
2024-08-09 23:11:10 -04:00
}
impl Pep723Script {
/// Read the PEP 723 `script` metadata from a Python file, if it exists.
///
/// See: <https://peps.python.org/pep-0723/>
pub async fn read ( file : impl AsRef < Path > ) -> Result < Option < Self > , Pep723Error > {
2024-08-11 03:40:59 +02:00
let contents = match fs_err ::tokio ::read ( & file ) . await {
Ok ( contents ) = > contents ,
Err ( err ) if err . kind ( ) = = io ::ErrorKind ::NotFound = > return Ok ( None ) ,
Err ( err ) = > return Err ( err . into ( ) ) ,
} ;
// Extract the `script` tag.
2024-08-27 13:47:11 -04:00
let ScriptTag {
2024-08-10 22:07:05 -04:00
prelude ,
metadata ,
postlude ,
2024-08-27 13:47:11 -04:00
} = match ScriptTag ::parse ( & contents ) {
Ok ( Some ( tag ) ) = > tag ,
Ok ( None ) = > return Ok ( None ) ,
Err ( err ) = > return Err ( err ) ,
2024-08-11 03:40:59 +02:00
} ;
// Parse the metadata.
2024-08-10 22:07:05 -04:00
let metadata = Pep723Metadata ::from_str ( & metadata ) ? ;
2024-08-11 03:40:59 +02:00
Ok ( Some ( Self {
2024-08-09 23:11:10 -04:00
path : file . as_ref ( ) . to_path_buf ( ) ,
metadata ,
2024-08-10 22:07:05 -04:00
prelude ,
postlude ,
2024-08-09 23:11:10 -04:00
} ) )
}
2024-08-11 03:40:59 +02:00
/// Reads a Python script and generates a default PEP 723 metadata table.
///
/// See: <https://peps.python.org/pep-0723/>
pub async fn create (
file : impl AsRef < Path > ,
requires_python : & VersionSpecifiers ,
) -> Result < Self , Pep723Error > {
2024-08-10 22:07:05 -04:00
let contents = fs_err ::tokio ::read ( & file ) . await ? ;
2024-08-11 03:40:59 +02:00
2024-08-10 22:07:05 -04:00
// Define the default metadata.
2024-08-11 03:40:59 +02:00
let default_metadata = indoc ::formatdoc! { r # "
requires-python = "{requires_python}"
dependencies = []
"# ,
requires_python = requires_python ,
} ;
let metadata = Pep723Metadata ::from_str ( & default_metadata ) ? ;
2024-08-10 22:07:05 -04:00
// Extract the shebang and script content.
2024-08-22 04:57:08 +08:00
let ( shebang , postlude ) = extract_shebang ( & contents ) ? ;
2024-08-10 22:07:05 -04:00
2024-08-11 03:40:59 +02:00
Ok ( Self {
path : file . as_ref ( ) . to_path_buf ( ) ,
2024-08-22 04:57:08 +08:00
prelude : if shebang . is_empty ( ) {
String ::new ( )
} else {
format! ( " {shebang} \n " )
} ,
2024-08-11 03:40:59 +02:00
metadata ,
2024-08-10 22:07:05 -04:00
postlude ,
2024-08-11 03:40:59 +02:00
} )
}
/// 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! (
" {} {} {} " ,
2024-08-22 04:57:08 +08:00
self . prelude ,
2024-08-11 03:40:59 +02:00
serialize_metadata ( metadata ) ,
2024-08-10 22:07:05 -04:00
self . postlude
2024-08-11 03:40:59 +02:00
) ;
Ok ( fs_err ::tokio ::write ( & self . path , content ) . await ? )
}
2024-08-09 23:11:10 -04:00
}
2024-07-01 08:20:24 -04:00
/// PEP 723 metadata as parsed from a `script` comment block.
///
/// See: <https://peps.python.org/pep-0723/>
2024-08-09 23:11:10 -04:00
#[ derive(Debug, Deserialize) ]
2024-07-01 08:20:24 -04:00
#[ serde(rename_all = " kebab-case " ) ]
pub struct Pep723Metadata {
2024-08-07 10:56:05 -04:00
pub dependencies : Option < Vec < pep508_rs ::Requirement < VerbatimParsedUrl > > > ,
2024-08-11 03:40:59 +02:00
pub requires_python : Option < VersionSpecifiers > ,
2024-08-09 23:11:10 -04:00
pub tool : Option < Tool > ,
2024-08-11 03:40:59 +02:00
/// The raw unserialized document.
#[ serde(skip) ]
pub raw : String ,
2024-08-09 23:11:10 -04:00
}
2024-08-11 03:40:59 +02:00
impl FromStr for Pep723Metadata {
type Err = Pep723Error ;
2024-08-09 23:11:10 -04:00
2024-08-11 03:40:59 +02:00
/// Parse `Pep723Metadata` from a raw TOML string.
fn from_str ( raw : & str ) -> Result < Self , Self ::Err > {
let metadata = toml ::from_str ( raw ) ? ;
2024-08-10 22:07:05 -04:00
Ok ( Self {
2024-08-11 03:40:59 +02:00
raw : raw . to_string ( ) ,
.. metadata
} )
2024-08-09 23:11:10 -04:00
}
}
#[ derive(Deserialize, Debug) ]
#[ serde(rename_all = " kebab-case " ) ]
pub struct Tool {
pub uv : Option < ToolUv > ,
}
#[ derive(Debug, Deserialize) ]
#[ serde(deny_unknown_fields) ]
pub struct ToolUv {
#[ serde(flatten) ]
pub globals : GlobalOptions ,
#[ serde(flatten) ]
pub top_level : ResolverInstallerOptions ,
pub sources : Option < BTreeMap < PackageName , Source > > ,
2024-07-01 08:20:24 -04:00
}
#[ derive(Debug, Error) ]
pub enum Pep723Error {
2024-08-27 13:47:11 -04:00
#[ error( " An opening tag (`# /// script`) was found without a closing tag (`# ///`). Ensure that every line between the opening and closing tags (including empty lines) starts with a leading `#`. " ) ]
UnclosedBlock ,
2024-07-01 08:20:24 -04:00
#[ error(transparent) ]
Io ( #[ from ] io ::Error ) ,
#[ error(transparent) ]
Utf8 ( #[ from ] std ::str ::Utf8Error ) ,
#[ error(transparent) ]
Toml ( #[ from ] toml ::de ::Error ) ,
}
2024-08-11 03:40:59 +02:00
#[ derive(Debug, Clone, Eq, PartialEq) ]
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.
2024-08-10 22:07:05 -04:00
postlude : String ,
2024-07-01 08:20:24 -04:00
}
2024-08-11 03:40:59 +02:00
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]`
2024-08-10 22:07:05 -04:00
/// - Postlude: `import requests\n\nprint("Hello, World!")\n`
2024-08-11 03:40:59 +02:00
///
/// See: <https://peps.python.org/pep-0723/>
fn parse ( contents : & [ u8 ] ) -> Result < Option < Self > , Pep723Error > {
// Identify the opening pragma.
let Some ( index ) = FINDER . find ( contents ) else {
return Ok ( None ) ;
} ;
// 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 Ok ( None ) ;
}
// Extract the preceding content.
let prelude = std ::str ::from_utf8 ( & contents [ .. index ] ) ? ;
// Decode as UTF-8.
let contents = & contents [ index .. ] ;
let contents = std ::str ::from_utf8 ( contents ) ? ;
let mut lines = contents . lines ( ) ;
// Ensure that the first line is exactly `# /// script`.
if ! lines . next ( ) . is_some_and ( | line | line = = " # /// script " ) {
return Ok ( 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 Some ( index ) = toml . iter ( ) . rev ( ) . position ( | line | * line = = " /// " ) else {
2024-08-27 13:47:11 -04:00
return Err ( Pep723Error ::UnclosedBlock ) ;
2024-08-11 03:40:59 +02:00
} ;
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 " ;
2024-08-10 22:07:05 -04:00
let postlude = python_script . join ( " \n " ) + " \n " ;
2024-08-11 03:40:59 +02:00
Ok ( Some ( Self {
prelude ,
metadata ,
2024-08-10 22:07:05 -04:00
postlude ,
2024-08-11 03:40:59 +02:00
} ) )
2024-07-01 08:20:24 -04:00
}
2024-08-11 03:40:59 +02:00
}
2024-07-01 08:20:24 -04:00
2024-08-11 03:40:59 +02:00
/// Extracts the shebang line from the given file contents and returns it along with the remaining
/// content.
2024-08-10 22:07:05 -04:00
fn extract_shebang ( contents : & [ u8 ] ) -> Result < ( String , String ) , Pep723Error > {
2024-07-01 08:20:24 -04:00
let contents = std ::str ::from_utf8 ( contents ) ? ;
2024-08-10 22:07:05 -04:00
if contents . starts_with ( " #! " ) {
// Find the first newline.
let bytes = contents . as_bytes ( ) ;
let index = bytes
. iter ( )
. position ( | & b | b = = b '\r' | | b = = b '\n' )
. unwrap_or ( bytes . len ( ) ) ;
// Support `\r`, `\n`, and `\r\n` line endings.
let width = match bytes . get ( index ) {
Some ( b '\r' ) = > {
if bytes . get ( index + 1 ) = = Some ( & b '\n' ) {
2
} else {
1
}
}
Some ( b '\n' ) = > 1 ,
_ = > 0 ,
} ;
2024-07-01 08:20:24 -04:00
2024-08-10 22:07:05 -04:00
// Extract the shebang line.
let shebang = contents [ .. index ] . to_string ( ) ;
let script = contents [ index + width .. ] . to_string ( ) ;
2024-07-01 08:20:24 -04:00
2024-08-10 22:07:05 -04:00
Ok ( ( shebang , script ) )
} else {
Ok ( ( String ::new ( ) , contents . to_string ( ) ) )
}
2024-08-11 03:40:59 +02:00
}
2024-07-01 08:20:24 -04:00
2024-08-11 03:40:59 +02:00
/// Formats the provided metadata by prefixing each line with `#` and wrapping it with script markers.
fn serialize_metadata ( metadata : & str ) -> String {
2024-08-10 22:07:05 -04:00
let mut output = String ::with_capacity ( metadata . len ( ) + 32 ) ;
2024-08-11 03:40:59 +02:00
2024-08-10 22:07:05 -04:00
output . push_str ( " # /// script " ) ;
output . push ( '\n' ) ;
2024-08-11 03:40:59 +02:00
for line in metadata . lines ( ) {
2024-07-01 08:20:24 -04:00
if line . is_empty ( ) {
2024-08-11 03:40:59 +02:00
output . push ( '\n' ) ;
} else {
output . push_str ( " # " ) ;
output . push_str ( line ) ;
output . push ( '\n' ) ;
2024-07-01 08:20:24 -04:00
}
}
2024-08-10 22:07:05 -04:00
output . push_str ( " # /// " ) ;
output . push ( '\n' ) ;
2024-08-11 03:40:59 +02:00
output
2024-07-01 08:20:24 -04:00
}
#[ cfg(test) ]
mod tests {
2024-08-27 13:47:11 -04:00
use crate ::{ serialize_metadata , Pep723Error , ScriptTag } ;
2024-08-11 03:40:59 +02:00
2024-07-01 08:20:24 -04:00
#[ test ]
fn missing_space ( ) {
let contents = indoc ::indoc! { r "
# /// script
#requires-python = '>=3.11'
# ///
" } ;
2024-08-27 13:47:11 -04:00
assert! ( matches! (
ScriptTag ::parse ( contents . as_bytes ( ) ) ,
Err ( Pep723Error ::UnclosedBlock )
) ) ;
2024-07-01 08:20:24 -04:00
}
#[ test ]
fn no_closing_pragma ( ) {
let contents = indoc ::indoc! { r "
# /// script
# requires-python = '>=3.11'
# dependencies = [
# 'requests<3',
# 'rich',
# ]
" } ;
2024-08-27 13:47:11 -04:00
assert! ( matches! (
ScriptTag ::parse ( contents . as_bytes ( ) ) ,
Err ( Pep723Error ::UnclosedBlock )
) ) ;
2024-07-01 08:20:24 -04:00
}
#[ test ]
fn leading_content ( ) {
let contents = indoc ::indoc! { r "
pass # /// script
# requires-python = '>=3.11'
# dependencies = [
# 'requests<3',
# 'rich',
# ]
# ///
#
#
" } ;
2024-08-11 03:40:59 +02:00
assert_eq! ( ScriptTag ::parse ( contents . as_bytes ( ) ) . unwrap ( ) , None ) ;
2024-07-01 08:20:24 -04:00
}
#[ test ]
fn simple ( ) {
let contents = indoc ::indoc! { r "
# /// script
# requires-python = '>=3.11'
# dependencies = [
# 'requests<3',
# 'rich',
# ]
# ///
2024-08-11 03:40:59 +02:00
import requests
from rich.pretty import pprint
resp = requests.get('https://peps.python.org/api/peps.json')
data = resp.json()
2024-07-01 08:20:24 -04:00
" } ;
2024-08-11 03:40:59 +02:00
let expected_metadata = indoc ::indoc! { r "
2024-07-01 08:20:24 -04:00
requires-python = '>=3.11'
dependencies = [
'requests<3',
'rich',
]
" } ;
2024-08-11 03:40:59 +02:00
let expected_data = indoc ::indoc! { r "
2024-07-01 08:20:24 -04:00
2024-08-11 03:40:59 +02:00
import requests
from rich.pretty import pprint
resp = requests.get('https://peps.python.org/api/peps.json')
data = resp.json()
" } ;
let actual = ScriptTag ::parse ( contents . as_bytes ( ) ) . unwrap ( ) . unwrap ( ) ;
assert_eq! ( actual . prelude , String ::new ( ) ) ;
assert_eq! ( actual . metadata , expected_metadata ) ;
2024-08-10 22:07:05 -04:00
assert_eq! ( actual . postlude , expected_data ) ;
2024-07-01 08:20:24 -04:00
}
2024-08-11 03:40:59 +02:00
#[ test ]
fn simple_with_shebang ( ) {
let contents = indoc ::indoc! { r "
#!/usr/bin/env python3
# /// 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()
" } ;
let expected_metadata = indoc ::indoc! { r "
requires-python = '>=3.11'
dependencies = [
'requests<3',
'rich',
]
" } ;
let expected_data = indoc ::indoc! { r "
import requests
from rich.pretty import pprint
resp = requests.get('https://peps.python.org/api/peps.json')
data = resp.json()
" } ;
let actual = ScriptTag ::parse ( contents . as_bytes ( ) ) . unwrap ( ) . unwrap ( ) ;
assert_eq! ( actual . prelude , " #!/usr/bin/env python3 \n " . to_string ( ) ) ;
assert_eq! ( actual . metadata , expected_metadata ) ;
2024-08-10 22:07:05 -04:00
assert_eq! ( actual . postlude , expected_data ) ;
2024-08-11 03:40:59 +02:00
}
2024-07-01 08:20:24 -04:00
#[ test ]
fn embedded_comment ( ) {
let contents = indoc ::indoc! { r "
# /// script
# embedded-csharp = '''
# /// <summary>
# /// text
# ///
# /// </summary>
# public class MyClass { }
# '''
# ///
" } ;
let expected = indoc ::indoc! { r "
embedded-csharp = '''
/// <summary>
/// text
///
/// </summary>
public class MyClass { }
'''
" } ;
2024-08-11 03:40:59 +02:00
let actual = ScriptTag ::parse ( contents . as_bytes ( ) )
. unwrap ( )
2024-07-01 08:20:24 -04:00
. unwrap ( )
2024-08-11 03:40:59 +02:00
. metadata ;
2024-07-01 08:20:24 -04:00
assert_eq! ( actual , expected ) ;
}
#[ test ]
fn trailing_lines ( ) {
let contents = indoc ::indoc! { r "
# /// script
# requires-python = '>=3.11'
# dependencies = [
# 'requests<3',
# 'rich',
# ]
# ///
#
#
" } ;
let expected = indoc ::indoc! { r "
requires-python = '>=3.11'
dependencies = [
'requests<3',
'rich',
]
" } ;
2024-08-11 03:40:59 +02:00
let actual = ScriptTag ::parse ( contents . as_bytes ( ) )
2024-07-01 08:20:24 -04:00
. unwrap ( )
2024-08-11 03:40:59 +02:00
. unwrap ( )
. metadata ;
2024-07-01 08:20:24 -04:00
assert_eq! ( actual , expected ) ;
}
2024-08-11 03:40:59 +02:00
#[ test ]
fn test_serialize_metadata_formatting ( ) {
let metadata = indoc ::indoc! { r "
requires-python = '>=3.11'
dependencies = [
'requests<3',
'rich',
]
" } ;
let expected_output = indoc ::indoc! { r "
# /// script
# requires-python = '>=3.11'
# dependencies = [
# 'requests<3',
# 'rich',
# ]
# ///
" } ;
let result = serialize_metadata ( metadata ) ;
assert_eq! ( result , expected_output ) ;
}
#[ test ]
fn test_serialize_metadata_empty ( ) {
let metadata = " " ;
let expected_output = " # /// script \n # /// \n " ;
let result = serialize_metadata ( metadata ) ;
assert_eq! ( result , expected_output ) ;
}
2024-07-01 08:20:24 -04:00
}