from __future__ import annotations
import enum
import inspect
import json
import platform
import re
try:
import readline
readline_available = True
# probably Mac
except ImportError:
readline_available = False
import sys
import traceback
import warnings
from io import StringIO
from pathlib import Path
from types import ModuleType
import finesse
from finesse import is_interactive
[docs]class SourceType(enum.Enum):
INTERACTIVE = "Interactive"
SCRIPT = "Script"
REPL = "REPL"
STDIN = "stdin"
[docs]def get_package_versions() -> str:
"""Report all currently imported package version by looping over :mod:`sys.modules`
and looking for '__version__' attributes. Explicitly avoids calling into conda/pip
since there are too many package managers to accommodate for.
Returns
-------
str
list of <package> == <version> for every package
"""
versions = ""
for mod in sys.modules.values():
if isinstance(mod, ModuleType):
if not any(char in mod.__name__ for char in (".", "/")) and hasattr(
mod, "__version__"
):
versions += f"{mod.__name__} == {mod.__version__}\n"
return versions
[docs]def get_source() -> str:
"""Get source of the '__main__' module. Supports Ipython (Jupyter Notebook, VSCode),
interactive interpreter and regular python modules.
Returns
-------
str
Source code
"""
main = sys.modules["__main__"]
source_type = get_source_type()
if source_type == SourceType.INTERACTIVE:
# Undocumented module attributes that might store the file path
for file_attr in ("__file__", "__vsc_ipynb_file__", "__session__"):
if source_fn := getattr(main, file_attr, False):
source_fn = Path(str(source_fn))
if source_fn.suffix == ".ipynb":
with open(source_fn, "r") as f:
return ipynb_to_md(json.load(f))
# otherwise concatenate lines from code cells (without markdown cells)
source = "\n".join(main.In)
# interactive interpreter: no distinction in history file between different sessions
elif source_type == SourceType.REPL:
warnings.warn(
"Using last 20 commands to generate bug report from interactive interpreter",
stacklevel=1,
)
if not readline_available:
warnings.warn(
"Can not read REPL history!",
stacklevel=1,
)
return ""
source = ""
hist_length = readline.get_current_history_length()
n_lines = min(hist_length, 20)
for i in range(hist_length - n_lines, hist_length):
source += str(readline.get_history_item(i)) + "\n"
# normal .py file
elif source_type == SourceType.SCRIPT:
source = inspect.getsource(main)
elif source_type == SourceType.STDIN:
# Should maybe be an exception, but we don't really want to raise exceptions
# in code meant to handle exceptions
warnings.warn(
RuntimeWarning("Can not get source when passing python code via stdin"),
stacklevel=2,
)
source = ""
else:
raise ValueError(f"Unknown source type {source_type}")
return source.strip()
[docs]def get_source_type() -> SourceType:
"""Type of source for the python code currently being executed.
Returns
-------
SourceType
Interactive environment (jupyter), terminal REPL or plain python script
"""
if is_interactive():
return SourceType.INTERACTIVE
elif not hasattr(sys.modules["__main__"], "__file__"):
return SourceType.REPL
elif sys.modules["__main__"].__file__ == sys.stdin.name:
return SourceType.STDIN
else:
return SourceType.SCRIPT
[docs]def ipynb_to_md(ipynb: dict) -> str:
"""Converts notebook json object to markdown. Extracts markdown cells as raw text
and code blocks wrapped in a python code block.
Parameters
----------
ipynb : dict
notebook json dict
Returns
-------
str
Markdown representing notebook
"""
md = ""
for cell in ipynb["cells"]:
if cell["cell_type"] == "code":
lang = "python"
elif cell["cell_type"] == "markdown":
lang = "markdown"
source = "".join(cell["source"])
md += wrap_block(source, lang=lang)
return md
[docs]def wrap_block(code: str, lang: str = "python") -> str:
"""Wraps a string in a markdown code block like.
```python
print('foo')
```
Parameters
----------
code : str
code to wrap
lang : str, optional
language of code, by default "python"
Returns
-------
str
Markdown code block
"""
return f"```{lang}\n{code}\n```\n"
def bug_report(
title: str | None = None,
file: str | Path | None = None,
include_source: bool = False,
):
"""Generate a markdown bug report, suitable for copy-pasting into chatrooms or
GitLab issues. Contains the source code, the triggered exception (if any) and
machine and python environment information.
Parameters
----------
title : str | None, optional
Title to insert on top of markdown, by default None
file : str | Path | None, optional
Whether to write the report to file. Will silently overwrite existing files,
by default None
include_source : bool, optional
Wether to include the source code that caused the exception (the contents of the
Jupyter notebook or Python script file) into the bug report. Be careful when
including source with proprietary/confidential information source in bug reports
shared in public spaces like Gitlab or the Matrix channel. Defaults to False
"""
# 4 spaces is equal to a code block in gitlab markdown!
report = f"""\
# {title if title else 'Finesse3 bug report'}
## Environment
- **Finesse version:** `{finesse.__version__}`
- **Python version:** `{sys.version}`
- **Platform:** `{platform.system()} {platform.machine()}`
## Entry point
`{sys.executable}`
{get_formatted_argv()}
{get_formatted_source() if include_source else ""}
## Stack trace
{wrap_block(get_formatted_traceback(), lang="text")}
## Package versions
{wrap_block(get_package_versions(), lang="text")}"""
# remove excessive empty lines due to removed sections
report = re.sub(r"\n{2,}", repl=r"\n\n", string=report)
if file:
file = Path(file)
file.write_text(report)
print(f"Bug report written to {Path.cwd() / file}\n")
return report