Skip to main content

uv_test/packse/
server.rs

1//! A per-test wiremock server that serves packse scenario packages.
2//!
3//! Each [`PackseServer`] reads a single scenario TOML file and serves:
4//! - PEP 691 Simple API at `/simple/{package}/`
5//! - Distribution downloads at `/files/{filename}`
6//!
7//! Cached build dependencies are exposed through the same `/simple/*` and
8//! `/files/*` routes as scenario packages.
9
10use std::collections::HashMap;
11use std::path::Path;
12use std::str::FromStr;
13use std::sync::Arc;
14
15use serde_json::json;
16use wiremock::{Request, ResponseTemplate};
17
18use uv_distribution_filename::WheelFilename;
19use uv_normalize::PackageName;
20use uv_pep440::VersionSpecifiers;
21
22use crate::http_server::{HttpServer, content_type_for_filename};
23use crate::vendor::{VendorArtifact, vendor_artifacts};
24
25use super::scenario::{Scenario, WheelTag};
26use super::scenarios_dir;
27use super::wheel::{generate_sdist, generate_wheel, sha256_hex};
28
29const PACKSE_UPLOAD_TIME: &str = "2024-03-24T00:00:00Z";
30
31/// Information about a single distribution file (metadata for the Simple API).
32struct DistInfo {
33    filename: String,
34    sha256: String,
35    requires_python: Option<VersionSpecifiers>,
36    upload_time: &'static str,
37    yanked: bool,
38}
39
40/// All distributions for a given package name, across versions.
41struct PackageEntry {
42    dists: Vec<DistInfo>,
43}
44
45enum FileData {
46    Bytes(Arc<[u8]>),
47    Vendor(&'static VendorArtifact),
48}
49
50impl FileData {
51    fn bytes(&self) -> anyhow::Result<Arc<[u8]>> {
52        match self {
53            Self::Bytes(bytes) => Ok(Arc::clone(bytes)),
54            Self::Vendor(artifact) => artifact.bytes(),
55        }
56    }
57}
58
59/// The complete pre-indexed database for a server instance.
60struct ServerIndex {
61    /// Simple API: normalized package name → list of distribution metadata.
62    packages: HashMap<PackageName, PackageEntry>,
63    /// File downloads: filename → generated bytes or a lazy vendored artifact.
64    files: HashMap<String, FileData>,
65}
66
67/// A running mock PyPI server for a single packse scenario.
68///
69/// The server runs on a background thread with its own single-threaded tokio runtime.
70/// When [`PackseServer`] is dropped, the background thread and server are shut down.
71pub struct PackseServer {
72    server: HttpServer,
73}
74
75impl PackseServer {
76    /// Load a scenario from a TOML path (relative to the vendored scenarios directory)
77    /// and start a mock server for it.
78    pub fn new(scenario_path: &str) -> Self {
79        let full_path = scenarios_dir().join(scenario_path);
80        let scenario =
81            Scenario::from_path(&full_path).expect("vendored Packse scenario should parse");
82        Self::from_scenario(&scenario)
83    }
84
85    /// Start a mock server with no packages (only cached build dependencies).
86    ///
87    /// Useful as a dummy index that will 404 for any non-cached package lookup.
88    pub fn empty() -> Self {
89        Self::from_scenario(&Scenario::empty())
90    }
91
92    /// Start a mock server for the given scenario.
93    pub fn from_scenario(scenario: &Scenario) -> Self {
94        let index = Arc::new(build_server_index(scenario));
95        let server = HttpServer::start(move |request, server_uri| {
96            handle_request(request, server_uri, &index)
97        });
98
99        Self { server }
100    }
101
102    /// The Simple API index URL (e.g., `http://127.0.0.1:PORT/simple/`).
103    pub fn index_url(&self) -> String {
104        format!("{}/simple/", self.server.url())
105    }
106
107    /// Return the URL for a generated distribution file.
108    pub fn file_url(&self, filename: &str) -> String {
109        format!("{}/files/{filename}", self.server.url())
110    }
111}
112
113/// Build the complete [`ServerIndex`] from a scenario and cached build dependencies.
114fn build_server_index(scenario: &Scenario) -> ServerIndex {
115    let mut packages = HashMap::new();
116    let mut files: HashMap<String, FileData> = HashMap::new();
117
118    for (package_name, package) in &scenario.packages {
119        let mut dists = Vec::new();
120
121        for (version, meta) in &package.versions {
122            if meta.wheel {
123                let tags = if meta.wheel_tags.is_empty() {
124                    vec!["py3-none-any"]
125                } else {
126                    meta.wheel_tags.iter().map(WheelTag::as_str).collect()
127                };
128
129                for tag in tags {
130                    let (filename, bytes) = generate_wheel(
131                        package_name,
132                        version,
133                        &meta.requires,
134                        &meta.extras,
135                        meta.requires_python.as_ref(),
136                        tag,
137                    );
138                    let sha256 = sha256_hex(&bytes);
139                    files.insert(filename.clone(), FileData::Bytes(bytes.into()));
140                    dists.push(DistInfo {
141                        filename,
142                        sha256,
143                        requires_python: meta.requires_python.clone(),
144                        upload_time: PACKSE_UPLOAD_TIME,
145                        yanked: meta.yanked,
146                    });
147                }
148            }
149
150            if meta.sdist {
151                let (filename, bytes) = generate_sdist(
152                    package_name,
153                    version,
154                    &meta.requires,
155                    &meta.extras,
156                    meta.requires_python.as_ref(),
157                );
158                let sha256 = sha256_hex(&bytes);
159                files.insert(filename.clone(), FileData::Bytes(bytes.into()));
160                dists.push(DistInfo {
161                    filename,
162                    sha256,
163                    requires_python: meta.requires_python.clone(),
164                    upload_time: PACKSE_UPLOAD_TIME,
165                    yanked: meta.yanked,
166                });
167            }
168        }
169
170        packages.insert(package_name.clone(), PackageEntry { dists });
171    }
172
173    for artifact in vendor_artifacts() {
174        if !Path::new(artifact.filename)
175            .extension()
176            .is_some_and(|extension| extension.eq_ignore_ascii_case("whl"))
177        {
178            continue;
179        }
180
181        let wheel_filename =
182            WheelFilename::from_str(artifact.filename).expect("invalid vendor wheel filename");
183
184        files.insert(artifact.filename.to_string(), FileData::Vendor(artifact));
185        packages
186            .entry(wheel_filename.name)
187            .or_insert_with(|| PackageEntry { dists: Vec::new() })
188            .dists
189            .push(DistInfo {
190                filename: artifact.filename.to_string(),
191                sha256: artifact.sha256.to_string(),
192                requires_python: None,
193                upload_time: PACKSE_UPLOAD_TIME,
194                yanked: false,
195            });
196    }
197
198    ServerIndex { packages, files }
199}
200
201fn handle_request(req: &Request, server_uri: &str, index: &ServerIndex) -> ResponseTemplate {
202    let path = req.url.path();
203
204    if let Some(pkg) = extract_package_name(path) {
205        let Ok(package_name) = PackageName::from_str(pkg) else {
206            return ResponseTemplate::new(404);
207        };
208
209        if let Some(entry) = index.packages.get(&package_name) {
210            return build_simple_api_response(pkg, entry, server_uri);
211        }
212        return ResponseTemplate::new(404);
213    }
214
215    if let Some(filename) = path.strip_prefix("/files/") {
216        if let Some(file) = index.files.get(filename) {
217            return match file.bytes() {
218                Ok(bytes) => ResponseTemplate::new(200)
219                    .set_body_raw(bytes.to_vec(), content_type_for_filename(filename)),
220                Err(error) => ResponseTemplate::new(500).set_body_string(format!("{error:#}")),
221            };
222        }
223        return ResponseTemplate::new(404);
224    }
225
226    ResponseTemplate::new(404)
227}
228
229/// Build PEP 691 JSON response for a package.
230fn build_simple_api_response(
231    package_name: &str,
232    entry: &PackageEntry,
233    server_uri: &str,
234) -> ResponseTemplate {
235    let files: Vec<serde_json::Value> = entry
236        .dists
237        .iter()
238        .map(|dist| {
239            let url = format!("{server_uri}/files/{}", dist.filename);
240            let mut file_obj = json!({
241                "filename": dist.filename,
242                "url": url,
243                "hashes": {
244                    "sha256": dist.sha256,
245                },
246                "upload-time": dist.upload_time,
247            });
248            if let Some(rp) = &dist.requires_python {
249                file_obj["requires-python"] = json!(rp);
250            }
251            if dist.yanked {
252                file_obj["yanked"] = json!(true);
253            }
254            file_obj
255        })
256        .collect();
257
258    let body = json!({
259        "meta": { "api-version": "1.1" },
260        "name": package_name,
261        "files": files,
262    });
263
264    let body_str = body.to_string();
265    ResponseTemplate::new(200)
266        .insert_header("Content-Type", "application/vnd.pypi.simple.v1+json")
267        .set_body_raw(body_str, "application/vnd.pypi.simple.v1+json")
268}
269
270/// Extract the package name from `/simple/{package}` or `/simple/{package}/`.
271fn extract_package_name(path: &str) -> Option<&str> {
272    let rest = path.strip_prefix("/simple/")?;
273    let pkg = rest.strip_suffix('/').unwrap_or(rest);
274    if pkg.is_empty() || pkg.contains('/') {
275        return None;
276    }
277    Some(pkg)
278}
279
280#[cfg(test)]
281mod tests {
282    use crate::vendor::vendor_artifacts;
283
284    use super::{Scenario, build_server_index, extract_package_name};
285
286    #[test]
287    fn extract_package_name_accepts_with_or_without_trailing_slash() {
288        assert_eq!(extract_package_name("/simple/foo/"), Some("foo"));
289        assert_eq!(extract_package_name("/simple/foo"), Some("foo"));
290    }
291
292    #[test]
293    fn extract_package_name_rejects_invalid_paths() {
294        assert_eq!(extract_package_name("/simple/"), None);
295        assert_eq!(extract_package_name("/simple"), None);
296        assert_eq!(extract_package_name("/simple/foo/bar"), None);
297    }
298
299    #[test]
300    fn server_index_construction_does_not_load_vendor_artifacts() {
301        let _index = build_server_index(&Scenario::empty());
302
303        assert!(
304            vendor_artifacts()
305                .iter()
306                .all(|artifact| !artifact.is_loaded())
307        );
308    }
309}