diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f2464e42..8de99a53 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,7 @@ Changes * FIX: ``show_text`` now increases column sizes or switches to scientific notation to maintain alignment * ENH: ``show_text`` now has new options: sort and summarize * ENH: Added new CLI arguments ``-srm`` to ``line_profiler`` to control sorting, rich printing, and summary printing. +* ENH: New global ``profile`` function that can be enabled by ``--profile`` or ``LINE_PROFILE=1``. 4.0.3 ~~~~ diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..6fcf05b4 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py index 53b3a249..efac8533 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -163,6 +163,7 @@ def visit_Assign(self, node): 'xdoctest': ('https://xdoctest.readthedocs.io/en/latest/', None), 'networkx': ('https://networkx.org/documentation/stable/', None), 'scriptconfig': ('https://scriptconfig.readthedocs.io/en/latest/', None), + 'xdev': ('https://xdev.readthedocs.io/en/latest/', None), } __dev_note__ = """ diff --git a/docs/source/index.rst b/docs/source/index.rst index 150e0afb..47c4057b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,24 +1,17 @@ -.. The large version wont work because github strips rst image rescaling. https://i.imgur.com/AcWVroL.png - # TODO: Add a logo - .. image:: https://i.imgur.com/PoYIsWE.png - :height: 100px - :align: left - -Welcome to line_profiler's documentation! -========================================= - .. The __init__ files contains the top-level documentation overview .. automodule:: line_profiler.__init__ :show-inheritance: .. toctree:: - :maxdepth: 5 + :maxdepth: 8 + :caption: Package Layout line_profiler + kernprof Indices and tables ================== * :ref:`genindex` -* :ref:`modindex` \ No newline at end of file +* :ref:`modindex` diff --git a/docs/source/kernprof.rst b/docs/source/kernprof.rst new file mode 100644 index 00000000..0927b3e7 --- /dev/null +++ b/docs/source/kernprof.rst @@ -0,0 +1,10 @@ +.. .. manually created (not sure how to get automodule to do it) + +kernprof module +=============== + +.. automodule:: kernprof + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/docs/source/line_profiler.__main__.rst b/docs/source/line_profiler.__main__.rst new file mode 100644 index 00000000..65636606 --- /dev/null +++ b/docs/source/line_profiler.__main__.rst @@ -0,0 +1,8 @@ +line\_profiler.\_\_main\_\_ module +================================== + +.. automodule:: line_profiler.__main__ + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/docs/source/line_profiler._line_profiler.rst b/docs/source/line_profiler._line_profiler.rst new file mode 100644 index 00000000..106dc5e1 --- /dev/null +++ b/docs/source/line_profiler._line_profiler.rst @@ -0,0 +1,8 @@ +line\_profiler.\_line\_profiler module +====================================== + +.. automodule:: line_profiler._line_profiler + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/docs/source/line_profiler.explicit_profiler.rst b/docs/source/line_profiler.explicit_profiler.rst new file mode 100644 index 00000000..093cf34a --- /dev/null +++ b/docs/source/line_profiler.explicit_profiler.rst @@ -0,0 +1,8 @@ +line\_profiler.explicit\_profiler module +======================================== + +.. automodule:: line_profiler.explicit_profiler + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/docs/source/line_profiler.ipython_extension.rst b/docs/source/line_profiler.ipython_extension.rst new file mode 100644 index 00000000..3a6e184e --- /dev/null +++ b/docs/source/line_profiler.ipython_extension.rst @@ -0,0 +1,8 @@ +line\_profiler.ipython\_extension module +======================================== + +.. automodule:: line_profiler.ipython_extension + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/docs/source/line_profiler.line_profiler.rst b/docs/source/line_profiler.line_profiler.rst new file mode 100644 index 00000000..8d37d151 --- /dev/null +++ b/docs/source/line_profiler.line_profiler.rst @@ -0,0 +1,8 @@ +line\_profiler.line\_profiler module +==================================== + +.. automodule:: line_profiler.line_profiler + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/docs/source/line_profiler.rst b/docs/source/line_profiler.rst new file mode 100644 index 00000000..e70d557b --- /dev/null +++ b/docs/source/line_profiler.rst @@ -0,0 +1,23 @@ +line\_profiler package +====================== + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + line_profiler.__main__ + line_profiler._line_profiler + line_profiler.explicit_profiler + line_profiler.ipython_extension + line_profiler.line_profiler + +Module contents +--------------- + +.. automodule:: line_profiler + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 00000000..a6ff2cbc --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,7 @@ +line_profiler +============= + +.. toctree:: + :maxdepth: 4 + + line_profiler diff --git a/kernprof.py b/kernprof.py index 63c95212..f25bed83 100755 --- a/kernprof.py +++ b/kernprof.py @@ -239,6 +239,8 @@ def positive_float(value): import line_profiler prof = line_profiler.LineProfiler() options.builtin = True + # Overwrite the explicit profile decorator + line_profiler.profile._kernprof_overwrite(prof) else: prof = ContextualProfile() if options.builtin: diff --git a/line_profiler/__init__.py b/line_profiler/__init__.py index 7cd49f59..1e1ca838 100644 --- a/line_profiler/__init__.py +++ b/line_profiler/__init__.py @@ -1,5 +1,25 @@ """ -The line_profiler modula for doing line-by-line profiling of functions +Line Profiler +============= + +The line_profiler module for doing line-by-line profiling of functions + ++---------------+-------------------------------------------+ +| Github | https://github.com/pyutils/line_profiler | ++---------------+-------------------------------------------+ +| Pypi | https://pypi.org/project/line_profiler | ++---------------+-------------------------------------------+ + + +Installation +============ + +Releases of ``line_profiler`` can be installed using pip + +.. code:: bash + + pip install line_profiler + """ __submodules__ = [ 'line_profiler', @@ -21,6 +41,10 @@ load_ipython_extension, load_stats, main, show_func, show_text,) + +from .explicit_profiler import profile + + __all__ = ['LineProfiler', 'line_profiler', 'load_ipython_extension', 'load_stats', 'main', 'show_func', - 'show_text', '__version__'] + 'show_text', '__version__', 'profile'] diff --git a/line_profiler/explicit_profiler.py b/line_profiler/explicit_profiler.py new file mode 100644 index 00000000..4b12378e --- /dev/null +++ b/line_profiler/explicit_profiler.py @@ -0,0 +1,364 @@ +""" +The idea is that we are going to expose a top-level ``profile`` decorator which +will be disabled by default **unless** you are running with with line profiler +itself OR if the LINE_PROFILE environment variable is True. + +This uses the :mod:`atexit` module to perform a profile dump at the end. + +This work is ported from :mod:`xdev`. + +Basic usage is to import line_profiler and decorate your function with +line_profiler.profile. By default this does nothing, it's a no-op decorator. +However, if you run with the environment variable ``LINE_PROFILER=1`` or if +``'--profile' in sys.argv'``, then it enables profiling and at the end of your +script it will output the profile text. + +Here is a minimal example: + +.. code:: bash + + # Write demo python script to disk + python -c "if 1: + import textwrap + text = textwrap.dedent( + ''' + from line_profiler import profile + + @profile + def plus(a, b): + return a + b + + @profile + def fib(n): + a, b = 0, 1 + while a < n: + a, b = b, plus(a, b) + + @profile + def main(): + import math + import time + start = time.time() + + print('start calculating') + while time.time() - start < 1: + fib(10) + math.factorial(1000) + print('done calculating') + + main() + ''' + ).strip() + with open('demo.py', 'w') as file: + file.write(text) + " + + echo "---" + echo "## Base Case: Run without any profiling" + python demo.py + + echo "---" + echo "## Option 0: Original Usage" + python -m kernprof -l demo.py + python -m line_profiler -rmt demo.py.lprof + + echo "---" + echo "## Option 1: Enable profiler with the command line" + python demo.py --line-profile + + echo "---" + echo "## Option 1: Enable profiler with an environment variable" + LINE_PROFILE=1 python demo.py + + +An example with in-code enabling: + +.. code:: bash + + # In-code enabling + python -c "if 1: + import textwrap + text = textwrap.dedent( + ''' + from line_profiler import profile + profile.enable(output_prefix='customized') + + @profile + def fib(n): + a, b = 0, 1 + while a < n: + a, b = b, a + b + + fib(100) + ''' + ).strip() + with open('demo.py', 'w') as file: + file.write(text) + " + echo "## Configuration handled inside the script" + python demo.py + + +An example with in-code enabling and disabling: + +.. code:: bash + + # In-code enabling / disable + python -c "if 1: + import textwrap + text = textwrap.dedent( + ''' + from line_profiler import profile + + @profile + def func1(): + return list(range(100)) + + profile.enable(output_prefix='custom') + + @profile + def func2(): + return tuple(range(100)) + + profile.disable() + + @profile + def func3(): + return set(range(100)) + + profile.enable() + + @profile + def func4(): + return dict(zip(range(100), range(100))) + + print(type(func1())) + print(type(func2())) + print(type(func3())) + print(type(func4())) + ''' + ).strip() + with open('demo.py', 'w') as file: + file.write(text) + " + + echo "---" + echo "## Configuration handled inside the script" + python demo.py + python demo.py --line-profile +""" +from .line_profiler import LineProfiler +import sys +import os +import atexit + + +_FALSY_STRINGS = {'', '0', 'off', 'false', 'no'} + + +class GlobalProfiler: + """ + Manages a profiler that will output on interpreter exit. + + Attributes: + setup_config (Dict[str, List[str]]): + Determines how the implicit setup behaves by defining which + environment variables / command line flags to look for. + + output_prefix (str): + The prefix of any output files written. Should include + a part of a filename. Defaults to "profile_output". + + write_config (Dict[str, bool]): + Which outputs are enabled. All default to True. + Options are lprof, text, timestamped_text, and stdout. + + show_config (Dict[str, bool]): + Display configuration options. Some outputs force certain options. + (e.g. text always has details and is never rich). + + enabled (bool | None): + True if the profiler is enabled (i.e. if it will wrap a function + that it decorates with a real profiler). If None, then the value + defaults based on the ``setup_config``, :py:obj:`os.environ`, and + :py:obj:`sys.argv`. + + Example: + >>> from line_profiler.explicit_profiler import * # NOQA + >>> self = GlobalProfiler() + >>> # Setting the _profile attribute prevents atexit from running. + >>> self._profile = LineProfiler() + >>> # User can personalize the configuration + >>> self.show_config['details'] = True + >>> self.write_config['lprof'] = False + >>> self.write_config['text'] = False + >>> self.write_config['timestamped_text'] = False + >>> # Demo data: a function to profile + >>> def collatz(n): + >>> while n != 1: + >>> if n % 2 == 0: + >>> n = n // 2 + >>> else: + >>> n = 3 * n + 1 + >>> return n + >>> # Disabled by default, implicitly checks to auto-enable on first wrap + >>> assert self.enabled is None + >>> wrapped = self(collatz) + >>> assert self.enabled is False + >>> assert wrapped is collatz + >>> # Can explicitly enable + >>> self.enable() + >>> wrapped = self(collatz) + >>> assert self.enabled is True + >>> assert wrapped is not collatz + >>> wrapped(100) + >>> # Can explicitly request output + >>> self.show() + """ + + def __init__(self): + self.setup_config = { + 'environ_flags': ['LINE_PROFILE'], + 'cli_flags': ['--line-profile', '--line_profile'], + } + self.output_prefix = 'profile_output' + self._profile = None + self.enabled = None + + # Control which outputs will be written on exit + self.write_config = { + 'lprof': True, + 'text': True, + 'timestamped_text': True, + 'stdout': True, + } + + # Configuration for how output will be displayed + self.show_config = { + 'sort': 1, + 'stripzeros': 1, + 'rich': 1, + 'details': 0, + 'summarize': 1, + } + + def _kernprof_overwrite(self, profile): + """ + Kernprof will call this when it runs, so we can use its profile object + instead of our own. Note: when kernprof overwrites us we wont register + an atexit hook. This is what we want because kernprof wants us to use + another program to read its output file. + """ + self._profile = profile + self.enabled = True + + def _implicit_setup(self): + """ + Called once the first time the user decorates a function with + ``line_profiler.profile`` and they have not explicitly setup the global + profiling options. + """ + environ_flags = self.setup_config['environ_flags'] + cli_flags = self.setup_config['cli_flags'] + is_profiling = any(os.environ.get(f, '').lower() not in _FALSY_STRINGS + for f in environ_flags) + is_profiling |= any(f in sys.argv for f in cli_flags) + if is_profiling: + self.enable() + else: + self.disable() + + def enable(self, output_prefix=None): + """ + Explicitly enables global profiler and controls its settings. + """ + if self._profile is None: + # Try to only ever create one real LineProfiler object + atexit.register(self.show) + self._profile = LineProfiler() # type: ignore + + # The user can call this function more than once to update the final + # reporting or to re-enable the profiler after it a disable. + self.enabled = True + + if output_prefix is not None: + self.output_prefix = output_prefix + + def disable(self): + """ + Explicitly initialize and disable this global profiler. + """ + self.enabled = False + + def __call__(self, func): + """ + If the global profiler is enabled, decorate a function to start the + profiler on function entry and stop it on function exit. Otherwise + return the input. + + Args: + func (Callable): the function to profile + + Returns: + Callable: a potentially wrapped function + """ + if self.enabled is None: + # Force a setup if we haven't done it before. + self._implicit_setup() + if not self.enabled: + return func + return self._profile(func) + + def show(self): + """ + Write the managed profiler stats to enabled outputs. + + If the implicit setup triggered, then this will be called by atexit. + """ + import io + import pathlib + + srite_stdout = self.write_config['stdout'] + write_text = self.write_config['text'] + write_timestamped_text = self.write_config['timestamped_text'] + write_lprof = self.write_config['lprof'] + + if srite_stdout: + kwargs = self.show_config.copy() + self._profile.print_stats(**kwargs) + + if write_text or write_timestamped_text: + stream = io.StringIO() + # Text output always contains details, and cannot be rich. + text_kwargs = self.show_config.copy() + text_kwargs['rich'] = 0 + text_kwargs['details'] = 1 + self._profile.print_stats(stream=stream, **text_kwargs) + raw_text = stream.getvalue() + + if write_text: + txt_output_fpath1 = pathlib.Path(f'{self.output_prefix}.txt') + txt_output_fpath1.write_text(raw_text) + print('Wrote profile results to %s' % txt_output_fpath1) + + if write_timestamped_text: + from datetime import datetime as datetime_cls + now = datetime_cls.now() + timestamp = now.strftime('%Y-%m-%dT%H%M%S') + txt_output_fpath2 = pathlib.Path(f'{self.output_prefix}_{timestamp}.txt') + txt_output_fpath2.write_text(raw_text) + print('Wrote profile results to %s' % txt_output_fpath2) + + if write_lprof: + lprof_output_fpath = pathlib.Path(f'{self.output_prefix}.lprof') + self._profile.dump_stats(lprof_output_fpath) + print('Wrote profile results to %s' % lprof_output_fpath) + print('To view details run:') + print(sys.executable + ' -m line_profiler -rtmz ' + str(lprof_output_fpath)) + + +# Construct the global profiler. +# The first time it is called, it will be initialized. This is usually a +# NoOpProfiler unless the user requested the real one. +# NOTE: kernprof or the user may explicitly setup the global profiler. +profile = GlobalProfiler() diff --git a/line_profiler/explicit_profiler.pyi b/line_profiler/explicit_profiler.pyi new file mode 100644 index 00000000..8f402729 --- /dev/null +++ b/line_profiler/explicit_profiler.pyi @@ -0,0 +1,30 @@ +from typing import Callable +from _typeshed import Incomplete + + +class GlobalProfiler: + output_prefix: str + environ_flag: str + cli_flags: Incomplete + enabled: Incomplete + + def __init__(self) -> None: + ... + + def implicit_setup(self) -> None: + ... + + def enable(self, output_prefix: Incomplete | None = ...) -> None: + ... + + def disable(self) -> None: + ... + + def __call__(self, func: Callable) -> Callable: + ... + + def show(self) -> None: + ... + + +profile: Incomplete diff --git a/tests/test_explicit_profile.py b/tests/test_explicit_profile.py new file mode 100644 index 00000000..f260d0c4 --- /dev/null +++ b/tests/test_explicit_profile.py @@ -0,0 +1,258 @@ +import tempfile +import pathlib +import shutil +import sys +import os +import subprocess +import textwrap +from subprocess import PIPE + + +def _demo_explicit_profile_script(): + return textwrap.dedent( + ''' + from line_profiler import profile + + @profile + def fib(n): + a, b = 0, 1 + while a < n: + a, b = b, a + b + + fib(10) + ''').strip() + + +def test_explicit_profile_with_nothing(): + """ + Test that no profiling happens when we dont request it. + """ + temp_dpath = pathlib.Path(tempfile.mkdtemp()) + with ChDir(temp_dpath): + + script_fpath = pathlib.Path('script.py') + script_fpath.write_text(_demo_explicit_profile_script()) + + args = [sys.executable, os.fspath(script_fpath)] + proc = subprocess.run(args, stdout=PIPE, stderr=PIPE, + universal_newlines=True) + print(proc.stdout) + print(proc.stderr) + proc.check_returncode() + + assert not (temp_dpath / 'profile_output.txt').exists() + assert not (temp_dpath / 'profile_output.lprof').exists() + shutil.rmtree(temp_dpath) + + +def test_explicit_profile_with_environ_on(): + """ + Test that explicit profiling is enabled when we specify the LINE_PROFILE + enviornment variable. + """ + temp_dpath = pathlib.Path(tempfile.mkdtemp()) + env = os.environ.copy() + env['LINE_PROFILE'] = '1' + + with ChDir(temp_dpath): + + script_fpath = pathlib.Path('script.py') + script_fpath.write_text(_demo_explicit_profile_script()) + + args = [sys.executable, os.fspath(script_fpath)] + proc = subprocess.run(args, stdout=PIPE, stderr=PIPE, + env=env, + universal_newlines=True) + print(proc.stdout) + print(proc.stderr) + proc.check_returncode() + + assert (temp_dpath / 'profile_output.txt').exists() + assert (temp_dpath / 'profile_output.lprof').exists() + shutil.rmtree(temp_dpath) + + +def test_explicit_profile_with_environ_off(): + """ + When LINE_PROFILE is falsy, profiling should not run. + """ + temp_dpath = pathlib.Path(tempfile.mkdtemp()) + env = os.environ.copy() + env['LINE_PROFILE'] = '0' + + with ChDir(temp_dpath): + + script_fpath = pathlib.Path('script.py') + script_fpath.write_text(_demo_explicit_profile_script()) + + args = [sys.executable, os.fspath(script_fpath)] + proc = subprocess.run(args, stdout=PIPE, stderr=PIPE, + env=env, + universal_newlines=True) + print(proc.stdout) + print(proc.stderr) + proc.check_returncode() + + assert not (temp_dpath / 'profile_output.txt').exists() + assert not (temp_dpath / 'profile_output.lprof').exists() + shutil.rmtree(temp_dpath) + + +def test_explicit_profile_with_cmdline(): + """ + Test that explicit profiling is enabled when we specify the --line-profile + command line flag. + + xdoctest ~/code/line_profiler/tests/test_explicit_profile.py test_explicit_profile_with_environ + """ + temp_dpath = pathlib.Path(tempfile.mkdtemp()) + + with ChDir(temp_dpath): + + script_fpath = pathlib.Path('script.py') + script_fpath.write_text(_demo_explicit_profile_script()) + + args = [sys.executable, os.fspath(script_fpath), '--line-profile'] + print(f'args={args}') + proc = subprocess.run(args, stdout=PIPE, stderr=PIPE, + universal_newlines=True) + print(proc.stdout) + print(proc.stderr) + proc.check_returncode() + + assert (temp_dpath / 'profile_output.txt').exists() + assert (temp_dpath / 'profile_output.lprof').exists() + shutil.rmtree(temp_dpath) + + +def test_explicit_profile_with_kernprof(): + """ + Test that explicit profiling works when using kernprof. In this case + we should get as many output files. + """ + temp_dpath = pathlib.Path(tempfile.mkdtemp()) + + with ChDir(temp_dpath): + + script_fpath = pathlib.Path('script.py') + script_fpath.write_text(_demo_explicit_profile_script()) + + args = [sys.executable, '-m', 'kernprof', '-l', os.fspath(script_fpath)] + print(f'args={args}') + proc = subprocess.run(args, stdout=PIPE, stderr=PIPE, + universal_newlines=True) + print(proc.stdout) + print(proc.stderr) + proc.check_returncode() + + assert not (temp_dpath / 'profile_output.txt').exists() + assert (temp_dpath / 'script.py.lprof').exists() + shutil.rmtree(temp_dpath) + + +def test_explicit_profile_with_in_code_enable(): + """ + Test that the user can enable the profiler explicitly from within their + code. + """ + temp_dpath = pathlib.Path(tempfile.mkdtemp()) + + code = textwrap.dedent( + ''' + from line_profiler import profile + + @profile + def func1(a): + return a + 1 + + profile.enable(output_prefix='custom_output') + + @profile + def func2(a): + return a + 1 + + profile.disable() + + @profile + def func3(a): + return a + 1 + + profile.enable() + + @profile + def func4(a): + return a + 1 + + func1(1) + func2(1) + func3(1) + func4(1) + ''').strip() + with ChDir(temp_dpath): + + script_fpath = pathlib.Path('script.py') + script_fpath.write_text(code) + + args = [sys.executable, os.fspath(script_fpath)] + proc = subprocess.run(args, stdout=PIPE, stderr=PIPE, + universal_newlines=True) + print(proc.stdout) + print(proc.stderr) + proc.check_returncode() + + output_fpath = (temp_dpath / 'custom_output.txt') + raw_output = output_fpath.read_text() + + assert 'func1' not in raw_output + assert 'func2' in raw_output + assert 'func3' not in raw_output + assert 'func4' in raw_output + + assert output_fpath.exists() + assert (temp_dpath / 'custom_output.lprof').exists() + shutil.rmtree(temp_dpath) + + +class ChDir: + """ + Context manager that changes the current working directory and then + returns you to where you were. + + This is nearly the same as the stdlib :func:`contextlib.chdir`, with the + exception that it will do nothing if the input path is None (i.e. the user + did not want to change directories). + + Args: + dpath (str | PathLike | None): + The new directory to work in. + If None, then the context manager is disabled. + + SeeAlso: + :func:`contextlib.chdir` + """ + def __init__(self, dpath): + self._context_dpath = dpath + self._orig_dpath = None + + def __enter__(self): + """ + Returns: + ChDir: self + """ + if self._context_dpath is not None: + self._orig_dpath = os.getcwd() + os.chdir(self._context_dpath) + return self + + def __exit__(self, ex_type, ex_value, ex_traceback): + """ + Args: + ex_type (Type[BaseException] | None): + ex_value (BaseException | None): + ex_traceback (TracebackType | None): + + Returns: + bool | None + """ + if self._context_dpath is not None: + os.chdir(self._orig_dpath)