Skip to main content

uv_scripts/
lib.rs

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