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
108/// Build the complete [`ServerIndex`] from a scenario and cached build dependencies.
109fn build_server_index(scenario: &Scenario) -> ServerIndex {
110    let mut packages = HashMap::new();
111    let mut files: HashMap<String, FileData> = HashMap::new();
112
113    for (package_name, package) in &scenario.packages {
114        let mut dists = Vec::new();
115
116        for (version, meta) in &package.versions {
117            if meta.wheel {
118                let tags = if meta.wheel_tags.is_empty() {
119                    vec!["py3-none-any"]
120                } else {
121                    meta.wheel_tags.iter().map(WheelTag::as_str).collect()
122                };
123
124                for tag in tags {
125                    let (filename, bytes) = generate_wheel(
126                        package_name,
127                        version,
128                        &meta.requires,
129                        &meta.extras,
130                        meta.requires_python.as_ref(),
131                        tag,
132                    );
133                    let sha256 = sha256_hex(&bytes);
134                    files.insert(filename.clone(), FileData::Bytes(bytes.into()));
135                    dists.push(DistInfo {
136                        filename,
137                        sha256,
138                        requires_python: meta.requires_python.clone(),
139                        upload_time: PACKSE_UPLOAD_TIME,
140                        yanked: meta.yanked,
141                    });
142                }
143            }
144
145            if meta.sdist {
146                let (filename, bytes) = generate_sdist(
147                    package_name,
148                    version,
149                    &meta.requires,
150                    &meta.extras,
151                    meta.requires_python.as_ref(),
152                );
153                let sha256 = sha256_hex(&bytes);
154                files.insert(filename.clone(), FileData::Bytes(bytes.into()));
155                dists.push(DistInfo {
156                    filename,
157                    sha256,
158                    requires_python: meta.requires_python.clone(),
159                    upload_time: PACKSE_UPLOAD_TIME,
160                    yanked: meta.yanked,
161                });
162            }
163        }
164
165        packages.insert(package_name.clone(), PackageEntry { dists });
166    }
167
168    for artifact in vendor_artifacts() {
169        if !Path::new(artifact.filename)
170            .extension()
171            .is_some_and(|extension| extension.eq_ignore_ascii_case("whl"))
172        {
173            continue;
174        }
175
176        let wheel_filename =
177            WheelFilename::from_str(artifact.filename).expect("invalid vendor wheel filename");
178
179        files.insert(artifact.filename.to_string(), FileData::Vendor(artifact));
180        packages
181            .entry(wheel_filename.name)
182            .or_insert_with(|| PackageEntry { dists: Vec::new() })
183            .dists
184            .push(DistInfo {
185                filename: artifact.filename.to_string(),
186                sha256: artifact.sha256.to_string(),
187                requires_python: None,
188                upload_time: PACKSE_UPLOAD_TIME,
189                yanked: false,
190            });
191    }
192
193    ServerIndex { packages, files }
194}
195
196fn handle_request(req: &Request, server_uri: &str, index: &ServerIndex) -> ResponseTemplate {
197    let path = req.url.path();
198
199    if let Some(pkg) = extract_package_name(path) {
200        let Ok(package_name) = PackageName::from_str(pkg) else {
201            return ResponseTemplate::new(404);
202        };
203
204        if let Some(entry) = index.packages.get(&package_name) {
205            return build_simple_api_response(pkg, entry, server_uri);
206        }
207        return ResponseTemplate::new(404);
208    }
209
210    if let Some(filename) = path.strip_prefix("/files/") {
211        if let Some(file) = index.files.get(filename) {
212            return match file.bytes() {
213                Ok(bytes) => ResponseTemplate::new(200)
214                    .set_body_raw(bytes.to_vec(), content_type_for_filename(filename)),
215                Err(error) => ResponseTemplate::new(500).set_body_string(format!("{error:#}")),
216            };
217        }
218        return ResponseTemplate::new(404);
219    }
220
221    ResponseTemplate::new(404)
222}
223
224/// Build PEP 691 JSON response for a package.
225fn build_simple_api_response(
226    package_name: &str,
227    entry: &PackageEntry,
228    server_uri: &str,
229) -> ResponseTemplate {
230    let files: Vec<serde_json::Value> = entry
231        .dists
232        .iter()
233        .map(|dist| {
234            let url = format!("{server_uri}/files/{}", dist.filename);
235            let mut file_obj = json!({
236                "filename": dist.filename,
237                "url": url,
238                "hashes": {
239                    "sha256": dist.sha256,
240                },
241                "upload-time": dist.upload_time,
242            });
243            if let Some(rp) = &dist.requires_python {
244                file_obj["requires-python"] = json!(rp);
245            }
246            if dist.yanked {
247                file_obj["yanked"] = json!(true);
248            }
249            file_obj
250        })
251        .collect();
252
253    let body = json!({
254        "meta": { "api-version": "1.1" },
255        "name": package_name,
256        "files": files,
257    });
258
259    let body_str = body.to_string();
260    ResponseTemplate::new(200)
261        .insert_header("Content-Type", "application/vnd.pypi.simple.v1+json")
262        .set_body_raw(body_str, "application/vnd.pypi.simple.v1+json")
263}
264
265/// Extract the package name from `/simple/{package}` or `/simple/{package}/`.
266fn extract_package_name(path: &str) -> Option<&str> {
267    let rest = path.strip_prefix("/simple/")?;
268    let pkg = rest.strip_suffix('/').unwrap_or(rest);
269    if pkg.is_empty() || pkg.contains('/') {
270        return None;
271    }
272    Some(pkg)
273}
274
275#[cfg(test)]
276mod tests {
277    use crate::vendor::vendor_artifacts;
278
279    use super::{Scenario, build_server_index, extract_package_name};
280
281    #[test]
282    fn extract_package_name_accepts_with_or_without_trailing_slash() {
283        assert_eq!(extract_package_name("/simple/foo/"), Some("foo"));
284        assert_eq!(extract_package_name("/simple/foo"), Some("foo"));
285    }
286
287    #[test]
288    fn extract_package_name_rejects_invalid_paths() {
289        assert_eq!(extract_package_name("/simple/"), None);
290        assert_eq!(extract_package_name("/simple"), None);
291        assert_eq!(extract_package_name("/simple/foo/bar"), None);
292    }
293
294    #[test]
295    fn server_index_construction_does_not_load_vendor_artifacts() {
296        let _index = build_server_index(&Scenario::empty());
297
298        assert!(
299            vendor_artifacts()
300                .iter()
301                .all(|artifact| !artifact.is_loaded())
302        );
303    }
304}