diff --git a/.gitignore b/.gitignore index 5202bcf..56413ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# OSx rubbish +.DS_Store + # Byte-compiled __pycache__/ *.py[cod] @@ -12,9 +15,11 @@ docs/_build/ .venv env/ venv/ +.idea # Poetry lock file poetry.lock +.python-version # Unit test / coverage reports htmlcov/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7b9b97..8eb2e2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,45 @@ # Contribution guide for developers +## Development + +### Setting up the environment + +To get started, you will need to setup the development environment. This goes +through two main steps, namely [(1) Installing `pyscript-cli` dependencies](#install-pyscript-cli-dependencies) +and [(2) Installing `pyscript-cli` Dev dependencies](#install-the-development-dependencies). + +### Install `pyscript-cli` dependencies + +`pyscript-cli` requires [`poetry`](https://python-poetry.org/) to manage dependencies +and setup the environment. + +Therefore, the first thing to do is to make sure that you have `poetry` installed +in your current Python environment. Please refer to the official `poetry` +[documentation](https://python-poetry.org/docs/master/#installation) for installation +instructions. + +To install `pyscript-cli` dependencies, you will need to run the following command from the project root: + +```shell +poetry install +``` + +### Install the development dependencies + +There are a few extra dependencies that are solely required for development. +To install these packages, you will need to run the following command from the project root: + +```shell +poetry install --with dev-dependencies +``` + +Once all the dependencies are installed, you will only need to setup the git hooks +via [`pre-commit`](https://pre-commit.com/): + +```shell +pre-commit install +``` + ## Documentation ### Install the documentation dependencies diff --git a/README.md b/README.md index a4d048b..75e3ba0 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,18 @@ $ pip install pyscript ## Usage -### Embed a Python script into a PyScript HTML file +### Embed Python code into a PyScript HTML file ```shell $ pyscript wrap ``` +Alternatively you could also use a **Jupyter notebook** as input file: + +```shell +$ python wrap +``` + This will generate a file called `` by default. This can be overwritten with the `-o` or `--output` option: diff --git a/pyproject.toml b/pyproject.toml index 522f8bf..be6803c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,10 @@ [build-system] -requires = ["poetry-core>=1.0.0"] +requires = ["poetry-core>=1.1.0b3"] build-backend = "poetry.core.masonry.api" +[tool.isort] +profile = "black" + [tool.poetry] name = "pyscript" version = "0.2.4" @@ -32,12 +35,17 @@ sphinx-autobuild = {version = "^2021.3.14", optional = true} sphinx-autodoc-typehints = {version = "^1.19.2", optional = true} myst-parser = {version = "^0.18.0", optional = true} pydata-sphinx-theme = {version = "^0.9.0", optional = true} +requests = "^2.28.1" +six = "^1.16.0" +nbconvert = "^7.0.0" +ipykernel = "^6.15.2" [tool.poetry.dev-dependencies] coverage = "^6.3.2" mypy = "^0.950" pytest = "^7.1.2" types-toml = "^0.10.8" +pre-commit = "^2.20.0" [tool.poetry.extras] docs = [ diff --git a/src/pyscript/_generator.py b/src/pyscript/_generator.py index 329ad62..31fbe85 100644 --- a/src/pyscript/_generator.py +++ b/src/pyscript/_generator.py @@ -5,21 +5,65 @@ import jinja2 import toml -_env = jinja2.Environment(loader=jinja2.PackageLoader("pyscript")) +from ._node_parser import FinderResult, _convert_notebook, find_imports -def string_to_html(input_str: str, title: str, output_path: Path) -> None: +class UnsupportedFileType(Exception): + pass + + +_env = jinja2.Environment( + loader=jinja2.PackageLoader("pyscript"), trim_blocks=True, lstrip_blocks=True +) + + +def string_to_html( + input_str: str, title: str, output_path: Path, pyenv: FinderResult = None +) -> None: """Write a Python script string to an HTML file template.""" template = _env.get_template("basic.html") + if pyenv is not None: + modules, paths = pyenv.packages, pyenv.paths + else: + modules = paths = () with output_path.open("w") as fp: - fp.write(template.render(code=input_str, title=title)) + fp.write( + template.render(code=input_str, title=title, modules=modules, paths=paths) + ) -def file_to_html(input_path: Path, title: str, output_path: Optional[Path]) -> None: - """Write a Python script string to an HTML file template.""" +def file_to_html( + input_path: Path, title: str, output_path: Optional[Path] +) -> FinderResult: + """Write a Python script string to an HTML file template. + + Warnings will be returned when scanning for environment, if any. + """ output_path = output_path or input_path.with_suffix(".html") - with input_path.open("r") as fp: - string_to_html(fp.read(), title, output_path) + + fname, extension = input_path.name, input_path.suffix + if extension == ".py": + with open(input_path, "rt") as f: + source = f.read() + + elif extension == ".ipynb": + try: + import nbconvert # noqa + except ImportError as e: # pragma no cover + raise ImportError( + "Please install nbconvert to serve Jupyter Notebooks." + ) from e + else: + source = _convert_notebook(input_path) + + else: + raise UnsupportedFileType( + "{} is neither a script (.py) nor a notebook (.ipynb)".format(fname) + ) + + import_results = find_imports(source, input_path) + string_to_html(source, title, output_path, pyenv=import_results) + return import_results def create_project( diff --git a/src/pyscript/_node_parser.py b/src/pyscript/_node_parser.py new file mode 100644 index 0000000..90fb48e --- /dev/null +++ b/src/pyscript/_node_parser.py @@ -0,0 +1,194 @@ +""" +ast-based parser to gather modules/package dependencies of a Python module. +Code adapted from the find-imports project, currently in graveyard archive. +""" +from __future__ import annotations + +import ast +import os +import pkgutil +from collections import defaultdict +from itertools import chain, filterfalse +from pathlib import Path + +from ._supported_packages import PACKAGE_RENAMES, PYODIDE_PACKAGES, STANDARD_LIBRARY + + +class NamespaceInfo: + def __init__(self, source_fpath: Path) -> None: + # expanding base_folder to absolute as pkgutils. + # FileFinder will do so - easier for later purging + self.base_folder = str(source_fpath.parent.absolute()) + self.source_mod_name = source_fpath.stem + self._collect() + # storing this as it will be useful for multiple lookups + self._all_namespace = set(chain(self.modules, self._packages)) + + def _collect(self): + iter_modules_paths = [self.base_folder] + for root, dirs, files in os.walk(self.base_folder): + for dirname in dirs: + iter_modules_paths.append(os.path.join(root, dirname)) + + # need to consume generator as I will iterate + # two times for _packages, and modules + pkg_mods = tuple(pkgutil.iter_modules(iter_modules_paths)) + modules = map( + lambda mi: os.path.join(mi.module_finder.path, mi.name), + filterfalse( + lambda mi: mi.ispkg or mi.name == self.source_mod_name, pkg_mods + ), + ) + _packages = map( + lambda mi: os.path.join(mi.module_finder.path, mi.name), + filter(lambda mi: mi.ispkg, pkg_mods), + ) + self.modules = set(map(self._dotted_path, modules)) + self._packages = set(map(self._dotted_path, _packages)) + + def _dotted_path(self, p: str): + p = p.replace(self.base_folder, "").replace(os.path.sep, ".") + if p.startswith("."): + p = p[1:] + return p + + def __contains__(self, item: str) -> bool: + return item in self._all_namespace + + def __str__(self) -> str: + return ( + f"NameSpace info for {self.base_folder} \n\t " + f"Modules: {self.modules} \n\t Packages: {self._packages}" + ) + + def __repr__(self) -> str: + return str(self) + + +class FinderResult: + def __init__(self) -> None: + self._packages: set[str] = set() + self._locals: set[str] = set() + self._unsupported: defaultdict[str, set] = defaultdict(set) + + def add_package(self, pkg_name: str) -> None: + self._packages.add(pkg_name) + + def add_locals(self, pkg_name: str) -> None: + self._locals.add(pkg_name) + + def add_unsupported_external_package(self, pkg_name: str) -> None: + self._unsupported["external"].add(pkg_name) + + def add_unsupported_local_package(self, pkg_name: str) -> None: + self._unsupported["local"].add(pkg_name) + + @property + def has_warnings(self): + return len(self._unsupported) > 0 + + @property + def unsupported_packages(self): + return self._unsupported["external"] + + @property + def unsupported_paths(self): + return self._unsupported["local"] + + @property + def packages(self): + return self._packages + + @property + def paths(self): + pyenv_paths = map( + lambda l: "{}.py".format(l.replace(".", os.path.sep)), self._locals + ) + return set(pyenv_paths) + + +# https://stackoverflow.com/a/58847554 +class ModuleFinder(ast.NodeVisitor): + def __init__(self, context: NamespaceInfo, *args, **kwargs): + # list of all potential local imports + self.context = context + self.results = FinderResult() + super().__init__(*args, **kwargs) + + def visit_Import(self, node): + for name in node.names: + # need to check for absolute module import here as they won't work in PyScript + # absolute package imports will be found later in _import_name + if len(name.name.split(".")) > 1 and name.name in self.context: + self.results.add_unsupported_local_package(name.name) + else: + self._import_name(name.name) + + def visit_ImportFrom(self, node): + # if node.module is missing it's a "from . import ..." statement + # if level > 0 it's a "from .submodule import ..." statement + if node.module is not None: + self._import_name(node.module) + + def _import_name(self, imported): + if imported in self.context: + if imported not in self.context._packages: + self.results.add_locals(imported) + else: + self.results.add_unsupported_local_package(imported) + else: + imported = imported.split(".")[0] + pkg_name = PACKAGE_RENAMES.get(imported, imported) + if pkg_name not in STANDARD_LIBRARY: + if pkg_name in PYODIDE_PACKAGES: + self.results.add_package(pkg_name) + else: + self.results.add_unsupported_external_package(pkg_name) + + +def _find_modules(source: str, source_fpath: Path): + fname = source_fpath.name + # importing all local modules from source_fpath + namespace_info = NamespaceInfo(source_fpath=source_fpath) + # passing mode='exec' just in case defaults will change in the future + nodes = ast.parse(source, fname, mode="exec") + + finder = ModuleFinder(context=namespace_info) + finder.visit(nodes) + return finder.results + + +def _convert_notebook(source_fpath: Path) -> str: + from nbconvert import ScriptExporter + + exporter = ScriptExporter() + source, _ = exporter.from_filename(source_fpath) + + return source + + +def find_imports( + source: str, + source_fpath: Path, +) -> FinderResult: + """ + Parse the input source, and returns its dependencies, as organised in + the sets of external _packages, and local modules, respectively. + Any modules or package with the same name found in the local + + Parameters + ---------- + source : str + Python source code to parse + source_fpath : Path + Path to the input Python module to parse + + Returns + ------- + FinderResult + Return the results of parsing as a `FinderResult` instance. + This instance provides reference to packages and paths to + include in the py-env, as well as any unsupported import. + """ + + return _find_modules(source, source_fpath) diff --git a/src/pyscript/_supported_packages.py b/src/pyscript/_supported_packages.py new file mode 100644 index 0000000..3efcf46 --- /dev/null +++ b/src/pyscript/_supported_packages.py @@ -0,0 +1,356 @@ +# extending find-import package names with Pyodide supported packages + +PACKAGE_RENAMES = { + "sklearn": "scikit-learn", + "umap": "umap-learn", # has numba dep. so not yet supported in PyScript/Pyodide +} + +# taken from https://pyodide.org/en/stable/usage/packages-in-pyodide.html +PYODIDE_PACKAGES = { + "asciitree", + "astropy", + "atomicwrites", + "attrs", + "autograd", + "beautifulsoup4", + "biopython", + "bitarray", + "bleach", + "bokeh", + "boost-histogram", + "brotli", + "certifi", + "cffi", + "cffi_example", + "cftime", + "CLAPACK", + "cloudpickle", + "cmyt", + "colorspacious", + "cryptography", + "cssselect", + "cycler", + "cytoolz", + "decorator", + "demes", + "distlib", + "docutils", + "fonttools", + "freesasa", + "future", + "galpy", + "geos", + "gmpy2", + "gsw", + "h5py", + "html5lib", + "imageio", + "iniconfig", + "jedi", + "Jinja2", + "joblib", + "jsonschema", + "kiwisolver", + "lazy-object-proxy", + "libmagic", + "logbook", + "lxml", + "MarkupSafe", + "matplotlib", + "micropip", + "mne", + "more-itertools", + "mpmath", + "msgpack", + "msprime", + "networkx", + "newick", + "nlopt", + "nltk", + "nose", + "numcodecs", + "numpy", + "opencv-python", + "openssl", + "optlang", + "packaging", + "pandas", + "parso", + "patsy", + "Pillow", + "pkgconfig", + "pluggy", + "py", + "pyb2d", + "pyclipper", + "pycparser", + "pydantic", + "pyerfa", + "Pygments", + "pyparsing", + "pyproj", + "pyrsistent", + "pytest", + "pytest-benchmark", + "python-dateutil", + "python-magic", + "python-sat", + "python_solvespace", + "pytz", + "pywavelets", + "pyyaml", + "rebound", + "reboundx", + "regex", + "retrying", + "RobotRaconteur", + "ruamel", + "scikit-image", + "scikit-learn", + "scipy", + "setuptools", + "shapely", + "six", + "soupsieve", + "sparseqr", + "sqlalchemy", + "ssl", + "statsmodels", + "suitesparse", + "svgwrite", + "swiglpk", + "sympy", + "tblib", + "termcolor", + "threadpoolctl", + "tomli", + "tomli-w", + "toolz", + "tqdm", + "traits", + "tskit", + "typing-extensions", + "uncertainties", + "unyt", + "webencodings", + "wrapt", + "xarray", + "xgboost", + "xlrd", + "yt", + "zarr", +} + +# taken from https://docs.python.org/3/py-modindex.html +STANDARD_LIBRARY = { + "__future__", + "__main__", + "_dummy_thread", + "_thread", + "abc", + "aifc", + "argparse", + "array", + "ast", + "asynchat", + "asyncio", + "asyncore", + "atexit", + "audioop", + "base64", + "bdb", + "binascii", + "binhex", + "bisect", + "builtins", + "bz2", + "calendar", + "cgi", + "cgitb", + "chunk", + "cmath", + "cmd", + "code", + "codecs", + "codeop", + "collections", + "colorsys", + "compileall", + "concurrent", + "configparser", + "contextlib", + "contextvars", + "copy", + "copyreg", + "cProfile", + "crypt", + "csv", + "ctypes", + "curses", + "dataclasses", + "datetime", + "dbm", + "decimal", + "difflib", + "dis", + "distutils", + "doctest", + "dummy_threading", + "email", + "encodings", + "ensurepip", + "enum", + "errno", + "faulthandler", + "fcntl", + "filecmp", + "fileinput", + "fnmatch", + "formatter", + "fractions", + "ftplib", + "functools", + "gc", + "getopt", + "getpass", + "gettext", + "glob", + "grp", + "gzip", + "hashlib", + "heapq", + "hmac", + "html", + "http", + "imaplib", + "imghdr", + "imp", + "importlib", + "inspect", + "io", + "ipaddress", + "itertools", + "json", + "keyword", + "lib2to3", + "linecache", + "locale", + "logging", + "lzma", + "mailbox", + "mailcap", + "marshal", + "math", + "mimetypes", + "mmap", + "modulefinder", + "msilib", + "msvcrt", + "multiprocessing", + "netrc", + "nis", + "nntplib", + "numbers", + "operator", + "optparse", + "os", + "ossaudiodev", + "parser", + "pathlib", + "pdb", + "pickle", + "pickletools", + "pipes", + "pkgutil", + "platform", + "plistlib", + "poplib", + "posix", + "pprint", + "profile", + "pstats", + "pty", + "pwd", + "py_compile", + "pyclbr", + "pydoc", + "queue", + "quopri", + "random", + "re", + "readline", + "reprlib", + "resource", + "rlcompleter", + "runpy", + "sched", + "secrets", + "select", + "selectors", + "shelve", + "shlex", + "shutil", + "signal", + "site", + "smtpd", + "smtplib", + "sndhdr", + "socket", + "socketserver", + "spwd", + "sqlite3", + "ssl", + "stat", + "statistics", + "string", + "stringprep", + "struct", + "subprocess", + "sunau", + "symbol", + "symtable", + "sys", + "sysconfig", + "syslog", + "tabnanny", + "tarfile", + "telnetlib", + "tempfile", + "termios", + "test", + "textwrap", + "threading", + "time", + "timeit", + "tkinter", + "token", + "tokenize", + "trace", + "traceback", + "tracemalloc", + "tty", + "turtle", + "turtledemo", + "types", + "typing", + "unicodedata", + "unittest", + "urllib", + "uu", + "uuid", + "venv", + "warnings", + "wave", + "weakref", + "webbrowser", + "winreg", + "winsound", + "wsgiref", + "xdrlib", + "xml", + "xmlrpc", + "zipapp", + "zipfile", + "zipimport", + "zlib", +} diff --git a/src/pyscript/cli.py b/src/pyscript/cli.py index 398f381..2f54bbd 100644 --- a/src/pyscript/cli.py +++ b/src/pyscript/cli.py @@ -31,6 +31,16 @@ def __init__(self, msg: str, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) +class Warning(typer.Exit): + """Warning with a consistent error message. + (Similar in essence to the Abort class - it only changes font color to yellow!) + """ + + def __init__(self, msg: str, *args: Any, **kwargs: Any): + console.print(msg, style="yellow") + super().__init__(*args, **kwargs) + + @app.callback(invoke_without_command=True, no_args_is_help=True) def main( version: Optional[bool] = typer.Option( @@ -47,6 +57,7 @@ def main( pm = PluginManager("pyscript") + pm.add_hookspecs(hookspecs) for modname in DEFAULT_PLUGINS: importspec = f"pyscript.plugins.{modname}" diff --git a/src/pyscript/plugins/wrap.py b/src/pyscript/plugins/wrap.py index e73190d..b06d9fe 100644 --- a/src/pyscript/plugins/wrap.py +++ b/src/pyscript/plugins/wrap.py @@ -52,9 +52,28 @@ def wrap( else: raise cli.Abort("Must provide an output file or use `--show` option") if input_file is not None: - file_to_html(input_file, title, output) + parsing_res = file_to_html(input_file, title, output) + if parsing_res.has_warnings: + warn_msg_template = ( + "WARNING: The input file has some dependencies that are not currently supported " + "in PyScript:{list}\n As a result, the wrapped code may not work." + ) + unsupported_deps = "" + if parsing_res.unsupported_packages: + unsupported_deps += "\n\t -" + "\n\t -".join( + parsing_res.unsupported_packages + ) + + if parsing_res.unsupported_paths: + unsupported_deps += "\n\t -" + "\n\t -".join( + parsing_res.unsupported_paths + ) + + raise cli.Warning(msg=warn_msg_template.format(list=unsupported_deps)) + if command: string_to_html(command, title, output) + if output: if show: console.print("Opening in web browser!") diff --git a/src/pyscript/templates/basic.html b/src/pyscript/templates/basic.html index 16c1a3a..8cd3049 100644 --- a/src/pyscript/templates/basic.html +++ b/src/pyscript/templates/basic.html @@ -4,6 +4,21 @@ {{ title }} + + {% if modules or paths %} + + {% for module in modules %} + - {{module}} + {% endfor %} + {% if paths %} + - paths: + {% for path in paths %} + - {{path}} + {% endfor %} + {% endif %} + + {% endif %} + diff --git a/tests/test_cli.py b/tests/test_cli.py index 115bf94..055fd19 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,8 @@ from __future__ import annotations import unittest.mock +from collections.abc import Sequence +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Callable, Optional @@ -17,6 +19,19 @@ CLIInvoker = Callable[[VarArg(str)], Result] +@dataclass +class Dependency: + """Data class to encapsulate code dependency dynamically injected in code under test. + This class is a general abstraction over both external and local module dependency.""" + + import_line: str # Import line to inject in code under test + filename: str | None = ( + None # filename: to be used for tmp local modules, i.e. paths + ) + code: str | None = None # code in the tmp local modules + inject: str | None = None # any code to inject in code under test + + @pytest.fixture() def invoke_cli(tmp_path: Path, monkeypatch: "MonkeyPatch") -> CLIInvoker: """Returns a function, which can be used to call the CLI from within a temporary directory.""" @@ -30,6 +45,35 @@ def f(*args: str) -> Result: return f +@pytest.fixture() +def py_code() -> str: + pycode = """ +from math import sqrt + + +def square_root(number: int) -> float: + return sqrt(number) + + +print(f"Square root of 25 is {square_root(25)}") + """ + return pycode + + +@pytest.fixture() +def py_code_with_import() -> str: + pycode = """ +import numpy as np + +from sklearn.datasets import load_iris + +SEED = 12345 +np.random.seed(SEED) +iris = load_iris() + """ + return pycode + + def test_version() -> None: runner = CliRunner() result = runner.invoke(app, "--version") @@ -69,14 +113,14 @@ def test_wrap_abort(invoke_cli: CLIInvoker, wrap_args: tuple[str]): def test_wrap_file( invoke_cli: CLIInvoker, tmp_path: Path, + py_code: str, wrap_args: tuple[str], expected_output_filename: str, ) -> None: - command = 'print("Hello World!")' input_file = tmp_path / "hello.py" with input_file.open("w") as fp: - fp.write(command) + fp.write(py_code) result = invoke_cli("wrap", str(input_file), *wrap_args) assert result.exit_code == 0 @@ -87,7 +131,110 @@ def test_wrap_file( with expected_html_path.open() as fp: html_text = fp.read() - assert f"\n{command}\n" in html_text + assert f"\n{py_code}\n" in html_text + + +@pytest.mark.parametrize( + "dependencies, expected_warnings", + [ + (None, False), + ( + ( + Dependency( + filename="preprocessor.py", + code="from sklearn.preprocessing import StandardScaler\n " + "scaler = StandardScaler()", + import_line="from preprocessor import scaler", + inject="scaler.fit(iris.data)", + ), + ), + False, + ), + ( + ( + Dependency( + filename="preprocessor.py", + code="from sklearn.preprocessing import StandardScaler\n " + "scaler = StandardScaler()", + import_line="import preprocessor", + inject="preprocessor.scaler.fit(iris.data)", + ), + ), + False, + ), + ( + ( + Dependency( + import_line="from umap import UMAP", + ), + ), + True, + ), + ], +) +def test_wrap_file_with_imports( + invoke_cli: CLIInvoker, + tmp_path: Path, + py_code_with_import, + dependencies: Optional[Sequence[Dependency]], + expected_warnings: bool, +) -> None: + + # A. Inject code dependencies, if any + if dependencies: + # inject dependency + for dep in dependencies: + if dep.filename and dep.code: + tmp_mod = tmp_path / dep.filename + with tmp_mod.open("w") as fp: + fp.write(dep.code) + # inject import line in code under test + py_code_with_import = dep.import_line + py_code_with_import + if dep.inject: + py_code_with_import += ( + "\n" + dep.inject + ) # append code to inject to code under test + + input_file = tmp_path / "ml.py" + with input_file.open("w") as fp: + fp.write(py_code_with_import) + + result = invoke_cli("wrap", str(input_file)) + assert result.exit_code == 0 + if expected_warnings: + assert "WARNING" in result.stdout + else: + assert "WARNING" not in result.stdout + + expected_html_path = tmp_path / "ml.html" + assert expected_html_path.exists() + + with expected_html_path.open() as fp: + html_text = fp.read() + + assert f"\n{py_code_with_import}\n" in html_text + assert "" in html_text and "" in html_text + + # get pyenv_tag content + pyenv_tag = html_text[html_text.find("") : html_text.find("")] + pyscript_tag = html_text[ + html_text.find("") : html_text.find("") + ] + + # default py-env dependency in fixture code + assert "numpy" in pyenv_tag + assert "scikit-learn" in pyenv_tag + + if dependencies: + for dep in dependencies: + if dep.filename: + assert "paths" in pyenv_tag + assert dep.filename in pyenv_tag + assert dep.import_line in py_code_with_import + assert dep.import_line in pyscript_tag + if dep.inject: + assert dep.inject in pyscript_tag + assert dep.inject in py_code_with_import @pytest.mark.parametrize( @@ -101,6 +248,7 @@ def test_wrap_file( def test_wrap_show( invoke_cli: CLIInvoker, tmp_path: Path, + py_code: str, input_filename: Optional[str], additional_args: tuple[str, ...], expected_output_filename: Optional[str], @@ -112,7 +260,7 @@ def test_wrap_show( if input_filename: input_file = tmp_path / input_filename with input_file.open("w") as fp: - fp.write('print("Hello World!")') + fp.write(py_code) args = (str(input_file), *additional_args) else: args = additional_args @@ -147,12 +295,13 @@ def test_wrap_show( ) def test_wrap_title( invoke_cli: CLIInvoker, + py_code: str, title: Optional[str], expected_title: str, tmp_path: Path, ) -> None: - command = 'print("Hello World!")' - args = ["wrap", "-c", command, "-o", "output.html"] + command = py_code + args = ["wrap", "-c", py_code, "-o", "output.html"] if title is not None: args.extend(["--title", title]) result = invoke_cli(*args)