From 225407b6e13704a9bfdd152d24711fe0cbc94c95 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 29 Jan 2026 13:49:36 -0800 Subject: [PATCH 1/2] Support multiple -o/--outfile flags in a single invocation Allow users to specify -o multiple times to generate multiple output formats in one run, e.g.: pyinstrument -o output.txt -o output.html script.py Each outfile infers its renderer from the file extension unless -r is explicitly set. Single -o and no -o behavior is unchanged. Closes #422 --- pyinstrument/__main__.py | 108 +++++++++++++++++++++++---------------- test/test_cmdline.py | 74 +++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 44 deletions(-) diff --git a/pyinstrument/__main__.py b/pyinstrument/__main__.py index b82f05e9..b31793e6 100644 --- a/pyinstrument/__main__.py +++ b/pyinstrument/__main__.py @@ -99,7 +99,7 @@ def store_and_consume_remaining( ) parser.add_option( - "-o", "--outfile", dest="outfile", action="store", help="save to ", default=None + "-o", "--outfile", dest="outfile", action="append", help="save to (can be specified multiple times)", default=None ) parser.add_option( @@ -307,38 +307,40 @@ def store_and_consume_remaining( if options.from_path and sys.platform == "win32": parser.error("--from-path is not supported on Windows") - renderer_class = get_renderer_class(options) - - # open the output file - if options.outfile: - f = open( - options.outfile, - "w", - encoding="utf-8", - errors="surrogateescape", - newline="" if renderer_class.output_is_binary else None, - ) - should_close_f_after_writing = True + if options.renderer is None and not options.output_html: + for outfile in options.outfile: + if guess_renderer_from_outfile(outfile) is None: + parser.error( + f"Can't determine renderer for {outfile!r}. " + "Use a recognizable extension or specify -r." + ) + exit(1) + + for outfile in options.outfile: + try: + get_renderer_class_for_outfile(outfile, options) + except OptionsParseError as e: + parser.error(e.args[0]) + exit(1) else: - f = sys.stdout - should_close_f_after_writing = False + renderer_class = get_renderer_class_for_outfile(None, options) - inner_exception = None + f = sys.stdout - # create the renderer + try: + renderer = create_renderer(renderer_class, options, output_file=f) + except OptionsParseError as e: + parser.error(e.args[0]) + exit(1) + + if renderer.output_is_binary and file_is_a_tty(f): + parser.error( + "Can't write binary output to a terminal. Redirect to a file or use --outfile." + ) + exit(1) - try: - renderer = create_renderer(renderer_class, options, output_file=f) - except OptionsParseError as e: - parser.error(e.args[0]) - exit(1) - - if renderer.output_is_binary and not options.outfile and file_is_a_tty(f): - parser.error( - "Can't write binary output to a terminal. Redirect to a file or use --outfile." - ) - exit(1) + inner_exception = None # get the session - execute code or load from disk @@ -414,20 +416,38 @@ def store_and_consume_remaining( session = profiler.stop() - if isinstance(renderer, renderers.HTMLRenderer) and not options.outfile and file_is_a_tty(f): - # don't write HTML to a TTY, open in browser instead - output_filename = renderer.open_in_browser(session) - print("stdout is a terminal, so saved profile output to %s" % output_filename) + if options.outfile: + # Write output to each outfile + for outfile in options.outfile: + rc = get_renderer_class_for_outfile(outfile, options) + f = open( + outfile, + "w", + encoding="utf-8", + errors="surrogateescape", + newline="" if rc.output_is_binary else None, + ) + try: + r = create_renderer(rc, options, output_file=f) + f.write(r.render(session)) + except OptionsParseError as e: + parser.error(e.args[0]) + exit(1) + finally: + f.close() else: - f.write(renderer.render(session)) - if should_close_f_after_writing: - f.close() + if isinstance(renderer, renderers.HTMLRenderer) and file_is_a_tty(f): + # don't write HTML to a TTY, open in browser instead + output_filename = renderer.open_in_browser(session) + print("stdout is a terminal, so saved profile output to %s" % output_filename) + else: + f.write(renderer.render(session)) - if isinstance(renderer, renderers.ConsoleRenderer) and not options.outfile: - _, report_identifier = save_report_to_temp_storage(session) - print("To view this report with different options, run:") - print(" pyinstrument --load-prev %s [options]" % report_identifier) - print("") + if isinstance(renderer, renderers.ConsoleRenderer): + _, report_identifier = save_report_to_temp_storage(session) + print("To view this report with different options, run:") + print(" pyinstrument --load-prev %s [options]" % report_identifier) + print("") if inner_exception: # If the script raised an exception, re-raise it now to resume @@ -548,14 +568,14 @@ def create_renderer( ) -def get_renderer_class(options: CommandLineOptions) -> type[renderers.Renderer]: +def get_renderer_class_for_outfile(outfile: str | None, options: CommandLineOptions) -> type[renderers.Renderer]: renderer = options.renderer if options.output_html: renderer = "html" - if renderer is None and options.outfile: - renderer = guess_renderer_from_outfile(options.outfile) + if renderer is None and outfile: + renderer = guess_renderer_from_outfile(outfile) if renderer is None: renderer = "text" @@ -662,7 +682,7 @@ class CommandLineOptions: show_regex: str | None show_all: bool output_html: bool - outfile: str | None + outfile: list[str] | None render_options: list[str] | None target_description: str diff --git a/test/test_cmdline.py b/test/test_cmdline.py index cfd22c14..3dfc9b5d 100644 --- a/test/test_cmdline.py +++ b/test/test_cmdline.py @@ -375,3 +375,77 @@ def test_program_exit_code(self, pyinstrument_invocation, tmp_path: Path): ) assert retcode == 1 + + def test_multiple_outfiles(self, pyinstrument_invocation, tmp_path: Path): + txt_file = tmp_path / "output.txt" + html_file = tmp_path / "output.html" + + subprocess.check_call( + [ + *pyinstrument_invocation, + "-o", str(txt_file), + "-o", str(html_file), + "-c", "import time; time.sleep(0.01)", + ], + ) + + assert txt_file.exists() + assert html_file.exists() + + txt_content = txt_file.read_text() + html_content = html_file.read_text() + + # text output should contain the pyinstrument banner + assert "pyinstrument" in txt_content.lower() or "Recorded" in txt_content + # html output should be HTML + assert "" in html_content + + def test_single_outfile(self, pyinstrument_invocation, tmp_path: Path): + txt_file = tmp_path / "output.txt" + + subprocess.check_call( + [ + *pyinstrument_invocation, + "-o", str(txt_file), + "-c", "import time; time.sleep(0.01)", + ], + ) + + assert txt_file.exists() + txt_content = txt_file.read_text() + assert len(txt_content) > 0 + + def test_multiple_outfiles_with_explicit_renderer(self, pyinstrument_invocation, tmp_path: Path): + file1 = tmp_path / "out1.dat" + file2 = tmp_path / "out2.dat" + + subprocess.check_call( + [ + *pyinstrument_invocation, + "-r", "json", + "-o", str(file1), + "-o", str(file2), + "-c", "import time; time.sleep(0.01)", + ], + ) + + import json + + # both files should contain valid JSON + for f in [file1, file2]: + data = json.loads(f.read_text()) + assert isinstance(data, dict) + + def test_multiple_outfiles_unrecognized_extension_errors(self, pyinstrument_invocation, tmp_path: Path): + result = subprocess.run( + [ + *pyinstrument_invocation, + "-o", str(tmp_path / "output.xyz"), + "-c", "import time; time.sleep(0.01)", + ], + stderr=subprocess.PIPE, + text=True, + ) + + assert result.returncode == 2 + assert "Can't determine renderer" in result.stderr From a742728543504b1a6fe8af5276a2ca7a7c5a4e52 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 29 Jan 2026 15:41:39 -0800 Subject: [PATCH 2/2] Document multiple -o/--outfile support in guide and reference docs --- docs/guide.md | 5 +++++ docs/reference.md | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/guide.md b/docs/guide.md index a52e7c08..6b07154e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -27,6 +27,11 @@ Here are the options you can use: **Protip:** `-r html` will give you a interactive profile report as HTML - you can really explore this way! +You can also specify `-o` multiple times to generate multiple output formats in +a single run. Each outfile infers its renderer from the file extension: + + pyinstrument -o profile.txt -o profile.html script.py + ## Profile a Python CLI command For profiling an installed Python script via the diff --git a/docs/reference.md b/docs/reference.md index eef5d63d..55288f6c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -12,6 +12,21 @@ print a profile report to the console. ```{program-output} pyinstrument --help ``` +### Output file (`-o`/`--outfile`) + +The `-o`/`--outfile` flag saves the profiling output to a file. The renderer +is automatically inferred from the file extension (e.g. `.html` for HTML, +`.json` for JSON, `.txt` for console text), unless `-r` is explicitly set. + +You can specify `-o` multiple times to generate multiple output formats in a +single run: + + pyinstrument -o profile.txt -o profile.html script.py + +When multiple output files are specified, each file's renderer is determined +independently from its extension. If `-r` is also provided, it applies to all +output files that don't have a recognized extension. + ## Python API The Python API is also available, for calling pyinstrument directly from