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::SourceStrategy;
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: SourceStrategy) -> &[uv_distribution_types::Index] {
114        match source_strategy {
115            SourceStrategy::Enabled => 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            SourceStrategy::Disabled => &[],
123        }
124    }
125
126    /// Collect any `tool.uv.sources` from the script.
127    pub fn sources(&self, source_strategy: SourceStrategy) -> &BTreeMap<PackageName, Sources> {
128        static EMPTY: BTreeMap<PackageName, Sources> = BTreeMap::new();
129        match source_strategy {
130            SourceStrategy::Enabled => 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            SourceStrategy::Disabled => &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    ) -> Result<(), Pep723Error> {
274        let file = file.as_ref();
275
276        let script_name = file
277            .file_name()
278            .and_then(|name| name.to_str())
279            .ok_or_else(|| Pep723Error::InvalidFilename(file.to_string_lossy().to_string()))?;
280
281        let default_metadata = indoc::formatdoc! {r#"
282            requires-python = "{requires_python}"
283            dependencies = []
284            "#,
285        };
286        let metadata = serialize_metadata(&default_metadata);
287
288        let script = if let Some(existing_contents) = existing_contents {
289            let (mut shebang, contents) = extract_shebang(&existing_contents)?;
290            if !shebang.is_empty() {
291                shebang.push_str("\n#\n");
292                // If the shebang doesn't contain `uv`, it's probably something like
293                // `#! /usr/bin/env python`, which isn't going to respect the inline metadata.
294                // Issue a warning for users who might not know that.
295                // TODO: There are a lot of mistakes we could consider detecting here, like
296                // `uv run` without `--script` when the file doesn't end in `.py`.
297                if !regex::Regex::new(r"\buv\b").unwrap().is_match(&shebang) {
298                    warn_user!(
299                        "If you execute {} directly, it might ignore its inline metadata.\nConsider replacing its shebang with: {}",
300                        file.to_string_lossy().cyan(),
301                        "#!/usr/bin/env -S uv run --script".cyan(),
302                    );
303                }
304            }
305            indoc::formatdoc! {r"
306            {shebang}{metadata}
307            {contents}" }
308        } else {
309            indoc::formatdoc! {r#"
310            {metadata}
311
312            def main() -> None:
313                print("Hello from {name}!")
314
315
316            if __name__ == "__main__":
317                main()
318        "#,
319                metadata = metadata,
320                name = script_name,
321            }
322        };
323
324        Ok(fs_err::tokio::write(file, script).await?)
325    }
326
327    /// Replace the existing metadata in the file with new metadata and write the updated content.
328    pub fn write(&self, metadata: &str) -> Result<(), io::Error> {
329        let content = format!(
330            "{}{}{}",
331            self.prelude,
332            serialize_metadata(metadata),
333            self.postlude
334        );
335
336        fs_err::write(&self.path, content)?;
337
338        Ok(())
339    }
340
341    /// Return the [`Sources`] defined in the PEP 723 metadata.
342    pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
343        static EMPTY: BTreeMap<PackageName, Sources> = BTreeMap::new();
344
345        self.metadata
346            .tool
347            .as_ref()
348            .and_then(|tool| tool.uv.as_ref())
349            .and_then(|uv| uv.sources.as_ref())
350            .unwrap_or(&EMPTY)
351    }
352}
353
354/// PEP 723 metadata as parsed from a `script` comment block.
355///
356/// See: <https://peps.python.org/pep-0723/>
357#[derive(Debug, Deserialize, Clone)]
358#[serde(rename_all = "kebab-case")]
359pub struct Pep723Metadata {
360    pub dependencies: Option<Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
361    pub requires_python: Option<VersionSpecifiers>,
362    pub tool: Option<Tool>,
363    /// The raw unserialized document.
364    #[serde(skip)]
365    pub raw: String,
366}
367
368impl Pep723Metadata {
369    /// Parse the PEP 723 metadata from `stdin`.
370    pub fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
371        // Extract the `script` tag.
372        let ScriptTag { metadata, .. } = match ScriptTag::parse(contents) {
373            Ok(Some(tag)) => tag,
374            Ok(None) => return Ok(None),
375            Err(err) => return Err(err),
376        };
377
378        // Parse the metadata.
379        Ok(Some(Self::from_str(&metadata)?))
380    }
381
382    /// Read the PEP 723 `script` metadata from a Python file, if it exists.
383    ///
384    /// Returns `None` if the file is missing a PEP 723 metadata block.
385    ///
386    /// See: <https://peps.python.org/pep-0723/>
387    pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
388        let contents = fs_err::tokio::read(&file).await?;
389
390        // Extract the `script` tag.
391        let ScriptTag { metadata, .. } = match ScriptTag::parse(&contents) {
392            Ok(Some(tag)) => tag,
393            Ok(None) => return Ok(None),
394            Err(err) => return Err(err),
395        };
396
397        // Parse the metadata.
398        Ok(Some(Self::from_str(&metadata)?))
399    }
400}
401
402impl FromStr for Pep723Metadata {
403    type Err = toml::de::Error;
404
405    /// Parse `Pep723Metadata` from a raw TOML string.
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<uv_pep508::Requirement<VerbatimParsedUrl>>>,
429    pub exclude_dependencies: Option<Vec<uv_normalize::PackageName>>,
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 PEP 723 metadata block is missing from the script.")]
443    MissingTag,
444    #[error(transparent)]
445    Io(#[from] io::Error),
446    #[error(transparent)]
447    Utf8(#[from] std::str::Utf8Error),
448    #[error(transparent)]
449    Toml(#[from] toml::de::Error),
450    #[error("Invalid filename `{0}` supplied")]
451    InvalidFilename(String),
452}
453
454#[derive(Debug, Clone, Eq, PartialEq)]
455pub struct ScriptTag {
456    /// The content of the script before the metadata block.
457    prelude: String,
458    /// The metadata block.
459    metadata: String,
460    /// The content of the script after the metadata block.
461    postlude: String,
462}
463
464impl ScriptTag {
465    /// Given the contents of a Python file, extract the `script` metadata block with leading
466    /// comment hashes removed, any preceding shebang or content (prelude), and the remaining Python
467    /// script.
468    ///
469    /// Given the following input string representing the contents of a Python script:
470    ///
471    /// ```python
472    /// #!/usr/bin/env python3
473    /// # /// script
474    /// # requires-python = '>=3.11'
475    /// # dependencies = [
476    /// #   'requests<3',
477    /// #   'rich',
478    /// # ]
479    /// # ///
480    ///
481    /// import requests
482    ///
483    /// print("Hello, World!")
484    /// ```
485    ///
486    /// This function would return:
487    ///
488    /// - Preamble: `#!/usr/bin/env python3\n`
489    /// - Metadata: `requires-python = '>=3.11'\ndependencies = [\n  'requests<3',\n  'rich',\n]`
490    /// - Postlude: `import requests\n\nprint("Hello, World!")\n`
491    ///
492    /// See: <https://peps.python.org/pep-0723/>
493    pub fn parse(contents: &[u8]) -> Result<Option<Self>, Pep723Error> {
494        // Identify the opening pragma.
495        let Some(index) = FINDER.find(contents) else {
496            return Ok(None);
497        };
498
499        // The opening pragma must be the first line, or immediately preceded by a newline.
500        if !(index == 0 || matches!(contents[index - 1], b'\r' | b'\n')) {
501            return Ok(None);
502        }
503
504        // Extract the preceding content.
505        let prelude = std::str::from_utf8(&contents[..index])?;
506
507        // Decode as UTF-8.
508        let contents = &contents[index..];
509        let contents = std::str::from_utf8(contents)?;
510
511        let mut lines = contents.lines();
512
513        // Ensure that the first line is exactly `# /// script`.
514        if lines.next().is_none_or(|line| line != "# /// script") {
515            return Ok(None);
516        }
517
518        // > Every line between these two lines (# /// TYPE and # ///) MUST be a comment starting
519        // > with #. If there are characters after the # then the first character MUST be a space. The
520        // > embedded content is formed by taking away the first two characters of each line if the
521        // > second character is a space, otherwise just the first character (which means the line
522        // > consists of only a single #).
523        let mut toml = vec![];
524
525        for line in lines {
526            // Remove the leading `#`.
527            let Some(line) = line.strip_prefix('#') else {
528                break;
529            };
530
531            // If the line is empty, continue.
532            if line.is_empty() {
533                toml.push("");
534                continue;
535            }
536
537            // Otherwise, the line _must_ start with ` `.
538            let Some(line) = line.strip_prefix(' ') else {
539                break;
540            };
541
542            toml.push(line);
543        }
544
545        // Find the closing `# ///`. The precedence is such that we need to identify the _last_ such
546        // line.
547        //
548        // For example, given:
549        // ```python
550        // # /// script
551        // #
552        // # ///
553        // #
554        // # ///
555        // ```
556        //
557        // The latter `///` is the closing pragma
558        let Some(index) = toml.iter().rev().position(|line| *line == "///") else {
559            return Err(Pep723Error::UnclosedBlock);
560        };
561        let index = toml.len() - index;
562
563        // Discard any lines after the closing `# ///`.
564        //
565        // For example, given:
566        // ```python
567        // # /// script
568        // #
569        // # ///
570        // #
571        // #
572        // ```
573        //
574        // We need to discard the last two lines.
575        toml.truncate(index - 1);
576
577        // Join the lines into a single string.
578        let prelude = prelude.to_string();
579        let metadata = toml.join("\n") + "\n";
580        let postlude = contents
581            .lines()
582            .skip(index + 1)
583            .collect::<Vec<_>>()
584            .join("\n")
585            + "\n";
586
587        Ok(Some(Self {
588            prelude,
589            metadata,
590            postlude,
591        }))
592    }
593}
594
595/// Extracts the shebang line from the given file contents and returns it along with the remaining
596/// content.
597fn extract_shebang(contents: &[u8]) -> Result<(String, String), Pep723Error> {
598    let contents = std::str::from_utf8(contents)?;
599
600    if contents.starts_with("#!") {
601        // Find the first newline.
602        let bytes = contents.as_bytes();
603        let index = bytes
604            .iter()
605            .position(|&b| b == b'\r' || b == b'\n')
606            .unwrap_or(bytes.len());
607
608        // Support `\r`, `\n`, and `\r\n` line endings.
609        let width = match bytes.get(index) {
610            Some(b'\r') => {
611                if bytes.get(index + 1) == Some(&b'\n') {
612                    2
613                } else {
614                    1
615                }
616            }
617            Some(b'\n') => 1,
618            _ => 0,
619        };
620
621        // Extract the shebang line.
622        let shebang = contents[..index].to_string();
623        let script = contents[index + width..].to_string();
624
625        Ok((shebang, script))
626    } else {
627        Ok((String::new(), contents.to_string()))
628    }
629}
630
631/// Formats the provided metadata by prefixing each line with `#` and wrapping it with script markers.
632fn serialize_metadata(metadata: &str) -> String {
633    let mut output = String::with_capacity(metadata.len() + 32);
634
635    output.push_str("# /// script");
636    output.push('\n');
637
638    for line in metadata.lines() {
639        output.push('#');
640        if !line.is_empty() {
641            output.push(' ');
642            output.push_str(line);
643        }
644        output.push('\n');
645    }
646
647    output.push_str("# ///");
648    output.push('\n');
649
650    output
651}
652
653#[cfg(test)]
654mod tests {
655    use crate::{Pep723Error, Pep723Script, ScriptTag, serialize_metadata};
656    use std::str::FromStr;
657
658    #[test]
659    fn missing_space() {
660        let contents = indoc::indoc! {r"
661        # /// script
662        #requires-python = '>=3.11'
663        # ///
664    "};
665
666        assert!(matches!(
667            ScriptTag::parse(contents.as_bytes()),
668            Err(Pep723Error::UnclosedBlock)
669        ));
670    }
671
672    #[test]
673    fn no_closing_pragma() {
674        let contents = indoc::indoc! {r"
675        # /// script
676        # requires-python = '>=3.11'
677        # dependencies = [
678        #     'requests<3',
679        #     'rich',
680        # ]
681    "};
682
683        assert!(matches!(
684            ScriptTag::parse(contents.as_bytes()),
685            Err(Pep723Error::UnclosedBlock)
686        ));
687    }
688
689    #[test]
690    fn leading_content() {
691        let contents = indoc::indoc! {r"
692        pass # /// script
693        # requires-python = '>=3.11'
694        # dependencies = [
695        #   'requests<3',
696        #   'rich',
697        # ]
698        # ///
699        #
700        #
701    "};
702
703        assert_eq!(ScriptTag::parse(contents.as_bytes()).unwrap(), None);
704    }
705
706    #[test]
707    fn simple() {
708        let contents = indoc::indoc! {r"
709        # /// script
710        # requires-python = '>=3.11'
711        # dependencies = [
712        #     'requests<3',
713        #     'rich',
714        # ]
715        # ///
716
717        import requests
718        from rich.pretty import pprint
719
720        resp = requests.get('https://peps.python.org/api/peps.json')
721        data = resp.json()
722    "};
723
724        let expected_metadata = indoc::indoc! {r"
725        requires-python = '>=3.11'
726        dependencies = [
727            'requests<3',
728            'rich',
729        ]
730    "};
731
732        let expected_data = indoc::indoc! {r"
733
734        import requests
735        from rich.pretty import pprint
736
737        resp = requests.get('https://peps.python.org/api/peps.json')
738        data = resp.json()
739    "};
740
741        let actual = ScriptTag::parse(contents.as_bytes()).unwrap().unwrap();
742
743        assert_eq!(actual.prelude, String::new());
744        assert_eq!(actual.metadata, expected_metadata);
745        assert_eq!(actual.postlude, expected_data);
746    }
747
748    #[test]
749    fn simple_with_shebang() {
750        let contents = indoc::indoc! {r"
751        #!/usr/bin/env python3
752        # /// script
753        # requires-python = '>=3.11'
754        # dependencies = [
755        #     'requests<3',
756        #     'rich',
757        # ]
758        # ///
759
760        import requests
761        from rich.pretty import pprint
762
763        resp = requests.get('https://peps.python.org/api/peps.json')
764        data = resp.json()
765    "};
766
767        let expected_metadata = indoc::indoc! {r"
768        requires-python = '>=3.11'
769        dependencies = [
770            'requests<3',
771            'rich',
772        ]
773    "};
774
775        let expected_data = indoc::indoc! {r"
776
777        import requests
778        from rich.pretty import pprint
779
780        resp = requests.get('https://peps.python.org/api/peps.json')
781        data = resp.json()
782    "};
783
784        let actual = ScriptTag::parse(contents.as_bytes()).unwrap().unwrap();
785
786        assert_eq!(actual.prelude, "#!/usr/bin/env python3\n".to_string());
787        assert_eq!(actual.metadata, expected_metadata);
788        assert_eq!(actual.postlude, expected_data);
789    }
790
791    #[test]
792    fn embedded_comment() {
793        let contents = indoc::indoc! {r"
794        # /// script
795        # embedded-csharp = '''
796        # /// <summary>
797        # /// text
798        # ///
799        # /// </summary>
800        # public class MyClass { }
801        # '''
802        # ///
803    "};
804
805        let expected = indoc::indoc! {r"
806        embedded-csharp = '''
807        /// <summary>
808        /// text
809        ///
810        /// </summary>
811        public class MyClass { }
812        '''
813    "};
814
815        let actual = ScriptTag::parse(contents.as_bytes())
816            .unwrap()
817            .unwrap()
818            .metadata;
819
820        assert_eq!(actual, expected);
821    }
822
823    #[test]
824    fn trailing_lines() {
825        let contents = indoc::indoc! {r"
826            # /// script
827            # requires-python = '>=3.11'
828            # dependencies = [
829            #     'requests<3',
830            #     'rich',
831            # ]
832            # ///
833            #
834            #
835        "};
836
837        let expected = indoc::indoc! {r"
838            requires-python = '>=3.11'
839            dependencies = [
840                'requests<3',
841                'rich',
842            ]
843        "};
844
845        let actual = ScriptTag::parse(contents.as_bytes())
846            .unwrap()
847            .unwrap()
848            .metadata;
849
850        assert_eq!(actual, expected);
851    }
852
853    #[test]
854    fn serialize_metadata_formatting() {
855        let metadata = indoc::indoc! {r"
856            requires-python = '>=3.11'
857            dependencies = [
858              'requests<3',
859              'rich',
860            ]
861        "};
862
863        let expected_output = indoc::indoc! {r"
864            # /// script
865            # requires-python = '>=3.11'
866            # dependencies = [
867            #   'requests<3',
868            #   'rich',
869            # ]
870            # ///
871        "};
872
873        let result = serialize_metadata(metadata);
874        assert_eq!(result, expected_output);
875    }
876
877    #[test]
878    fn serialize_metadata_empty() {
879        let metadata = "";
880        let expected_output = "# /// script\n# ///\n";
881
882        let result = serialize_metadata(metadata);
883        assert_eq!(result, expected_output);
884    }
885
886    #[test]
887    fn script_init_empty() {
888        let contents = "".as_bytes();
889        let (prelude, metadata, postlude) =
890            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
891                .unwrap();
892        assert_eq!(prelude, "");
893        assert_eq!(
894            metadata.raw,
895            indoc::indoc! {r"
896            dependencies = []
897            "}
898        );
899        assert_eq!(postlude, "");
900    }
901
902    #[test]
903    fn script_init_requires_python() {
904        let contents = "".as_bytes();
905        let (prelude, metadata, postlude) = Pep723Script::init_metadata(
906            contents,
907            &uv_pep440::VersionSpecifiers::from_str(">=3.8").unwrap(),
908        )
909        .unwrap();
910        assert_eq!(prelude, "");
911        assert_eq!(
912            metadata.raw,
913            indoc::indoc! {r#"
914            requires-python = ">=3.8"
915            dependencies = []
916            "#}
917        );
918        assert_eq!(postlude, "");
919    }
920
921    #[test]
922    fn script_init_with_hashbang() {
923        let contents = indoc::indoc! {r#"
924        #!/usr/bin/env python3
925
926        print("Hello, world!")
927        "#}
928        .as_bytes();
929        let (prelude, metadata, postlude) =
930            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
931                .unwrap();
932        assert_eq!(prelude, "#!/usr/bin/env python3\n");
933        assert_eq!(
934            metadata.raw,
935            indoc::indoc! {r"
936            dependencies = []
937            "}
938        );
939        assert_eq!(
940            postlude,
941            indoc::indoc! {r#"
942
943            print("Hello, world!")
944            "#}
945        );
946    }
947
948    #[test]
949    fn script_init_with_other_metadata() {
950        let contents = indoc::indoc! {r#"
951        # /// noscript
952        # Hello,
953        #
954        # World!
955        # ///
956
957        print("Hello, world!")
958        "#}
959        .as_bytes();
960        let (prelude, metadata, postlude) =
961            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
962                .unwrap();
963        assert_eq!(prelude, "");
964        assert_eq!(
965            metadata.raw,
966            indoc::indoc! {r"
967            dependencies = []
968            "}
969        );
970        // Note the extra line at the beginning.
971        assert_eq!(
972            postlude,
973            indoc::indoc! {r#"
974
975            # /// noscript
976            # Hello,
977            #
978            # World!
979            # ///
980
981            print("Hello, world!")
982            "#}
983        );
984    }
985
986    #[test]
987    fn script_init_with_hashbang_and_other_metadata() {
988        let contents = indoc::indoc! {r#"
989        #!/usr/bin/env python3
990        # /// noscript
991        # Hello,
992        #
993        # World!
994        # ///
995
996        print("Hello, world!")
997        "#}
998        .as_bytes();
999        let (prelude, metadata, postlude) =
1000            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1001                .unwrap();
1002        assert_eq!(prelude, "#!/usr/bin/env python3\n");
1003        assert_eq!(
1004            metadata.raw,
1005            indoc::indoc! {r"
1006            dependencies = []
1007            "}
1008        );
1009        // Note the extra line at the beginning.
1010        assert_eq!(
1011            postlude,
1012            indoc::indoc! {r#"
1013
1014            # /// noscript
1015            # Hello,
1016            #
1017            # World!
1018            # ///
1019
1020            print("Hello, world!")
1021            "#}
1022        );
1023    }
1024
1025    #[test]
1026    fn script_init_with_valid_metadata_line() {
1027        let contents = indoc::indoc! {r#"
1028        # Hello,
1029        # /// noscript
1030        #
1031        # World!
1032        # ///
1033
1034        print("Hello, world!")
1035        "#}
1036        .as_bytes();
1037        let (prelude, metadata, postlude) =
1038            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1039                .unwrap();
1040        assert_eq!(prelude, "");
1041        assert_eq!(
1042            metadata.raw,
1043            indoc::indoc! {r"
1044            dependencies = []
1045            "}
1046        );
1047        // Note the extra line at the beginning.
1048        assert_eq!(
1049            postlude,
1050            indoc::indoc! {r#"
1051
1052            # Hello,
1053            # /// noscript
1054            #
1055            # World!
1056            # ///
1057
1058            print("Hello, world!")
1059            "#}
1060        );
1061    }
1062
1063    #[test]
1064    fn script_init_with_valid_empty_metadata_line() {
1065        let contents = indoc::indoc! {r#"
1066        #
1067        # /// noscript
1068        # Hello,
1069        # World!
1070        # ///
1071
1072        print("Hello, world!")
1073        "#}
1074        .as_bytes();
1075        let (prelude, metadata, postlude) =
1076            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1077                .unwrap();
1078        assert_eq!(prelude, "");
1079        assert_eq!(
1080            metadata.raw,
1081            indoc::indoc! {r"
1082            dependencies = []
1083            "}
1084        );
1085        // Note the extra line at the beginning.
1086        assert_eq!(
1087            postlude,
1088            indoc::indoc! {r#"
1089
1090            #
1091            # /// noscript
1092            # Hello,
1093            # World!
1094            # ///
1095
1096            print("Hello, world!")
1097            "#}
1098        );
1099    }
1100
1101    #[test]
1102    fn script_init_with_non_metadata_comment() {
1103        let contents = indoc::indoc! {r#"
1104        #Hello,
1105        # /// noscript
1106        #
1107        # World!
1108        # ///
1109
1110        print("Hello, world!")
1111        "#}
1112        .as_bytes();
1113        let (prelude, metadata, postlude) =
1114            Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
1115                .unwrap();
1116        assert_eq!(prelude, "");
1117        assert_eq!(
1118            metadata.raw,
1119            indoc::indoc! {r"
1120            dependencies = []
1121            "}
1122        );
1123        assert_eq!(
1124            postlude,
1125            indoc::indoc! {r#"
1126            #Hello,
1127            # /// noscript
1128            #
1129            # World!
1130            # ///
1131
1132            print("Hello, world!")
1133            "#}
1134        );
1135    }
1136}