1use clap::{
2 crate_description, crate_name, crate_version, value_parser, Arg, ArgEnum, Command,
3 PossibleValue,
4};
5use remoteprocess::Pid;
6
7#[derive(Debug, Clone, PartialEq)]
9pub struct Config {
10 pub blocking: LockingStrategy,
15
16 pub native: bool,
20
21 #[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 #[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 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 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 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 #[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 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 config.pid = matches.value_of("pid").map(|p| {
437 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 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 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 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 let short_config = get_config("py-spy r -p 1234 -o foo").unwrap();
515 assert_eq!(config, short_config);
516
517 assert_eq!(
519 get_config("py-spy record -o foo").unwrap_err().kind,
520 clap::ErrorKind::MissingRequiredArgument
521 );
522
523 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 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 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 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 let short_config = get_config("py-spy d -p 1234").unwrap();
559 assert_eq!(config, short_config);
560
561 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 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 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}