py_spy/
config.rs

1use clap::{
2    crate_description, crate_name, crate_version, value_parser, Arg, ArgEnum, Command,
3    PossibleValue,
4};
5use remoteprocess::Pid;
6
7/// Options on how to collect samples from a python process
8#[derive(Debug, Clone, PartialEq)]
9pub struct Config {
10    /// Whether or not we should stop the python process when taking samples.
11    /// Setting this to false will reduce the performance impact on the target
12    /// python process, but can lead to incorrect results like partial stack
13    /// traces being returned or a higher sampling error rate
14    pub blocking: LockingStrategy,
15
16    /// Whether or not to profile native extensions. Note: this option can not be
17    /// used with the nonblocking option, as we have to pause the process to collect
18    /// the native stack traces
19    pub native: bool,
20
21    // The following config options only apply when using py-spy as an application
22    #[doc(hidden)]
23    pub command: String,
24    #[doc(hidden)]
25    pub pid: Option<Pid>,
26    #[doc(hidden)]
27    pub python_program: Option<Vec<String>>,
28    #[doc(hidden)]
29    pub sampling_rate: u64,
30    #[doc(hidden)]
31    pub filename: Option<String>,
32    #[doc(hidden)]
33    pub format: Option<FileFormat>,
34    #[doc(hidden)]
35    pub show_line_numbers: bool,
36    #[doc(hidden)]
37    pub duration: RecordDuration,
38    #[doc(hidden)]
39    pub include_idle: bool,
40    #[doc(hidden)]
41    pub include_thread_ids: bool,
42    #[doc(hidden)]
43    pub subprocesses: bool,
44    #[doc(hidden)]
45    pub gil_only: bool,
46    #[doc(hidden)]
47    pub hide_progress: bool,
48    #[doc(hidden)]
49    pub capture_output: bool,
50    #[doc(hidden)]
51    pub dump_json: bool,
52    #[doc(hidden)]
53    pub dump_locals: u64,
54    #[doc(hidden)]
55    pub full_filenames: bool,
56    #[doc(hidden)]
57    pub lineno: LineNo,
58    #[doc(hidden)]
59    pub refresh_seconds: f64,
60    #[doc(hidden)]
61    pub core_filename: Option<String>,
62}
63
64#[allow(non_camel_case_types)]
65#[derive(ArgEnum, Debug, Copy, Clone, Eq, PartialEq)]
66pub enum FileFormat {
67    flamegraph,
68    raw,
69    speedscope,
70    chrometrace,
71}
72
73impl FileFormat {
74    pub fn possible_values() -> impl Iterator<Item = PossibleValue<'static>> {
75        FileFormat::value_variants()
76            .iter()
77            .filter_map(ArgEnum::to_possible_value)
78    }
79}
80
81impl std::str::FromStr for FileFormat {
82    type Err = String;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        for variant in Self::value_variants() {
86            if variant.to_possible_value().unwrap().matches(s, false) {
87                return Ok(*variant);
88            }
89        }
90        Err(format!("Invalid fileformat: {}", s))
91    }
92}
93
94#[derive(Debug, Clone, Eq, PartialEq)]
95pub enum LockingStrategy {
96    NonBlocking,
97    #[allow(dead_code)]
98    AlreadyLocked,
99    Lock,
100}
101
102#[derive(Debug, Clone, Eq, PartialEq)]
103pub enum RecordDuration {
104    Unlimited,
105    Seconds(u64),
106}
107
108#[derive(Debug, Clone, Eq, PartialEq, Copy)]
109pub enum LineNo {
110    NoLine,
111    First,
112    LastInstruction,
113}
114
115impl Default for Config {
116    /// Initializes a new Config object with default parameters
117    #[allow(dead_code)]
118    fn default() -> Config {
119        Config {
120            pid: None,
121            python_program: None,
122            filename: None,
123            format: None,
124            command: String::from("top"),
125            blocking: LockingStrategy::Lock,
126            show_line_numbers: false,
127            sampling_rate: 100,
128            duration: RecordDuration::Unlimited,
129            native: false,
130            gil_only: false,
131            include_idle: false,
132            include_thread_ids: false,
133            hide_progress: false,
134            capture_output: true,
135            dump_json: false,
136            dump_locals: 0,
137            subprocesses: false,
138            full_filenames: false,
139            lineno: LineNo::LastInstruction,
140            refresh_seconds: 1.0,
141            core_filename: None,
142        }
143    }
144}
145
146impl Config {
147    /// Uses clap to set config options from commandline arguments
148    pub fn from_commandline() -> Config {
149        let args: Vec<String> = std::env::args().collect();
150        Config::from_args(&args).unwrap_or_else(|e| e.exit())
151    }
152
153    pub fn from_args(args: &[String]) -> clap::Result<Config> {
154        // pid/native/nonblocking/rate/python_program/subprocesses/full_filenames arguments can be
155        // used across various subcommand - define once here
156        let pid = Arg::new("pid")
157            .short('p')
158            .long("pid")
159            .value_name("pid")
160            .help("PID of a running python program to spy on, in decimal or hex")
161            .takes_value(true);
162
163        let mut native = Arg::new("native")
164            .short('n')
165            .long("native")
166            .help("Collect stack traces from native extensions written in Cython, C or C++");
167
168        // Only show `--native` on platforms where it's supported
169        if !cfg!(feature = "unwind") {
170            native = native.hide(true);
171        }
172
173        #[cfg(not(target_os="freebsd"))]
174        let nonblocking = Arg::new("nonblocking")
175                    .long("nonblocking")
176                    .help("Don't pause the python process when collecting samples. Setting this option will reduce \
177                          the performance impact of sampling, but may lead to inaccurate results");
178
179        let rate = Arg::new("rate")
180            .short('r')
181            .long("rate")
182            .value_name("rate")
183            .help("The number of samples to collect per second")
184            .default_value("100")
185            .takes_value(true);
186
187        let subprocesses = Arg::new("subprocesses")
188            .short('s')
189            .long("subprocesses")
190            .help("Profile subprocesses of the original process");
191
192        let full_filenames = Arg::new("full_filenames").long("full-filenames").help(
193            "Show full Python filenames, instead of shortening to show only the package part",
194        );
195        let program = Arg::new("python_program")
196            .help("commandline of a python program to run")
197            .multiple_values(true);
198
199        let idle = Arg::new("idle")
200            .short('i')
201            .long("idle")
202            .help("Include stack traces for idle threads");
203
204        let gil = Arg::new("gil")
205            .short('g')
206            .long("gil")
207            .help("Only include traces that are holding on to the GIL");
208
209        let top_delay = Arg::new("delay")
210            .long("delay")
211            .value_name("seconds")
212            .help("Delay between 'top' refreshes.")
213            .default_value("1.0")
214            .value_parser(clap::value_parser!(f64))
215            .takes_value(true);
216
217        let record = Command::new("record")
218            .about("Records stack trace information to a flamegraph, speedscope or raw file")
219            .arg(program.clone())
220            .arg(pid.clone().required_unless_present("python_program"))
221            .arg(full_filenames.clone())
222            .arg(
223                Arg::new("output")
224                    .short('o')
225                    .long("output")
226                    .value_name("filename")
227                    .help("Output filename")
228                    .takes_value(true)
229                    .required(false),
230            )
231            .arg(
232                Arg::new("format")
233                    .short('f')
234                    .long("format")
235                    .value_name("format")
236                    .help("Output file format")
237                    .takes_value(true)
238                    .possible_values(FileFormat::possible_values())
239                    .ignore_case(true)
240                    .default_value("flamegraph"),
241            )
242            .arg(
243                Arg::new("duration")
244                    .short('d')
245                    .long("duration")
246                    .value_name("duration")
247                    .help("The number of seconds to sample for")
248                    .default_value("unlimited")
249                    .takes_value(true),
250            )
251            .arg(rate.clone())
252            .arg(subprocesses.clone())
253            .arg(Arg::new("function").short('F').long("function").help(
254                "Aggregate samples by function's first line number, instead of current line number",
255            ))
256            .arg(
257                Arg::new("nolineno")
258                    .long("nolineno")
259                    .help("Do not show line numbers"),
260            )
261            .arg(
262                Arg::new("threads")
263                    .short('t')
264                    .long("threads")
265                    .help("Show thread ids in the output"),
266            )
267            .arg(gil.clone())
268            .arg(idle.clone())
269            .arg(
270                Arg::new("capture")
271                    .long("capture")
272                    .hide(true)
273                    .help("Captures output from child process"),
274            )
275            .arg(
276                Arg::new("hideprogress")
277                    .long("hideprogress")
278                    .hide(true)
279                    .help("Hides progress bar (useful for showing error output on record)"),
280            );
281
282        let top = Command::new("top")
283            .about("Displays a top like view of functions consuming CPU")
284            .arg(program.clone())
285            .arg(pid.clone().required_unless_present("python_program"))
286            .arg(rate.clone())
287            .arg(subprocesses.clone())
288            .arg(full_filenames.clone())
289            .arg(gil.clone())
290            .arg(idle.clone())
291            .arg(top_delay.clone());
292
293        #[cfg(target_os = "linux")]
294        let dump_pid = pid.clone().required_unless_present("core");
295
296        #[cfg(not(target_os = "linux"))]
297        let dump_pid = pid.clone().required(true);
298
299        let dump = Command::new("dump")
300            .about("Dumps stack traces for a target program to stdout")
301            .arg(dump_pid);
302
303        #[cfg(target_os = "linux")]
304        let dump = dump.arg(
305            Arg::new("core")
306                .short('c')
307                .long("core")
308                .help("Filename of coredump to display python stack traces from")
309                .value_name("core")
310                .takes_value(true),
311        );
312
313        let dump = dump.arg(full_filenames.clone())
314            .arg(Arg::new("locals")
315                .short('l')
316                .long("locals")
317                .multiple_occurrences(true)
318                .help("Show local variables for each frame. Passing multiple times (-ll) increases verbosity"))
319            .arg(Arg::new("json")
320                .short('j')
321                .long("json")
322                .help("Format output as JSON"))
323            .arg(subprocesses.clone());
324
325        let completions = Command::new("completions")
326            .about("Generate shell completions")
327            .hide(true)
328            .arg(
329                Arg::new("shell")
330                    .value_parser(value_parser!(clap_complete::Shell))
331                    .help("Shell type"),
332            );
333
334        let record = record.arg(native.clone());
335        let top = top.arg(native.clone());
336        let dump = dump.arg(native.clone());
337
338        // Nonblocking isn't an option for freebsd, remove
339        #[cfg(not(target_os = "freebsd"))]
340        let record = record.arg(nonblocking.clone());
341        #[cfg(not(target_os = "freebsd"))]
342        let top = top.arg(nonblocking.clone());
343        #[cfg(not(target_os = "freebsd"))]
344        let dump = dump.arg(nonblocking.clone());
345
346        let mut app = Command::new(crate_name!())
347            .version(crate_version!())
348            .about(crate_description!())
349            .subcommand_required(true)
350            .infer_subcommands(true)
351            .arg_required_else_help(true)
352            .global_setting(clap::AppSettings::DeriveDisplayOrder)
353            .subcommand(record)
354            .subcommand(top)
355            .subcommand(dump)
356            .subcommand(completions);
357        let matches = app.clone().try_get_matches_from(args)?;
358        info!("Command line args: {:?}", matches);
359
360        let mut config = Config::default();
361
362        let (subcommand, matches) = matches.subcommand().unwrap();
363
364        // Check if `--native` was used on an unsupported platform
365        if !cfg!(feature = "unwind") && matches.contains_id("native") {
366            eprintln!(
367                "Collecting stack traces from native extensions (`--native`) is not supported on your platform."
368            );
369            std::process::exit(1);
370        }
371
372        match subcommand {
373            "record" => {
374                config.sampling_rate = matches.value_of_t("rate")?;
375                config.duration = match matches.value_of("duration") {
376                    Some("unlimited") | None => RecordDuration::Unlimited,
377                    Some(seconds) => {
378                        RecordDuration::Seconds(seconds.parse().expect("invalid duration"))
379                    }
380                };
381                config.format = Some(matches.value_of_t("format")?);
382                config.filename = matches.value_of("output").map(|f| f.to_owned());
383                config.show_line_numbers = matches.occurrences_of("nolineno") == 0;
384                config.lineno = if matches.occurrences_of("nolineno") > 0 {
385                    LineNo::NoLine
386                } else if matches.occurrences_of("function") > 0 {
387                    LineNo::First
388                } else {
389                    LineNo::LastInstruction
390                };
391                config.include_thread_ids = matches.occurrences_of("threads") > 0;
392                if matches.occurrences_of("nolineno") > 0 && matches.occurrences_of("function") > 0
393                {
394                    eprintln!("--function & --nolinenos can't be used together");
395                    std::process::exit(1);
396                }
397                config.hide_progress = matches.occurrences_of("hideprogress") > 0;
398            }
399            "top" => {
400                config.sampling_rate = matches.value_of_t("rate")?;
401                config.refresh_seconds = *matches.get_one::<f64>("delay").unwrap();
402            }
403            "dump" => {
404                config.dump_json = matches.occurrences_of("json") > 0;
405                config.dump_locals = matches.occurrences_of("locals");
406
407                #[cfg(target_os = "linux")]
408                {
409                    config.core_filename = matches.value_of("core").map(|f| f.to_owned());
410                }
411            }
412            "completions" => {
413                let shell = matches.get_one::<clap_complete::Shell>("shell").unwrap();
414                let app_name = app.get_name().to_string();
415                clap_complete::generate(*shell, &mut app, app_name, &mut std::io::stdout());
416                std::process::exit(0);
417            }
418            _ => {}
419        }
420
421        match subcommand {
422            "record" | "top" => {
423                config.python_program = matches
424                    .values_of("python_program")
425                    .map(|vals| vals.map(|v| v.to_owned()).collect());
426                config.gil_only = matches.occurrences_of("gil") > 0;
427                config.include_idle = matches.occurrences_of("idle") > 0;
428            }
429            _ => {}
430        }
431
432        config.subprocesses = matches.occurrences_of("subprocesses") > 0;
433        config.command = subcommand.to_owned();
434
435        // options that can be shared between subcommands
436        config.pid = matches.value_of("pid").map(|p| {
437            // allow pid to be specified as a hexadecimal value
438            match p.to_lowercase().strip_prefix("0x") {
439                Some(prefix) => Pid::from_str_radix(prefix, 16).expect("invalid pid"),
440                None => p.parse().expect("invalid pid"),
441            }
442        });
443
444        config.full_filenames = matches.occurrences_of("full_filenames") > 0;
445        if cfg!(feature = "unwind") {
446            config.native = matches.occurrences_of("native") > 0;
447        }
448
449        config.capture_output = config.command != "record" || matches.occurrences_of("capture") > 0;
450        if !config.capture_output {
451            config.hide_progress = true;
452        }
453
454        if matches.occurrences_of("nonblocking") > 0 {
455            // disable native profiling if invalidly asked for
456            if config.native {
457                eprintln!("Can't get native stack traces with the --nonblocking option.");
458                std::process::exit(1);
459            }
460            config.blocking = LockingStrategy::NonBlocking;
461        }
462
463        #[cfg(windows)]
464        {
465            if config.native && config.subprocesses {
466                // the native extension profiling code relies on dbghelp library, which doesn't
467                // seem to work when connecting to multiple processes. disallow
468                eprintln!(
469                    "Can't get native stack traces with the ---subprocesses option on windows."
470                );
471                std::process::exit(1);
472            }
473        }
474
475        #[cfg(target_os = "freebsd")]
476        {
477            if config.pid.is_some() {
478                if std::env::var("PYSPY_ALLOW_FREEBSD_ATTACH").is_err() {
479                    eprintln!("On FreeBSD, running py-spy can cause an exception in the profiled process if the process \
480                        is calling 'socket.connect'.");
481                    eprintln!("While this is fixed in recent versions of python, you need to acknowledge the risk here by \
482                        setting an environment variable PYSPY_ALLOW_FREEBSD_ATTACH to run this command.");
483                    eprintln!(
484                        "\nSee https://github.com/benfred/py-spy/issues/147 for more information"
485                    );
486                    std::process::exit(-1);
487                }
488            }
489        }
490        Ok(config)
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    fn get_config(cmd: &str) -> clap::Result<Config> {
498        #[cfg(target_os = "freebsd")]
499        std::env::set_var("PYSPY_ALLOW_FREEBSD_ATTACH", "1");
500        let args: Vec<String> = cmd.split_whitespace().map(|x| x.to_owned()).collect();
501        Config::from_args(&args)
502    }
503
504    #[test]
505    fn test_parse_record_args() {
506        // basic use case
507        let config = get_config("py-spy record --pid 1234 --output foo").unwrap();
508        assert_eq!(config.pid, Some(1234));
509        assert_eq!(config.filename, Some(String::from("foo")));
510        assert_eq!(config.format, Some(FileFormat::flamegraph));
511        assert_eq!(config.command, String::from("record"));
512
513        // same command using short versions of everything
514        let short_config = get_config("py-spy r -p 1234 -o foo").unwrap();
515        assert_eq!(config, short_config);
516
517        // missing the --pid argument should fail
518        assert_eq!(
519            get_config("py-spy record -o foo").unwrap_err().kind,
520            clap::ErrorKind::MissingRequiredArgument
521        );
522
523        // but should work when passed a python program
524        let program_config = get_config("py-spy r -o foo -- python test.py").unwrap();
525        assert_eq!(
526            program_config.python_program,
527            Some(vec![String::from("python"), String::from("test.py")])
528        );
529        assert_eq!(program_config.pid, None);
530
531        // passing an invalid file format should fail
532        assert_eq!(
533            get_config("py-spy r -p 1234 -o foo -f unknown")
534                .unwrap_err()
535                .kind,
536            clap::ErrorKind::InvalidValue
537        );
538
539        // test out overriding these params by setting flags
540        assert_eq!(config.include_idle, false);
541        assert_eq!(config.gil_only, false);
542        assert_eq!(config.include_thread_ids, false);
543
544        let config_flags = get_config("py-spy r -p 1234 -o foo --idle --gil --threads").unwrap();
545        assert_eq!(config_flags.include_idle, true);
546        assert_eq!(config_flags.gil_only, true);
547        assert_eq!(config_flags.include_thread_ids, true);
548    }
549
550    #[test]
551    fn test_parse_dump_args() {
552        // basic use case
553        let config = get_config("py-spy dump --pid 1234").unwrap();
554        assert_eq!(config.pid, Some(1234));
555        assert_eq!(config.command, String::from("dump"));
556
557        // short version
558        let short_config = get_config("py-spy d -p 1234").unwrap();
559        assert_eq!(config, short_config);
560
561        // missing the --pid argument should fail
562        assert_eq!(
563            get_config("py-spy dump").unwrap_err().kind,
564            clap::ErrorKind::MissingRequiredArgument
565        );
566    }
567
568    #[test]
569    fn test_parse_top_args() {
570        // basic use case
571        let config = get_config("py-spy top --pid 1234").unwrap();
572        assert_eq!(config.pid, Some(1234));
573        assert_eq!(config.command, String::from("top"));
574
575        // short version
576        let short_config = get_config("py-spy t -p 1234").unwrap();
577        assert_eq!(config, short_config);
578    }
579
580    #[test]
581    fn test_parse_args() {
582        assert_eq!(
583            get_config("py-spy dude").unwrap_err().kind,
584            clap::ErrorKind::UnrecognizedSubcommand
585        );
586    }
587}