pyoxidizerlib/py_packaging/
packaging_tool.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5/*!
6Interaction with Python packaging tools (pip, setuptools, etc).
7*/
8
9use {
10    super::{
11        binary::LibpythonLinkMode, distribution::PythonDistribution,
12        distutils::read_built_extensions, standalone_distribution::resolve_python_paths,
13    },
14    crate::environment::Environment,
15    anyhow::{anyhow, Context, Result},
16    duct::{cmd, ReaderHandle},
17    log::warn,
18    python_packaging::{
19        filesystem_scanning::find_python_resources, policy::PythonPackagingPolicy,
20        resource::PythonResource, wheel::WheelArchive,
21    },
22    std::{
23        collections::{hash_map::RandomState, HashMap},
24        hash::BuildHasher,
25        io::{BufRead, BufReader},
26        path::{Path, PathBuf},
27    },
28};
29
30fn log_command_output(handle: &ReaderHandle) {
31    let reader = BufReader::new(handle);
32    for line in reader.lines() {
33        match line {
34            Ok(line) => {
35                warn!("{}", line);
36            }
37            Err(err) => {
38                warn!("Error when reading output: {:?}", err);
39            }
40        }
41    }
42}
43
44/// Find resources installed as part of a packaging operation.
45pub fn find_resources<'a>(
46    dist: &dyn PythonDistribution,
47    policy: &PythonPackagingPolicy,
48    path: &Path,
49    state_dir: Option<PathBuf>,
50) -> Result<Vec<PythonResource<'a>>> {
51    let mut res = Vec::new();
52
53    let built_extensions = if let Some(p) = state_dir {
54        read_built_extensions(&p)?
55            .iter()
56            .map(|ext| (ext.name.clone(), ext.clone()))
57            .collect()
58    } else {
59        HashMap::new()
60    };
61
62    for r in find_python_resources(
63        path,
64        dist.cache_tag(),
65        &dist.python_module_suffixes()?,
66        policy.file_scanner_emit_files(),
67        policy.file_scanner_classify_files(),
68    )? {
69        let r = r?.to_memory()?;
70
71        match r {
72            PythonResource::ExtensionModule(e) => {
73                // Use a built extension if present, as it will contain more metadata.
74                res.push(if let Some(built) = built_extensions.get(&e.name) {
75                    PythonResource::from(built.to_memory()?)
76                } else {
77                    PythonResource::ExtensionModule(e)
78                });
79            }
80            _ => {
81                res.push(r);
82            }
83        }
84    }
85
86    Ok(res)
87}
88
89/// Run `pip download` and collect resources found from downloaded packages.
90///
91/// `host_dist` is the Python distribution to use to run `pip`.
92///
93/// `build_dist` is the Python distribution that packages are being downloaded
94/// for.
95///
96/// The distributions are often the same. But passing a different
97/// distribution targeting a different platform allows this command to
98/// resolve resources for a non-native platform, which enables it to be used
99/// when cross-compiling.
100pub fn pip_download<'a>(
101    env: &Environment,
102    host_dist: &dyn PythonDistribution,
103    taget_dist: &dyn PythonDistribution,
104    policy: &PythonPackagingPolicy,
105    verbose: bool,
106    args: &[String],
107) -> Result<Vec<PythonResource<'a>>> {
108    let temp_dir = env.temporary_directory("pyoxidizer-pip-download")?;
109
110    host_dist.ensure_pip()?;
111
112    let target_dir = temp_dir.path();
113
114    warn!("pip downloading to {}", target_dir.display());
115
116    let mut pip_args = vec![
117        "-m".to_string(),
118        "pip".to_string(),
119        "--disable-pip-version-check".to_string(),
120    ];
121
122    if verbose {
123        pip_args.push("--verbose".to_string());
124    }
125
126    pip_args.extend(vec![
127        "download".to_string(),
128        // Download packages to our temporary directory.
129        "--dest".to_string(),
130        format!("{}", target_dir.display()),
131        // Only download wheels.
132        "--only-binary=:all:".to_string(),
133        // We download files compatible with the distribution we're targeting.
134        format!(
135            "--platform={}",
136            taget_dist.python_platform_compatibility_tag()
137        ),
138        format!("--python-version={}", taget_dist.python_version()),
139        format!(
140            "--implementation={}",
141            taget_dist.python_implementation_short()
142        ),
143    ]);
144
145    if let Some(abi) = taget_dist.python_abi_tag() {
146        pip_args.push(format!("--abi={}", abi));
147    }
148
149    pip_args.extend(args.iter().cloned());
150
151    warn!("running python {:?}", pip_args);
152
153    let command = cmd(host_dist.python_exe_path(), &pip_args)
154        .stderr_to_stdout()
155        .unchecked()
156        .reader()?;
157
158    log_command_output(&command);
159
160    let output = command
161        .try_wait()?
162        .ok_or_else(|| anyhow!("unable to wait on command"))?;
163    if !output.status.success() {
164        return Err(anyhow!("error running pip"));
165    }
166
167    // Since we used --only-binary=:all: above, we should only have .whl files
168    // in the destination directory. Iterate over them and collect resources
169    // from each.
170
171    let mut files = std::fs::read_dir(target_dir)?
172        .map(|entry| Ok(entry?.path()))
173        .collect::<Result<Vec<_>>>()?;
174    files.sort();
175
176    // TODO there's probably a way to do this using iterators.
177    let mut res = Vec::new();
178
179    for path in &files {
180        let wheel = WheelArchive::from_path(path)?;
181
182        res.extend(wheel.python_resources(
183            taget_dist.cache_tag(),
184            &taget_dist.python_module_suffixes()?,
185            policy.file_scanner_emit_files(),
186            policy.file_scanner_classify_files(),
187        )?);
188    }
189
190    temp_dir.close().context("closing temporary directory")?;
191
192    Ok(res)
193}
194
195/// Run `pip install` and return found resources.
196pub fn pip_install<'a, S: BuildHasher>(
197    env: &Environment,
198    dist: &dyn PythonDistribution,
199    policy: &PythonPackagingPolicy,
200    libpython_link_mode: LibpythonLinkMode,
201    verbose: bool,
202    install_args: &[String],
203    extra_envs: &HashMap<String, String, S>,
204) -> Result<Vec<PythonResource<'a>>> {
205    let temp_dir = env.temporary_directory("pyoxidizer-pip-install")?;
206
207    dist.ensure_pip()?;
208
209    let mut env: HashMap<String, String, RandomState> = std::env::vars().collect();
210    for (k, v) in dist.resolve_distutils(libpython_link_mode, temp_dir.path(), &[])? {
211        env.insert(k, v);
212    }
213
214    for (key, value) in extra_envs.iter() {
215        env.insert(key.clone(), value.clone());
216    }
217
218    let target_dir = temp_dir.path().join("install");
219
220    warn!("pip installing to {}", target_dir.display());
221
222    let mut pip_args: Vec<String> = vec![
223        "-m".to_string(),
224        "pip".to_string(),
225        "--disable-pip-version-check".to_string(),
226    ];
227
228    if verbose {
229        pip_args.push("--verbose".to_string());
230    }
231
232    pip_args.extend(vec![
233        "install".to_string(),
234        "--target".to_string(),
235        format!("{}", target_dir.display()),
236    ]);
237
238    pip_args.extend(install_args.iter().cloned());
239
240    let command = cmd(dist.python_exe_path(), &pip_args)
241        .full_env(&env)
242        .stderr_to_stdout()
243        .unchecked()
244        .reader()?;
245
246    log_command_output(&command);
247
248    let output = command
249        .try_wait()?
250        .ok_or_else(|| anyhow!("unable to wait on command"))?;
251    if !output.status.success() {
252        return Err(anyhow!("error running pip"));
253    }
254
255    let state_dir = env.get("PYOXIDIZER_DISTUTILS_STATE_DIR").map(PathBuf::from);
256
257    let resources =
258        find_resources(dist, policy, &target_dir, state_dir).context("scanning for resources")?;
259
260    temp_dir.close().context("closing temporary directory")?;
261
262    Ok(resources)
263}
264
265/// Discover Python resources from a populated virtualenv directory.
266pub fn read_virtualenv<'a>(
267    dist: &dyn PythonDistribution,
268    policy: &PythonPackagingPolicy,
269    path: &Path,
270) -> Result<Vec<PythonResource<'a>>> {
271    let python_paths = resolve_python_paths(path, &dist.python_major_minor_version());
272
273    find_resources(dist, policy, &python_paths.site_packages, None)
274}
275
276/// Run `setup.py install` against a path and return found resources.
277#[allow(clippy::too_many_arguments)]
278pub fn setup_py_install<'a, S: BuildHasher>(
279    env: &Environment,
280    dist: &dyn PythonDistribution,
281    policy: &PythonPackagingPolicy,
282    libpython_link_mode: LibpythonLinkMode,
283    package_path: &Path,
284    verbose: bool,
285    extra_envs: &HashMap<String, String, S>,
286    extra_global_arguments: &[String],
287) -> Result<Vec<PythonResource<'a>>> {
288    if !package_path.is_absolute() {
289        return Err(anyhow!(
290            "package_path must be absolute: got {:?}",
291            package_path.display()
292        ));
293    }
294
295    let temp_dir = env.temporary_directory("pyoxidizer-setup-py-install")?;
296
297    let target_dir_path = temp_dir.path().join("install");
298    let target_dir_s = target_dir_path.display().to_string();
299
300    let python_paths = resolve_python_paths(&target_dir_path, &dist.python_major_minor_version());
301
302    std::fs::create_dir_all(&python_paths.site_packages)?;
303
304    let mut envs: HashMap<String, String, RandomState> = std::env::vars().collect();
305    for (k, v) in dist.resolve_distutils(
306        libpython_link_mode,
307        temp_dir.path(),
308        &[&python_paths.site_packages, &python_paths.stdlib],
309    )? {
310        envs.insert(k, v);
311    }
312
313    for (key, value) in extra_envs {
314        envs.insert(key.clone(), value.clone());
315    }
316
317    warn!(
318        "python setup.py installing {} to {}",
319        package_path.display(),
320        target_dir_s
321    );
322
323    let mut args = vec!["setup.py"];
324
325    if verbose {
326        args.push("--verbose");
327    }
328
329    for arg in extra_global_arguments {
330        args.push(arg);
331    }
332
333    args.extend(["install", "--prefix", &target_dir_s, "--no-compile"]);
334
335    let command = cmd(dist.python_exe_path(), &args)
336        .dir(package_path)
337        .full_env(&envs)
338        .stderr_to_stdout()
339        .unchecked()
340        .reader()?;
341
342    log_command_output(&command);
343
344    let output = command
345        .try_wait()?
346        .ok_or_else(|| anyhow!("unable to wait on command"))?;
347    if !output.status.success() {
348        return Err(anyhow!("error running pip"));
349    }
350
351    let state_dir = envs
352        .get("PYOXIDIZER_DISTUTILS_STATE_DIR")
353        .map(PathBuf::from);
354    warn!(
355        "scanning {} for resources",
356        python_paths.site_packages.display()
357    );
358    let resources = find_resources(dist, policy, &python_paths.site_packages, state_dir)
359        .context("scanning for resources")?;
360
361    temp_dir.close().context("closing temporary directory")?;
362
363    Ok(resources)
364}
365
366#[cfg(test)]
367mod tests {
368    use {
369        super::*,
370        crate::testutil::*,
371        std::{collections::BTreeSet, ops::Deref},
372    };
373
374    #[test]
375    fn test_install_black() -> Result<()> {
376        let env = get_env()?;
377        let distribution = get_default_distribution(None)?;
378
379        let resources: Vec<PythonResource> = pip_install(
380            &env,
381            distribution.deref(),
382            &distribution.create_packaging_policy()?,
383            LibpythonLinkMode::Dynamic,
384            false,
385            &["black==19.10b0".to_string()],
386            &HashMap::new(),
387        )?;
388
389        assert!(resources.iter().any(|r| r.full_name() == "appdirs"));
390        assert!(resources.iter().any(|r| r.full_name() == "black"));
391
392        Ok(())
393    }
394
395    #[test]
396    #[cfg(windows)]
397    fn test_install_cffi() -> Result<()> {
398        let env = get_env()?;
399        let distribution = get_default_dynamic_distribution()?;
400        let policy = distribution.create_packaging_policy()?;
401
402        let resources: Vec<PythonResource> = pip_install(
403            &env,
404            distribution.deref(),
405            &policy,
406            LibpythonLinkMode::Dynamic,
407            false,
408            &["cffi==1.15.0".to_string()],
409            &HashMap::new(),
410        )?;
411
412        let ems = resources
413            .iter()
414            .filter(|r| matches!(r, PythonResource::ExtensionModule { .. }))
415            .collect::<Vec<&PythonResource>>();
416
417        assert_eq!(ems.len(), 1);
418        assert_eq!(ems[0].full_name(), "_cffi_backend");
419
420        Ok(())
421    }
422
423    #[test]
424    fn test_pip_download_zstandard() -> Result<()> {
425        let env = get_env()?;
426
427        for target_dist in get_all_standalone_distributions()? {
428            if target_dist.python_platform_compatibility_tag() == "none" {
429                continue;
430            }
431
432            let host_dist = get_host_distribution_from_target(&target_dist)?;
433
434            warn!(
435                "using distribution {}-{}-{}",
436                target_dist.python_implementation,
437                target_dist.python_platform_tag,
438                target_dist.version
439            );
440
441            let policy = target_dist.create_packaging_policy()?;
442
443            let resources = pip_download(
444                &env,
445                &*host_dist,
446                &*target_dist,
447                &policy,
448                false,
449                &["zstandard==0.16.0".to_string()],
450            )?;
451
452            assert!(!resources.is_empty());
453            let zstandard_resources = resources
454                .iter()
455                .filter(|r| r.is_in_packages(&["zstandard".to_string()]))
456                .collect::<Vec<_>>();
457            assert!(!zstandard_resources.is_empty());
458
459            let full_names = zstandard_resources
460                .iter()
461                .map(|r| r.full_name())
462                .collect::<BTreeSet<_>>();
463
464            let mut expected_names = [
465                "zstandard",
466                "zstandard.__init__.pyi",
467                "zstandard.backend_c",
468                "zstandard.backend_cffi",
469                "zstandard.py.typed",
470                "zstandard:LICENSE",
471                "zstandard:METADATA",
472                "zstandard:RECORD",
473                "zstandard:WHEEL",
474                "zstandard:top_level.txt",
475            ]
476            .iter()
477            .map(|x| x.to_string())
478            .collect::<BTreeSet<String>>();
479
480            let mut expected_extensions_count = 1;
481            let mut expected_first_extension_name = "zstandard.backend_c";
482
483            if matches!(
484                target_dist.target_triple.as_str(),
485                "i686-pc-windows-msvc" | "x86_64-pc-windows-msvc"
486            ) {
487                expected_names.insert("zstandard._cffi".to_string());
488                expected_extensions_count = 2;
489                expected_first_extension_name = "zstandard._cffi";
490            }
491
492            assert_eq!(
493                full_names, expected_names,
494                "target triple: {}",
495                target_dist.target_triple
496            );
497
498            let extensions = zstandard_resources
499                .iter()
500                .filter_map(|r| match r {
501                    PythonResource::ExtensionModule(em) => Some(em),
502                    _ => None,
503                })
504                .collect::<Vec<_>>();
505
506            assert_eq!(
507                extensions.len(),
508                expected_extensions_count,
509                "target triple: {}",
510                target_dist.target_triple
511            );
512            let em = extensions[0];
513            assert_eq!(em.name, expected_first_extension_name);
514            assert!(em.shared_library.is_some());
515        }
516
517        Ok(())
518    }
519
520    #[test]
521    fn test_pip_download_numpy() -> Result<()> {
522        let env = get_env()?;
523
524        for target_dist in get_all_standalone_distributions()? {
525            if target_dist.python_platform_compatibility_tag() == "none" {
526                continue;
527            }
528
529            let host_dist = get_host_distribution_from_target(&target_dist)?;
530
531            warn!(
532                "using distribution {}-{}-{}",
533                target_dist.python_implementation,
534                target_dist.python_platform_tag,
535                target_dist.version
536            );
537
538            let mut policy = target_dist.create_packaging_policy()?;
539            policy.set_file_scanner_emit_files(true);
540            policy.set_file_scanner_classify_files(true);
541
542            let res = pip_download(
543                &env,
544                &*host_dist,
545                &*target_dist,
546                &policy,
547                false,
548                &["numpy==1.22.1".to_string()],
549            );
550
551            // numpy wheel not available for 3.10 win32.
552            if target_dist.python_major_minor_version() == "3.10"
553                && target_dist.python_platform_tag() == "win32"
554            {
555                assert!(res.is_err());
556                continue;
557            }
558
559            let resources = res?;
560
561            assert!(!resources.is_empty());
562
563            let extensions = resources
564                .iter()
565                .filter_map(|r| match r {
566                    PythonResource::ExtensionModule(em) => Some(em),
567                    _ => None,
568                })
569                .collect::<Vec<_>>();
570
571            assert!(!extensions.is_empty());
572
573            assert!(extensions
574                .iter()
575                .any(|em| em.name == "numpy.random._common"));
576        }
577
578        Ok(())
579    }
580}