Skip to main content

uv_scripts/
lib.rs

1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::LazyLock;
7
8use memchr::memmem::Finder;
9use serde::Deserialize;
10use thiserror::Error;
11use tracing::instrument;
12use url::Url;
13
14use uv_configuration::NoSources;
15use uv_normalize::PackageName;
16use uv_pep440::VersionSpecifiers;
17use uv_pypi_types::VerbatimParsedUrl;
18use uv_redacted::DisplaySafeUrl;
19use uv_settings::{GlobalOptions, ResolverInstallerSchema};
20use uv_warnings::warn_user;
21use uv_workspace::pyproject::{ExtraBuildDependency, Sources};
22
23pub use uv_configuration::ExcludeDependency;
24pub use uv_workspace::pyproject::OverrideDependency;
25
26static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"));
27
28/// A PEP 723 item, either read from a script on disk or provided via `stdin`.
29#[derive(Debug)]
30pub enum Pep723Item {
31    /// A PEP 723 script read from disk.
32    Script(Pep723Script),
33    /// A PEP 723 script provided via `stdin`.
34    Stdin(Pep723Metadata),
35    /// A PEP 723 script provided via a remote URL.
36    Remote(Pep723Metadata, DisplaySafeUrl),
37}
38
39impl Pep723Item {
40    /// Return the [`Pep723Metadata`] associated with the item.
41    pub fn metadata(&self) -> &Pep723Metadata {
42        match self {
43            Self::Script(script) => &script.metadata,
44            Self::Stdin(metadata) => metadata,
45            Self::Remote(metadata, ..) => metadata,
46        }
47    }
48
49    /// Return the PEP 723 script, if any.
50    pub fn as_script(&self) -> Option<&Pep723Script> {
51        match self {
52            Self::Script(script) => Some(script),
53            _ => None,
54        }
55    }
56}
57
58/// A reference to a PEP 723 item.
59#[derive(Debug, Copy, Clone)]
60pub enum Pep723ItemRef<'item> {
61    /// A PEP 723 script read from disk.
62    Script(&'item Pep723Script),
63    /// A PEP 723 script provided via `stdin`.
64    Stdin(&'item Pep723Metadata),
65    /// A PEP 723 script provided via a remote URL.
66    Remote(&'item Pep723Metadata, &'item Url),
67}
68
69impl Pep723ItemRef<'_> {
70    /// Return the [`Pep723Metadata`] associated with the item.
71    pub fn metadata(&self) -> &Pep723Metadata {
72        match self {
73            Self::Script(script) => &script.metadata,
74            Self::Stdin(metadata) => metadata,
75            Self::Remote(metadata, ..) => metadata,
76        }
77    }
78
79    /// Return the path of the PEP 723 item, if any.
80    pub fn path(&self) -> Option<&Path> {
81        match self {
82            Self::Script(script) => Some(&script.path),
83            Self::Stdin(..) => None,
84            Self::Remote(..) => None,
85        }
86    }
87
88    /// Determine the working directory for the script.
89    pub fn directory(&self) -> Result<PathBuf, io::Error> {
90        match self {
91            Self::Script(script) => Ok(std::path::absolute(&script.path)?
92                .parent()
93                .expect("script path has no parent")
94                .to_owned()),
95            Self::Stdin(..) | Self::Remote(..) => std::env::current_dir(),
96        }
97    }
98
99    /// Collect any `tool.uv.index` from the script.
100    pub fn indexes(&self, source_strategy: &NoSources) -> &[uv_distribution_types::Index] {
101        match source_strategy {
102            NoSources::None | NoSources::Packages(_) => self
103                .metadata()
104                .tool
105                .as_ref()
106                .and_then(|tool| tool.uv.as_ref())
107                .and_then(|uv| uv.top_level.index.as_deref())
108                .unwrap_or(&[]),
109            NoSources::All => &[],
110        }
111    }
112
113    /// Collect any `tool.uv.sources` from the script.
114    pub fn sources(&self, source_strategy: &NoSources) -> Cow<'_, BTreeMap<PackageName, Sources>> {
115        static EMPTY: BTreeMap<PackageName, Sources> = BTreeMap::new();
116        let sources = self
117            .metadata()
118            .tool
119            .as_ref()
120            .and_then(|tool| tool.uv.as_ref())
121            .and_then(|uv| uv.sources.as_ref())
122            .unwrap_or(&EMPTY);
123
124        match source_strategy {
125            NoSources::None => Cow::Borrowed(sources),
126            NoSources::All => Cow::Borrowed(&EMPTY),
127            NoSources::Packages(packages) => Cow::Owned(
128                sources
129                    .iter()
130                    .filter(|(name, _)| !packages.contains(name))
131                    .map(|(name, sources)| (name.clone(), sources.clone()))
132                    .collect(),
133            ),
134        }
135    }
136}
137
138impl<'item> From<&'item Pep723Item> for Pep723ItemRef<'item> {
139    fn from(item: &'item Pep723Item) -> Self {
140        match item {
141            Pep723Item::Script(script) => Self::Script(script),
142            Pep723Item::Stdin(metadata) => Self::Stdin(metadata),
143            Pep723Item::Remote(metadata, url) => Self::Remote(metadata, url),
144        }
145    }
146}
147
148impl<'item> From<&'item Pep723Script> for Pep723ItemRef<'item> {
149    fn from(script: &'item Pep723Script) -> Self {
150        Self::Script(script)
151    }
152}
153
154/// A PEP 723 script, including its [`Pep723Metadata`].
155#[derive(Debug, Clone)]
156pub struct Pep723Script {
157    /// The path to the Python script.
158    pub path: PathBuf,
159    /// The parsed [`Pep723Metadata`] table from the script.
160    pub metadata: Pep723Metadata,
161    /// The content of the script before the metadata table.
162    pub prelude: String,
163    /// The content of the script after the metadata table.
164    pub postlude: String,
165}
166
167impl Pep723Script {
168    /// Read the PEP 723 `script` metadata from a Python file, if it exists.
169    ///
170    /// Returns `None` if the file is missing a PEP 723 metadata block.
171    ///
172    /// See: <https://peps.python.org/pep-0723/>
173    pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
174        let contents = fs_err::tokio::read(&file).await?;
175
176        // Extract the `script` tag.
177        let ScriptTag {
178            prelude,
179            metadata,
180            postlude,
181        } = match ScriptTag::parse(&contents) {
182            Ok(Some(tag)) => tag,
183            Ok(None) => return Ok(None),
184            Err(err) => return Err(err),
185        };
186
187        // Parse the metadata.
188        let metadata = Pep723Metadata::from_str(&metadata)?;
189
190        Ok(Some(Self {
191            path: std::path::absolute(file)?,
192            metadata,
193            prelude,
194            postlude,
195        }))
196    }
197
198    /// Reads a Python script and generates a default PEP 723 metadata table.
199    ///
200    /// See: <https://peps.python.org/pep-0723/>
201    pub async fn init(
202        file: impl AsRef<Path>,
203        requires_python: &VersionSpecifiers,
204    ) -> Result<Self, Pep723Error> {
205        let contents = fs_err::tokio::read(&file).await?;
206        let (prelude, metadata, postlude) = Self::init_metadata(&contents, requires_python)?;
207        Ok(Self {
208            path: std::path::absolute(file)?,
209            metadata,
210            prelude,
211            postlude,
212        })
213    }
214
215    /// Generates a default PEP 723 metadata table from the provided script contents.
216    ///
217    /// See: <https://peps.python.org/pep-0723/>
218    fn init_metadata(
219        contents: &[u8],
220        requires_python: &VersionSpecifiers,
221    ) -> Result<(String, Pep723Metadata, String), Pep723Error> {
222        // Define the default metadata.
223        let default_metadata = if requires_python.is_empty() {
224            indoc::formatdoc! {r"
225                dependencies = []
226            ",
227            }
228        } else {
229            indoc::formatdoc! {r#"
230                requires-python = "{requires_python}"
231                dependencies = []
232                "#,
233                requires_python = requires_python,
234            }
235        };
236        let metadata = Pep723Metadata::from_str(&default_metadata)?;
237
238        // Extract the shebang and script content.
239        let (shebang, postlude) = extract_shebang(contents)?;
240
241        // Add a newline to the beginning if it starts with a valid metadata comment line.
242        let postlude = if postlude.strip_prefix('#').is_some_and(|postlude| {
243            postlude
244                .chars()
245                .next()
246                .is_some_and(|c| matches!(c, ' ' | '\r' | '\n'))
247        }) {
248            format!("\n{postlude}")
249        } else {
250            postlude
251        };
252
253        Ok((
254            if shebang.is_empty() {
255                String::new()
256            } else {
257                format!("{shebang}\n")
258            },
259            metadata,
260            postlude,
261        ))
262    }
263
264    /// Create a PEP 723 script at the given path.
265    pub async fn create(
266        file: impl AsRef<Path>,
267        requires_python: &VersionSpecifiers,
268        existing_contents: Option<Vec<u8>>,
269        bare: bool,
270    ) -> Result<(), Pep723Error> {
271        let file = file.as_ref();
272
273        let script_name = file
274            .file_name()
275            .and_then(|name| name.to_str())
276            .ok_or_else(|| Pep723Error::InvalidFilename(file.to_string_lossy().to_string()))?;
277
278        let default_metadata = indoc::formatdoc! {r#"
279            requires-python = "{requires_python}"
280            dependencies = []
281            "#,
282        };
283        let metadata = serialize_metadata(&default_metadata);
284
285        let script = if let Some(existing_contents) = existing_contents {
286            let (mut shebang, contents) = extract_shebang(&existing_contents)?;
287            if !shebang.is_empty() {
288                shebang.push_str("\n#\n");
289                // If the shebang doesn't contain `uv`, it's probably something like
290                // `#! /usr/bin/env python`, which isn't going to respect the inline metadata.
291                // Issue a warning for users who might not know that.
292                // TODO: There are a lot of mistakes we could consider detecting here, like
293                // `uv run` without `--script` when the file doesn't end in `.py`.
294                if !regex::Regex::new(r"\buv\b").unwrap().is_match(&shebang) {
295                    warn_user!(
296                        "If you execute {} directly, it might ignore its inline metadata.\nConsider replacing its shebang with: {}",
297                        file.to_string_lossy().cyan(),
298                        "#!/usr/bin/env -S uv run --script".cyan(),
299                    );
300                }
301            }
302            indoc::formatdoc! {r"
303            {shebang}{metadata}
304            {contents}" }
305        } else if bare {
306            metadata
307        } else {
308            indoc::formatdoc! {r#"
309            {metadata}
310
311            def main() -> None:
312                print("Hello from {name}!")
313
314
315            if __name__ == "__main__":
316                main()
317        "#,
318                metadata = metadata,
319                name = script_name,
320            }
321        };
322
323        Ok(fs_err::tokio::write(file, script).await?)
324    }
325
326    /// Replace the existing metadata in the file with new metadata and write the updated content.
327    pub fn write(&self, metadata: &str) -> Result<(), io::Error> {
328        let content = format!(
329            "{}{}{}",
330            self.prelude,
331            serialize_metadata(metadata),
332            self.postlude
333        );
334
335        fs_err::write(&self.path, content)?;
336
337        Ok(())
338    }
339
340    /// Return the [`Sources`] defined in the PEP 723 metadata.
341    pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
342        static EMPTY: BTreeMap<PackageName, Sources> = BTreeMap::new();
343
344        self.metadata
345            .tool
346            .as_ref()
347            .and_then(|tool| tool.uv.as_ref())
348            .and_then(|uv| uv.sources.as_ref())
349            .unwrap_or(&EMPTY)
350    }
351}
352
353/// PEP 723 metadata as parsed from a `script` comment block.
354///
355/// See: <https://peps.python.org/pep-0723/>
356#[derive(Debug, Deserialize, Clone)]
357#[serde(rename_all = "kebab-case")]
358pub struct Pep723Metadata {
359    pub dependencies: Option<Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
360    pub requires_python: Option<VersionSpecifiers>,
361    pub tool: Option<Tool>,
362    /// The raw unserialized document.
363    #[serde(skip)]
364    pub raw: String,
365}
366
367impl Pep723Metadata {
368    /// Parse the PEP 723 metadata from `stdin`.
369    pub fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
370        // Extract the `script` tag.
371        let ScriptTag { metadata, .. } = match ScriptTag::parse(contents) {
372            Ok(Some(tag)) => tag,
373            Ok(None) => return Ok(None),
374            Err(err) => return Err(err),
375        };
376
377        // Parse the metadata.
378        Ok(Some(Self::from_str(&metadata)?))
379    }
380
381    /// Read the PEP 723 `script` metadata from a Python file, if it exists.
382    ///
383    /// Returns `None` if the file is missing a PEP 723 metadata block.
384    ///
385    /// See: <https://peps.python.org/pep-0723/>
386    pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
387        let contents = fs_err::tokio::read(&file).await?;
388
389        // Extract the `script` tag.
390        let ScriptTag { metadata, .. } = match ScriptTag::parse(&contents) {
391            Ok(Some(tag)) => tag,
392            Ok(None) => return Ok(None),
393            Err(err) => return Err(err),
394        };
395
396        // Parse the metadata.
397        Ok(Some(Self::from_str(&metadata)?))
398    }
399}
400
401impl FromStr for Pep723Metadata {
402    type Err = toml::de::Error;
403
404    /// Parse `Pep723Metadata` from a raw TOML string.
405    #[instrument(name = "toml::from_str PEP 723 metadata", skip_all)]
406    fn from_str(raw: &str) -> Result<Self, Self::Err> {
407        let metadata = toml::from_str(raw)?;
408        Ok(Self {
409            raw: raw.to_string(),
410            ..metadata
411        })
412    }
413}
414
415#[derive(Deserialize, Debug, Clone)]
416#[serde(rename_all = "kebab-case")]
417pub struct Tool {
418    pub uv: Option<ToolUv>,
419}
420
421#[derive(Debug, Deserialize, Clone)]
422#[serde(deny_unknown_fields, rename_all = "kebab-case")]
423pub struct ToolUv {
424    #[serde(flatten)]
425    pub globals: GlobalOptions,
426    #[serde(flatten)]
427    pub top_level: ResolverInstallerSchema,
428    pub override_dependencies: Option<Vec<OverrideDependency>>,
429    pub exclude_dependencies: Option<Vec<ExcludeDependency>>,
430    pub constraint_dependencies: Option<Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
431    pub build_constraint_dependencies: Option<Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
432    pub extra_build_dependencies: Option<BTreeMap<PackageName, Vec<ExtraBuildDependency>>>,
433    pub sources: Option<BTreeMap<PackageName, Sources>>,
434}
435
436#[derive(Debug, Error)]
437pub enum Pep723Error {
438    #[error(
439        "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 `#`."
440    )]
441    UnclosedBlock,
442    #[error("The script contains multiple PEP 723 metadata blocks")]
443    DuplicateBlock,
444    #[error("The PEP 723 metadata block is missing from the script.")]
445    MissingTag,
446    #[error(transparent)]
447    Io(#[from] io::Error),
448    #[error(transparent)]
449    Utf8(#[from] std::str::Utf8Error),
450    #[error(transparent)]
451    Toml(#[from] toml::de::Error),
452    #[error("Invalid filename `{0}` supplied")]
453    InvalidFilename(String),
454}
455
456#[derive(Debug, Clone, Eq, PartialEq)]
457pub struct ScriptTag {
458    /// The content of the script before the metadata block.
459    prelude: String,
460    /// The metadata block.
461    metadata: String,
462    /// The content of the script after the metadata block.
463    postlude: String,
464}
465
466impl ScriptTag {
467    /// Given the contents of a Python file, extract the `script` metadata block with leading
468    /// comment hashes removed, any preceding shebang or content (prelude), and the remaining Python
469    /// script.
470    ///
471    /// Given the following input string representing the contents of a Python script:
472    ///
473    /// ```python
474    /// #!/usr/bin/env python3
475    /// # /// script
476    /// # requires-python = '>=3.11'
477    /// # dependencies = [
478    /// #   'requests<3',
479    /// #   'rich',
480    /// # ]
481    /// # ///
482    ///
483    /// import requests
484    ///
485    /// print("Hello, World!")
486    /// ```
487    ///
488    /// This function would return:
489    ///
490    /// - Preamble: `#!/usr/bin/env python3\n`
491    /// - Metadata: `requires-python = '>=3.11'\ndependencies = [\n  'requests<3',\n  'rich',\n]`
492    /// - Postlude: `import requests\n\nprint("Hello, World!")\n`
493    ///
494    /// See: <https://peps.python.org/pep-0723/>
495    pub fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
496        // Identify the opening pragma.
497        let Some(index) = FINDER.find(contents) else {
498            return Ok(None);
499        };
500
501        // The opening pragma must be the first line, or immediately preceded by a newline.
502        if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) {
503            return Ok(None);
504        }
505
506        // Extract the preceding content.
507        let prelude = std::str::from_utf8(&contents[..index])?;
508
509        // Decode as UTF-8.
510        let contents = &contents[index..];
511        let contents = std::str::from_utf8(contents)?;
512
513        let mut lines = contents.lines();
514
515        // Ensure that the first line is exactly `# /// script`.
516        if lines.next().is_none_or(|line| line != "# /// script") {
517            return Ok(None);
518        }
519
520        // > Every line between these two lines (# /// TYPE and # ///) MUST be a comment starting
521        // > with #. If there are characters after the # then the first character MUST be a space. The
522        // > embedded content is formed by taking away the first two characters of each line if the
523        // > second character is a space, otherwise just the first character (which means the line
524        // > consists of only a single #).
525        let mut toml = vec![];
526
527        for line in lines {
528            // Remove the leading `#`.
529            let Some(line) = line.strip_prefix('#') else {
530                break;
531            };
532
533            // If the line is empty, continue.
534            if line.is_empty() {
535                toml.push("");
536                continue;
537            }
538
539            // Otherwise, the line _must_ start with ` `.
540            let Some(line) = line.strip_prefix(' ') else {
541                break;
542            };
543
544            toml.push(line);
545        }
546
547        // Find the closing `# ///`. The precedence is such that we need to identify the _last_ such
548        // line.
549        //
550        // For example, given:
551        // ```python
552        // # /// script
553        // #
554        // # ///
555        // #
556        // # ///
557        // ```
558        //
559        // The latter `///` is the closing pragma
560        let Some(index) = toml.iter().rev().position(|line| *line == "///") else {
561            return Err(Pep723Error::UnclosedBlock);
562        };
563        let index = toml.len() - index;
564
565        // Discard any lines after the closing `# ///`.
566        //
567        // For example, given:
568        // ```python
569        // # /// script
570        // #
571        // # ///
572        // #
573        // #
574        // ```
575        //
576        // We need to discard the last two lines.
577        toml.truncate(index - 1);
578
579        // Extract the remaining content.
580        let postlude = contents.lines().skip(index + 1).collect::<Vec<_>>();
581
582        // Ensure that the remaining content doesn't include another complete `script` block.
583        // A `# /// script` line can be embedded content inside another typed block.
584        let mut lines = postlude.iter().peekable();
585        while let Some(line) = lines.next() {
586            // Capture the metadata.
587            let Some(metadata_type) = line.strip_prefix("# /// ") else {
588                continue;
589            };
590
591            // Parse the metadata type per spec
592            if metadata_type.is_empty()
593                || !metadata_type
594                    .bytes()
595                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
596            {
597                continue;
598            }
599
600            let is_script_block = metadata_type == "script";
601            let mut is_closed = false;
602            while let Some(line) = lines.next() {
603                // Per e.g. # dependencies = []
604                let Some(content) = line.strip_prefix('#') else {
605                    break;
606                };
607                if !(content.is_empty() || content.starts_with(' ')) {
608                    break;
609                }
610
611                if *line == "# ///" {
612                    let Some(next_line) = lines.peek() else {
613                        is_closed = true;
614                        break;
615                    };
616
617                    let Some(next_content) = next_line.strip_prefix('#') else {
618                        is_closed = true;
619                        break;
620                    };
621
622                    if !(next_content.is_empty() || next_content.starts_with(' ')) {
623                        is_closed = true;
624                        break;
625                    }
626                }
627            }
628
629            if is_script_block && is_closed {
630                return Err(Pep723Error::DuplicateBlock);
631            }
632        }
633
634        // Join the lines into a single string.
635        let prelude = prelude.to_string();
636        let metadata = toml.join("\n") + "\n";
637        let postlude = postlude.join("\n") + "\n";
638
639        Ok(Some(Self {
640            prelude,
641            metadata,
642            postlude,
643        }))
644    }
645}
646
647/// Extracts the shebang line from the given file contents and returns it along with the remaining
648/// content.
649fn extract_shebang(contents: &[u8]) -> Result<(String, String), Pep723Error> {
650    let contents = std::str::from_utf8(contents)?;
651
652    if contents.starts_with("#!") {
653        // Find the first newline.
654        let bytes = contents.as_bytes();
655        let index = bytes
656            .iter()
657            .position(|&b| b == b'\r' || b == b'\n')
658            .unwrap_or(bytes.len());
659
660        // Support `\r`, `\n`, and `\r\n` line endings.
661        let width = match bytes.get(index) {
662            Some(b'\r') => {
663                if bytes.get(index + 1) == Some(&b'\n') {
664                    2
665                } else {
666                    1
667                }
668            }
669            Some(b'\n') => 1,
670            _ => 0,
671        };
672
673        // Extract the shebang line.
674        let shebang = contents[..index].to_string();
675        let script = contents[index + width..].to_string();
676
677        Ok((shebang, script))
678    } else {
679        Ok((String::new(), contents.to_string()))
680    }
681}
682
683/// Formats the provided metadata by prefixing each line with `#` and wrapping it with script markers.
684fn serialize_metadata(metadata: &str) -> String {
685    let mut output = String::with_capacity(metadata.len() + 32);
686
687    output.push_str("# /// script");
688    output.push('\n');
689
690    for line in metadata.lines() {
691        output.push('#');
692        if !line.is_empty() {
693            output.push(' ');
694            output.push_str(line);
695        }
696        output.push('\n');
697    }
698
699    output.push_str("# ///");
700    output.push('\n');
701
702    output
703}
704
705#[cfg(test)]
706mod tests {
707    use crate::{Pep723Error, Pep723Script, ScriptTag, serialize_metadata};
708    use std::str::FromStr;
709
710    #[test]
711    fn missing_space() {
712        let contents = indoc::indoc! {r"
713        # /// script
714        #requires-python = '>=3.11'
715        # ///
716    "};
717
718        assert!(matches!(
719            ScriptTag::parse(contents.as_bytes()),
720            Err(Pep723Error::UnclosedBlock)
721        ));
722    }
723
724    #[test]
725    fn no_closing_pragma() {
726        let contents = indoc::indoc! {r"
727        # /// script
728        # requires-python = '>=3.11'
729        # dependencies = [
730        #     'requests<3',
731        #     'rich',
732        # ]
733    "};
734
735        assert!(matches!(
736            ScriptTag::parse(contents.as_bytes()),
737            Err(Pep723Error::UnclosedBlock)
738        ));
739    }
740
741    #[test]
742    fn leading_content() {
743        let contents = indoc::indoc! {r"
744        pass # /// script
745        # requires-python = '>=3.11'
746        # dependencies = [
747        #   'requests<3',
748        #   'rich',
749        # ]
750        # ///
751        #
752        #
753    "};
754
755        assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None);
756    }
757
758    #[test]
759    fn simple() {
760        let contents = indoc::indoc! {r"
761        # /// script
762        # requires-python = '>=3.11'
763        # dependencies = [
764        #     'requests<3',
765        #     'rich',
766        # ]
767        # ///
768
769        import requests
770        from rich.pretty import pprint
771
772        resp = requests.get('https://peps.python.org/api/peps.json')
773        data = resp.json()
774    "};
775
776        let expected_metadata = indoc::indoc! {r"
777        requires-python = '>=3.11'
778        dependencies = [
779            'requests<3',
780            'rich',
781        ]
782    "};
783
784        let expected_data = indoc::indoc! {r"
785
786        import requests
787        from rich.pretty import pprint
788
789        resp = requests.get('https://peps.python.org/api/peps.json')
790        data = resp.json()
791    "};
792
793        let actual = ScriptTag::parse(contents.as_bytes()).unwrap().unwrap();
794
795        assert_eq!(actual.prelude, String::new());
796        assert_eq!(actual.metadata, expected_metadata);
797        assert_eq!(actual.postlude, expected_data);
798    }
799
800    #[test]
801    fn simple_with_shebang() {
802        let contents = indoc::indoc! {r"
803        #!/usr/bin/env python3
804        # /// script
805        # requires-python = '>=3.11'
806        # dependencies = [
807        #     'requests<3',
808        #     'rich',
809        # ]
810        # ///
811
812        import requests
813        from rich.pretty import pprint
814
815        resp = requests.get('https://peps.python.org/api/peps.json')
816        data = resp.json()
817    "};
818
819        let expected_metadata = indoc::indoc! {r"
820        requires-python = '>=3.11'
821        dependencies = [
822            'requests<3',
823            'rich',
824        ]
825    "};
826
827        let expected_data = indoc::indoc! {r"
828
829        import requests
830        from rich.pretty import pprint
831
832        resp = requests.get('https://peps.python.org/api/peps.json')
833        data = resp.json()
834    "};
835
836        let actual = ScriptTag::parse(contents.as_bytes()).unwrap().unwrap();
837
838        assert_eq!(actual.prelude, "#!/usr/bin/env python3\n".to_string());
839        assert_eq!(actual.metadata, expected_metadata);
840        assert_eq!(actual.postlude, expected_data);
841    }
842
843    #[test]
844    fn embedded_comment() {
845        let contents = indoc::indoc! {r"
846        # /// script
847        # embedded-csharp = '''
848        # /// <summary>
849        # /// text
850        # ///
851        # /// </summary>
852        # public class MyClass { }
853        # '''
854        # ///
855    "};
856
857        let expected = indoc::indoc! {r"
858        embedded-csharp = '''
859        /// <summary>
860        /// text
861        ///
862        /// </summary>
863        public class MyClass { }
864        '''
865    "};
866
867        let actual = ScriptTag::parse(contents.as_bytes())
868            .unwrap()
869            .unwrap()
870            .metadata;
871
872        assert_eq!(actual, expected);
873    }
874
875    #[test]
876    fn trailing_lines() {
877        let contents = indoc::indoc! {r"
878            # /// script
879            # requires-python = '>=3.11'
880            # dependencies = [
881            #     'requests<3',
882            #     'rich',
883            # ]
884            # ///
885            #
886            #
887        "};
888
889        let expected = indoc::indoc! {r"
890            requires-python = '>=3.11'
891            dependencies = [
892                'requests<3',
893                'rich',
894            ]
895        "};
896
897        let actual = ScriptTag::parse(contents.as_bytes())
898            .unwrap()
899            .unwrap()
900            .metadata;
901
902        assert_eq!(actual, expected);
903    }
904
905    #[test]
906    fn unclosed_second_script_block_is_not_duplicate() {
907        let contents = indoc::indoc! {r#"
908            # /// script
909            # dependencies = ["requests"]
910            # ///
911
912            print("Hello, world!")
913
914            # /// script
915        "#};
916
917        assert!(ScriptTag::parse(contents.as_bytes()).is_ok());
918    }
919
920    #[test]
921    fn other_script_block_is_ignored() {
922        let contents = indoc::indoc! {r#"
923            # /// script
924            # dependencies = ["requests"]
925            # ///
926
927            
928            # /// other
929            # /// script
930            # ///
931
932            print("Hello, world!")
933        "#};
934
935        assert!(ScriptTag::parse(contents.as_bytes()).is_ok());
936    }
937
938    #[test]
939    fn serialize_metadata_formatting() {
940        let metadata = indoc::indoc! {r"
941            requires-python = '>=3.11'
942            dependencies = [
943              'requests<3',
944              'rich',
945            ]
946        "};
947
948        let expected_output = indoc::indoc! {r"
949            # /// script
950            # requires-python = '>=3.11'
951            # dependencies = [
952            #   'requests<3',
953            #   'rich',
954            # ]
955            # ///
956        "};
957
958        let result = serialize_metadata(metadata);
959        assert_eq!(result, expected_output);
960    }
961
962    #[test]
963    fn serialize_metadata_empty() {
964        let metadata = "";
965        let expected_output = "# /// script\n# ///\n";
966
967        let result = serialize_metadata(metadata);
968        assert_eq!(result, expected_output);
969    }
970
971    #[test]
972    fn script_init_empty() {
973        let contents = "".as_bytes();
974        let (prelude, metadata, postlude) =
975            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
976                .unwrap();
977        assert_eq!(prelude, "");
978        assert_eq!(
979            metadata.raw,
980            indoc::indoc! {r"
981            dependencies = []
982            "}
983        );
984        assert_eq!(postlude, "");
985    }
986
987    #[test]
988    fn script_init_requires_python() {
989        let contents = "".as_bytes();
990        let (prelude, metadata, postlude) = Pep723Script::init_metadata(
991            contents,
992            &uv_pep440::VersionSpecifiers::from_str(">=3.8").unwrap(),
993        )
994        .unwrap();
995        assert_eq!(prelude, "");
996        assert_eq!(
997            metadata.raw,
998            indoc::indoc! {r#"
999            requires-python = ">=3.8"
1000            dependencies = []
1001            "#}
1002        );
1003        assert_eq!(postlude, "");
1004    }
1005
1006    #[test]
1007    fn script_init_with_hashbang() {
1008        let contents = indoc::indoc! {r#"
1009        #!/usr/bin/env python3
1010
1011        print("Hello, world!")
1012        "#}
1013        .as_bytes();
1014        let (prelude, metadata, postlude) =
1015            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1016                .unwrap();
1017        assert_eq!(prelude, "#!/usr/bin/env python3\n");
1018        assert_eq!(
1019            metadata.raw,
1020            indoc::indoc! {r"
1021            dependencies = []
1022            "}
1023        );
1024        assert_eq!(
1025            postlude,
1026            indoc::indoc! {r#"
1027
1028            print("Hello, world!")
1029            "#}
1030        );
1031    }
1032
1033    #[test]
1034    fn script_init_with_other_metadata() {
1035        let contents = indoc::indoc! {r#"
1036        # /// noscript
1037        # Hello,
1038        #
1039        # World!
1040        # ///
1041
1042        print("Hello, world!")
1043        "#}
1044        .as_bytes();
1045        let (prelude, metadata, postlude) =
1046            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1047                .unwrap();
1048        assert_eq!(prelude, "");
1049        assert_eq!(
1050            metadata.raw,
1051            indoc::indoc! {r"
1052            dependencies = []
1053            "}
1054        );
1055        // Note the extra line at the beginning.
1056        assert_eq!(
1057            postlude,
1058            indoc::indoc! {r#"
1059
1060            # /// noscript
1061            # Hello,
1062            #
1063            # World!
1064            # ///
1065
1066            print("Hello, world!")
1067            "#}
1068        );
1069    }
1070
1071    #[test]
1072    fn script_init_with_hashbang_and_other_metadata() {
1073        let contents = indoc::indoc! {r#"
1074        #!/usr/bin/env python3
1075        # /// noscript
1076        # Hello,
1077        #
1078        # World!
1079        # ///
1080
1081        print("Hello, world!")
1082        "#}
1083        .as_bytes();
1084        let (prelude, metadata, postlude) =
1085            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1086                .unwrap();
1087        assert_eq!(prelude, "#!/usr/bin/env python3\n");
1088        assert_eq!(
1089            metadata.raw,
1090            indoc::indoc! {r"
1091            dependencies = []
1092            "}
1093        );
1094        // Note the extra line at the beginning.
1095        assert_eq!(
1096            postlude,
1097            indoc::indoc! {r#"
1098
1099            # /// noscript
1100            # Hello,
1101            #
1102            # World!
1103            # ///
1104
1105            print("Hello, world!")
1106            "#}
1107        );
1108    }
1109
1110    #[test]
1111    fn script_init_with_valid_metadata_line() {
1112        let contents = indoc::indoc! {r#"
1113        # Hello,
1114        # /// noscript
1115        #
1116        # World!
1117        # ///
1118
1119        print("Hello, world!")
1120        "#}
1121        .as_bytes();
1122        let (prelude, metadata, postlude) =
1123            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1124                .unwrap();
1125        assert_eq!(prelude, "");
1126        assert_eq!(
1127            metadata.raw,
1128            indoc::indoc! {r"
1129            dependencies = []
1130            "}
1131        );
1132        // Note the extra line at the beginning.
1133        assert_eq!(
1134            postlude,
1135            indoc::indoc! {r#"
1136
1137            # Hello,
1138            # /// noscript
1139            #
1140            # World!
1141            # ///
1142
1143            print("Hello, world!")
1144            "#}
1145        );
1146    }
1147
1148    #[test]
1149    fn script_init_with_valid_empty_metadata_line() {
1150        let contents = indoc::indoc! {r#"
1151        #
1152        # /// noscript
1153        # Hello,
1154        # World!
1155        # ///
1156
1157        print("Hello, world!")
1158        "#}
1159        .as_bytes();
1160        let (prelude, metadata, postlude) =
1161            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1162                .unwrap();
1163        assert_eq!(prelude, "");
1164        assert_eq!(
1165            metadata.raw,
1166            indoc::indoc! {r"
1167            dependencies = []
1168            "}
1169        );
1170        // Note the extra line at the beginning.
1171        assert_eq!(
1172            postlude,
1173            indoc::indoc! {r#"
1174
1175            #
1176            # /// noscript
1177            # Hello,
1178            # World!
1179            # ///
1180
1181            print("Hello, world!")
1182            "#}
1183        );
1184    }
1185
1186    #[test]
1187    fn script_init_with_non_metadata_comment() {
1188        let contents = indoc::indoc! {r#"
1189        #Hello,
1190        # /// noscript
1191        #
1192        # World!
1193        # ///
1194
1195        print("Hello, world!")
1196        "#}
1197        .as_bytes();
1198        let (prelude, metadata, postlude) =
1199            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1200                .unwrap();
1201        assert_eq!(prelude, "");
1202        assert_eq!(
1203            metadata.raw,
1204            indoc::indoc! {r"
1205            dependencies = []
1206            "}
1207        );
1208        assert_eq!(
1209            postlude,
1210            indoc::indoc! {r#"
1211            #Hello,
1212            # /// noscript
1213            #
1214            # World!
1215            # ///
1216
1217            print("Hello, world!")
1218            "#}
1219        );
1220    }
1221}