Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 64 additions & 44 deletions pyinstrument/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def store_and_consume_remaining(
)

parser.add_option(
"-o", "--outfile", dest="outfile", action="store", help="save to <outfile>", default=None
"-o", "--outfile", dest="outfile", action="append", help="save to <outfile> (can be specified multiple times)", default=None
)

parser.add_option(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions test/test_cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<!DOCTYPE html>" 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