Repository: joerick/pyinstrument

Issue:
Support `-c` input mode
I often want to run pyinstrument on snippets, e.g. just an import or one function call. It would be nice if I could write `pyinstrument -c "import slow_thing"` to profile, instead of having to write a dummy file containing `import slow_thing`.

Comments:

Patch:
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 93f9492..22c6de2 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -41,7 +41,7 @@ jobs:
           python-version: '3.8'
 
       - name: Build wheels
-        uses: joerick/cibuildwheel@v2.16.1
+        uses: joerick/cibuildwheel@v2.16.2
         env:
           CIBW_SKIP: pp*
           CIBW_ARCHS: ${{matrix.archs}}
diff --git a/docs/guide.md b/docs/guide.md
index d217787..b6fb68d 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -294,8 +294,7 @@ def auto_profile(request):
     profiler.stop()
     PROFILE_ROOT.mkdir(exist_ok=True)
     results_file = PROFILE_ROOT / f"{request.node.name}.html"
-    with open(results_file, "w", encoding="utf-8") as f_html:
-        f_html.write(profiler.output_html())
+    profiler.write_html(results_file)
 ```
 
 This will generate a HTML file for each test node in your test suite inside
diff --git a/metrics/interrupt.py b/metrics/interrupt.py
index 76f1baa..aa446df 100644
--- a/metrics/interrupt.py
+++ b/metrics/interrupt.py
@@ -21,5 +21,4 @@ p.stop()
 
 print(p.output_text())
 
-with open("ioerror_out.html", "w") as f:
-    f.write(p.output_html())
+p.write_html("ioerror_out.html")
diff --git a/metrics/overflow.py b/metrics/overflow.py
index 912486f..a5c6175 100644
--- a/metrics/overflow.py
+++ b/metrics/overflow.py
@@ -21,5 +21,4 @@ p.stop()
 
 print(p.output_text())
 
-with open("overflow_out.html", "w") as f:
-    f.write(p.output_html())
+p.write_html("overflow_out.html")
diff --git a/metrics/overhead.py b/metrics/overhead.py
index ca906b8..a71b53a 100644
--- a/metrics/overhead.py
+++ b/metrics/overhead.py
@@ -51,8 +51,7 @@ profiler.stop()
 # pyinstrument_timeline_timings = test_func()
 # profiler.stop()
 
-with open("out.html", "w") as f:
-    f.write(profiler.output_html())
+profiler.write_html("out.html")
 
 print(profiler.output_text(unicode=True, color=True))
 
diff --git a/pyinstrument/__main__.py b/pyinstrument/__main__.py
index 4a60fd0..e63df3d 100644
--- a/pyinstrument/__main__.py
+++ b/pyinstrument/__main__.py
@@ -34,13 +34,25 @@ def main():
     parser = optparse.OptionParser(usage=usage, version=version_string)
     parser.allow_interspersed_args = False
 
-    def dash_m_callback(option: str, opt: str, value: str, parser: optparse.OptionParser):
-        parser.values.module_name = value  # type: ignore
-
-        # everything after the -m argument should be passed to that module
-        parser.values.module_args = parser.rargs + parser.largs  # type: ignore
-        parser.rargs[:] = []  # type: ignore
-        parser.largs[:] = []  # type: ignore
+    def store_and_consume_remaining(
+        option: optparse.Option, opt: str, value: str, parser: optparse.OptionParser
+    ):
+        """
+        A callback for optparse that stores the value and consumes all
+        remaining arguments, storing them in the same variable as a tuple.
+        """
+
+        # assert a few things we know to be true about the parser
+        assert option.dest
+        assert parser.rargs is not None
+        assert parser.largs is not None
+
+        # everything after this argument should be consumed
+        remaining_arguments = parser.rargs + parser.largs
+        parser.rargs[:] = []
+        parser.largs[:] = []
+
+        setattr(parser.values, option.dest, ValueWithRemainingArgs(value, remaining_arguments))
 
     parser.add_option(
         "--load",
@@ -62,12 +74,21 @@ def main():
     parser.add_option(
         "-m",
         "",
-        dest="module_name",
+        dest="module",
         action="callback",
-        callback=dash_m_callback,
-        type="str",
+        callback=store_and_consume_remaining,
+        type="string",
         help="run library module as a script, like 'python -m module'",
     )
+    parser.add_option(
+        "-c",
+        "",
+        dest="program",
+        action="callback",
+        callback=store_and_consume_remaining,
+        type="string",
+        help="program passed in as string, like 'python -c \"...\"'",
+    )
     parser.add_option(
         "",
         "--from-path",
@@ -244,7 +265,8 @@ def main():
     session_options_used = [
         options.load is not None,
         options.load_prev is not None,
-        options.module_name is not None,
+        options.module is not None,
+        options.program is not None,
         len(args) > 0,
     ]
     if session_options_used.count(True) == 0:
@@ -253,7 +275,7 @@ def main():
     if session_options_used.count(True) > 1:
         parser.error("You can only specify one of --load, --load-prev, -m, or script arguments")
 
-    if options.module_name is not None and options.from_path:
+    if options.module is not None and options.from_path:
         parser.error("The options -m and --from-path are mutually exclusive.")
 
     if options.from_path and sys.platform == "win32":
@@ -297,14 +319,21 @@ def main():
     elif options.load:
         session = Session.load(options.load)
     else:
-        if options.module_name is not None:
+        # we are running some code
+        if options.module is not None:
             if not (sys.path[0] and os.path.samefile(sys.path[0], ".")):
                 # when called with '-m', search the cwd for that module
                 sys.path[0] = os.path.abspath(".")
 
-            argv = [options.module_name] + options.module_args
+            argv = [options.module.value] + options.module.remaining_args
             code = "run_module(modname, run_name='__main__', alter_sys=True)"
-            globs = {"run_module": runpy.run_module, "modname": options.module_name}
+            globs = {"run_module": runpy.run_module, "modname": options.module.value}
+        elif options.program is not None:
+            argv = ["-c", *options.program.remaining_args]
+            code = options.program.value
+            globs = {"__name__": "__main__"}
+            # set the first path entry to '' to match behaviour of python -c
+            sys.path[0] = ""
         else:
             argv = args
             if options.from_path:
@@ -322,15 +351,15 @@ def main():
             code = "run_path(progname, run_name='__main__')"
             globs = {"run_path": runpy.run_path, "progname": progname}
 
+        old_argv = sys.argv.copy()
+
         # there is no point using async mode for command line invocation,
         # because it will always be capturing the whole program, we never want
         # any execution to be <out-of-context>, and it avoids duplicate
         # profiler errors.
         profiler = Profiler(interval=options.interval, async_mode="disabled")
-
         profiler.start()
 
-        old_argv = sys.argv.copy()
         try:
             sys.argv[:] = argv
             exec(code, globs, None)
@@ -552,8 +581,8 @@ class CommandLineOptions:
     A type that codifies the `options` variable.
     """
 
-    module_name: str | None
-    module_args: list[str]
+    module: ValueWithRemainingArgs | None
+    program: ValueWithRemainingArgs | None
     load: str | None
     load_prev: str | None
     from_path: str | None
@@ -573,5 +602,11 @@ class CommandLineOptions:
     interval: float
 
 
+class ValueWithRemainingArgs:
+    def __init__(self, value: str, remaining_args: list[str]):
+        self.value = value
+        self.remaining_args = remaining_args
+
+
 if __name__ == "__main__":
     main()
diff --git a/pyinstrument/profiler.py b/pyinstrument/profiler.py
index 74dd844..eef5f44 100644
--- a/pyinstrument/profiler.py
+++ b/pyinstrument/profiler.py
@@ -1,9 +1,11 @@
 from __future__ import annotations
 
 import inspect
+import os
 import sys
 import time
 import types
+from pathlib import Path
 from time import process_time
 from typing import IO, Any
 
@@ -304,6 +306,16 @@ class Profiler:
         """
         return self.output(renderer=renderers.HTMLRenderer(timeline=timeline))
 
+    def write_html(self, path: str | os.PathLike[str], timeline: bool = False):
+        """
+        Writes the profile output as HTML to a file, as rendered by :class:`HTMLRenderer`
+        """
+        file = Path(path)
+        file.write_text(
+            self.output(renderer=renderers.HTMLRenderer(timeline=timeline)),
+            encoding="utf-8",
+        )
+
     def open_in_browser(self, timeline: bool = False):
         """
         Opens the last profile session in your web browser.


Test patch:
diff --git a/test/test_cmdline.py b/test/test_cmdline.py
index 3a0706d..d718a2c 100644
--- a/test/test_cmdline.py
+++ b/test/test_cmdline.py
@@ -2,14 +2,13 @@ import os
 import re
 import subprocess
 import sys
+import textwrap
 from pathlib import Path
 
 import pytest
 
 from .util import BUSY_WAIT_SCRIPT
 
-# this script just does a busywait for 0.25 seconds.
-
 EXECUTION_DETAILS_SCRIPT = f"""
 #!{sys.executable}
 import sys, os
@@ -78,6 +77,35 @@ class TestCommandLine:
             [*pyinstrument_invocation, "--from-path", "--", "pyi_test_program"],
         )
 
+    def test_program_passed_as_string(self, pyinstrument_invocation, tmp_path: Path):
+        # check the program actually runs
+        output_file = tmp_path / "output.txt"
+        output = subprocess.check_output(
+            [
+                *pyinstrument_invocation,
+                "-c",
+                textwrap.dedent(
+                    f"""
+                    import sys
+                    from pathlib import Path
+                    output_file = Path(sys.argv[1])
+                    output_file.write_text("Hello World")
+                    print("Finished.")
+                    """
+                ),
+                str(output_file),
+            ],
+        )
+
+        assert "Finished." in str(output)
+        assert output_file.read_text() == "Hello World"
+
+        # check the output
+        output = subprocess.check_output([*pyinstrument_invocation, "-c", BUSY_WAIT_SCRIPT])
+
+        assert "busy_wait" in str(output)
+        assert "do_nothing" in str(output)
+
     def test_script_execution_details(self, pyinstrument_invocation, tmp_path: Path):
         program_path = tmp_path / "program.py"
         program_path.write_text(EXECUTION_DETAILS_SCRIPT)
@@ -157,6 +185,27 @@ class TestCommandLine:
         print("process_native.stderr", process_native.stderr)
         assert process_pyi.stderr == process_native.stderr
 
+    def test_program_passed_as_string_execution_details(
+        self, pyinstrument_invocation, tmp_path: Path
+    ):
+        process_pyi = subprocess.run(
+            [*pyinstrument_invocation, "-c", EXECUTION_DETAILS_SCRIPT],
+            stderr=subprocess.PIPE,
+            check=True,
+            text=True,
+        )
+        process_native = subprocess.run(
+            [sys.executable, "-c", EXECUTION_DETAILS_SCRIPT],
+            stderr=subprocess.PIPE,
+            check=True,
+            text=True,
+        )
+
+        print("process_pyi.stderr", process_pyi.stderr)
+        print("process_native.stderr", process_native.stderr)
+        assert process_native.stderr
+        assert process_pyi.stderr == process_native.stderr
+
     def test_session_save_and_load(self, pyinstrument_invocation, tmp_path: Path):
         busy_wait_py = tmp_path / "busy_wait.py"
         busy_wait_py.write_text(BUSY_WAIT_SCRIPT)