From 55e0d00395e6256ecf05b85fc3907b1dbcb93bc1 Mon Sep 17 00:00:00 2001 From: msftcangoblowme Date: Thu, 12 Dec 2024 04:34:29 +0000 Subject: [PATCH] sphobjinv-textconv - feat: add sphobjinv-textconv entrypoint (#295) --- .coveragerc | 1 + conftest.py | 122 ++++++- doc/source/cli/git_diff.rst | 166 ++++++++++ .../cli/implementation/core-textconv.rst | 7 + doc/source/cli/implementation/index.rst | 1 + doc/source/cli/textconv.rst | 112 +++++++ doc/source/conf.py | 6 + doc/source/index.rst | 3 +- pyproject.toml | 2 + src/sphobjinv/cli/core_textconv.py | 304 ++++++++++++++++++ src/sphobjinv/cli/parser.py | 102 ++++++ .../resource/objects_attrs_plus_one_entry.inv | Bin 0 -> 1446 bytes .../resource/objects_attrs_plus_one_entry.txt | 134 ++++++++ tests/test_api_good.py | 8 + tests/test_api_good_nonlocal.py | 4 + tests/test_cli_textconv.py | 298 +++++++++++++++++ tests/test_cli_textconv_nonlocal.py | 113 +++++++ 17 files changed, 1372 insertions(+), 11 deletions(-) create mode 100644 doc/source/cli/git_diff.rst create mode 100644 doc/source/cli/implementation/core-textconv.rst create mode 100644 doc/source/cli/textconv.rst create mode 100644 src/sphobjinv/cli/core_textconv.py create mode 100644 tests/resource/objects_attrs_plus_one_entry.inv create mode 100644 tests/resource/objects_attrs_plus_one_entry.txt create mode 100644 tests/test_cli_textconv.py create mode 100644 tests/test_cli_textconv_nonlocal.py diff --git a/.coveragerc b/.coveragerc index d3b3b11b..38fa089d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,6 +5,7 @@ source = omit = # Don't worry about covering vendored libraries src/sphobjinv/_vendored/* + setup.py [report] exclude_lines = diff --git a/conftest.py b/conftest.py index 1008c063..f7cec58f 100644 --- a/conftest.py +++ b/conftest.py @@ -29,6 +29,7 @@ """ +import logging import os.path as osp import platform import re @@ -81,6 +82,51 @@ def res_dec(res_path, misc_info): return res_path / (misc_info.FNames.RES.value + misc_info.Extensions.DEC.value) +@pytest.fixture(scope="session") +def res_cmp_plus_one_line(res_path, misc_info): + """res_cmp with a line appended. Overwrites objects.inv file.""" + + def func(path_cwd): + """Overwrite objects.inv file. New objects.inv contains one additional line. + + Parameters + ---------- + path_cwd + + |Path| -- test sessions current working directory + + """ + logger = logging.getLogger() + + # src + str_postfix = "_plus_one_entry" + fname = ( + f"{misc_info.FNames.RES.value}{str_postfix}{misc_info.Extensions.CMP.value}" + ) + path_f_src = res_path / fname + reason = f"source file not found src {path_f_src}" + assert path_f_src.is_file() and path_f_src.exists(), reason + + # dst + fname_dst = f"{misc_info.FNames.INIT.value}{misc_info.Extensions.CMP.value}" + path_f_dst = path_cwd / fname_dst + reason = f"dest file not found src {path_f_src} dest {path_f_dst}" + assert path_f_dst.is_file() and path_f_dst.exists(), reason + + # file sizes differ + objects_inv_size_existing = path_f_dst.stat().st_size + objects_inv_size_new = path_f_src.stat().st_size + reason = f"file sizes do not differ src {path_f_src} dest {path_f_dst}" + assert objects_inv_size_new != objects_inv_size_existing, reason + + msg_info = f"copy {path_f_src} --> {path_f_dst}" + logger.info(msg_info) + + shutil.copy2(str(path_f_src), str(path_f_dst)) + + return func + + @pytest.fixture(scope="session") def misc_info(res_path): """Supply Info object with various test-relevant content.""" @@ -151,19 +197,13 @@ def scratch_path(tmp_path, res_path, misc_info, is_win, unix2dos): # With the conversion of resources/objects_attrs.txt to Unix EOLs in order to # provide for a Unix-testable sdist, on Windows systems this resource needs # to be converted to DOS EOLs for consistency. - if is_win: + if is_win: # pragma: no cover win_path = tmp_path / f"{scr_base}{misc_info.Extensions.DEC.value}" win_path.write_bytes(unix2dos(win_path.read_bytes())) yield tmp_path -@pytest.fixture(scope="session") -def ensure_doc_scratch(): - """Ensure doc/scratch dir exists, for README shell examples.""" - Path("doc", "scratch").mkdir(parents=True, exist_ok=True) - - @pytest.fixture(scope="session") def bytes_txt(misc_info, res_path): """Load and return the contents of the example objects_attrs.txt as bytes.""" @@ -211,7 +251,7 @@ def func(path): """Perform the 'live' inventory load test.""" try: sphinx_ifile_load(path) - except Exception as e: # noqa: PIE786 + except Exception as e: # noqa: PIE786 # pragma: no cover # An exception here is a failing test, not a test error. pytest.fail(e) @@ -251,7 +291,40 @@ def func(arglist, *, expect=0): # , suffix=None): except SystemExit as e: retcode = e.args[0] ok = True - else: + else: # pragma: no cover + ok = False + + # Do all pytesty stuff outside monkeypatch context + assert ok, "SystemExit not raised on termination." + + # Test that execution completed w/indicated exit code + assert retcode == expect, runargs + + return func + + +@pytest.fixture() # Must be function scope since uses monkeypatch +def run_cmdline_textconv(monkeypatch): + """Return function to perform command line exit code test.""" + from sphobjinv.cli.core_textconv import main as main_textconv + + def func(arglist, *, expect=0): # , suffix=None): + """Perform the CLI exit-code test.""" + + # Assemble execution arguments + runargs = ["sphobjinv-textconv"] + runargs.extend(str(a) for a in arglist) + + # Mock sys.argv, run main, and restore sys.argv + with monkeypatch.context() as m: + m.setattr(sys, "argv", runargs) + + try: + main_textconv() + except SystemExit as e: + retcode = e.args[0] + ok = True + else: # pragma: no cover ok = False # Do all pytesty stuff outside monkeypatch context @@ -263,6 +336,35 @@ def func(arglist, *, expect=0): # , suffix=None): return func +@pytest.fixture() # Must be function scope since uses monkeypatch +def run_cmdline_no_checks(monkeypatch): + """Return function to perform command line. So as to debug issues no tests.""" + from sphobjinv.cli.core_textconv import main as main_textconv + + def func(arglist, *, prog="sphobjinv-textconv"): + """Perform the CLI exit-code test.""" + + # Assemble execution arguments + runargs = [prog] + runargs.extend(str(a) for a in arglist) + + # Mock sys.argv, run main, and restore sys.argv + with monkeypatch.context() as m: + m.setattr(sys, "argv", runargs) + + try: + main_textconv() + except SystemExit as e: + retcode = e.args[0] + is_system_exit = True + else: # pragma: no cover + is_system_exit = False + + return retcode, is_system_exit + + return func + + @pytest.fixture(scope="session") def decomp_cmp_test(misc_info, is_win, unix2dos): """Return function to confirm a decompressed file is identical to resource.""" @@ -273,7 +375,7 @@ def func(path): res_bytes = Path(misc_info.res_decomp_path).read_bytes() tgt_bytes = Path(path).read_bytes() # .replace(b"\r\n", b"\n") - if is_win: + if is_win: # pragma: no cover # Have to explicitly convert these newlines, now that the # tests/resource/objects_attrs.txt file is marked 'binary' in # .gitattributes diff --git a/doc/source/cli/git_diff.rst b/doc/source/cli/git_diff.rst new file mode 100644 index 00000000..ca4d9ca5 --- /dev/null +++ b/doc/source/cli/git_diff.rst @@ -0,0 +1,166 @@ +.. Description of configure git diff support for inventory files + +Integration -- git diff +======================== + +.. program:: git diff + +|soi-textconv| converts .inv files to plain text sending the +output to |stdout|. + +.. code-block:: shell + + sphobjinv-textconv objects.inv + +Which is equivalent to + +.. code-block:: shell + + sphobjinv convert plain objects.inv - + +Convenience aside, why the redundant CLI command, |soi-textconv|? + +To compare changes to a |objects.inv| file, :code:`git diff` won't +produce a useful result without configuration. And git only accepts a +CLI command with: + +- one input, the INFILE path + +- sends output to |stdout| + +Usage +------ + +Initialize git +""""""""""""""" + +.. code-block:: shell + + git init + git config user.email test@example.com + git config user.name "a test" + +Configure git +"""""""""""""" + +``git diff`` is really useful, so it's time to configure git + +There is no CLI command to configure git for us. + +In ``.git/config`` (or $HOME/.config/git/config) append, + +.. code-block:: text + + [diff "inv"] + textconv = [absolute path to venv bin folder]/sphobjinv-textconv + +Note has one tab, not whitespace(s) + +In ``.gitattributes`` append, + +.. code-block:: text + + *.inv binary diff=inv + +Example +-------- + +Make one commit +"""""""""""""""" + +Commit these files: + +- objects_attrs.inv + +- objects_attrs.txt + +- .gitattributes + +.. code-block:: shell + + git add . + git commit --no-verify --no-gpg-sign -m "test textconv" + +Make a change to ``objects_attrs.inv`` +""""""""""""""""""""""""""""""""""""""" + +By shell + +.. code-block:: shell + + URL="https://github.com/bskinn/sphobjinv/raw/main/tests/resource/objects_attrs.inv" + wget "$URL" + sphobjinv convert plain -qu "$URL" objects_attrs.txt + export APPEND_THIS="attrs.validators.set_cheat_mode py:function 1 api.html#$ -" + echo "$APPEND_THIS" >> objects_attrs.txt + sphobjinv convert zlib -qu objects_attrs.txt objects_attrs.inv + +By python code + +.. versionadded:: 2.4.0 + Append a line to .inv (compressed) inventory + + .. doctest:: append_a_line + + >>> from pathlib import Path + >>> from sphobjinv import DataObjStr + >>> from sphobjinv.cli.load import import_infile + >>> from sphobjinv.cli.write import write_plaintext + >>> + >>> remote_url = ( + ... "https://github.com/bskinn/sphobjinv/" + ... "raw/main/tests/resource/objects_attrs.inv" + ... ) + >>> cli_run(f'sphobjinv convert plain -qu {remote_url} objects_attrs.txt') + + >>> path_dst_dec = Path('objects_attrs.txt') + >>> path_dst_cmp = Path('objects_attrs.inv') + >>> dst_dec_path = str(path_dst_dec) + >>> path_dst_dec.is_file() + True + >>> inv_0 = import_infile(dst_dec_path) + >>> obj_datum = DataObjStr( + ... name="attrs.validators.set_cheat_mode", + ... domain="py", + ... role="function", + ... priority="1", + ... uri="api.html#$", + ... dispname="-", + ... ) + >>> inv_0.objects.append(obj_datum) + >>> write_plaintext(inv_0, dst_dec_path) + >>> cli_run('sphobjinv convert -q zlib objects_attrs.txt objects_attrs.inv') + + >>> path_dst_cmp.is_file() + True + +Show the diff +"""""""""""""" + +To see the changes to objects_attrs.inv + +.. code-block:: shell + + git diff HEAD objects_attrs.inv 2>/dev/null + +Without |soi-textconv|, *These two binary files differ* + +With |soi-textconv| configured + +.. code-block:: text + + diff --git a/objects.inv b/objects.inv + index 85189bd..65cc567 100644 + --- a/objects.inv + +++ b/objects.inv + @@ -131,4 +131,5 @@ types std:doc -1 types.html Type Annotations + validators std:label -1 init.html#$ Validators + version-info std:label -1 api.html#$ - + why std:doc -1 why.html Why not… + +attrs.validators.set_cheat_mode py:function 1 api.html#$ - + +The last line contains rather than + +The 2nd line changes every time + +:code:`2>/dev/null` means suppress |stderr| diff --git a/doc/source/cli/implementation/core-textconv.rst b/doc/source/cli/implementation/core-textconv.rst new file mode 100644 index 00000000..afb1a231 --- /dev/null +++ b/doc/source/cli/implementation/core-textconv.rst @@ -0,0 +1,7 @@ +.. Module API page for cli/core_textconv.py + +sphobjinv.cli.core_textconv +=========================== + +.. automodule:: sphobjinv.cli.core_textconv + :members: diff --git a/doc/source/cli/implementation/index.rst b/doc/source/cli/implementation/index.rst index a174e6f3..f3be26bd 100644 --- a/doc/source/cli/implementation/index.rst +++ b/doc/source/cli/implementation/index.rst @@ -8,6 +8,7 @@ sphobjinv.cli (non-API) convert core + core-textconv load parser paths diff --git a/doc/source/cli/textconv.rst b/doc/source/cli/textconv.rst new file mode 100644 index 00000000..70b5d416 --- /dev/null +++ b/doc/source/cli/textconv.rst @@ -0,0 +1,112 @@ +.. Description of sphobjinv-textconv commandline usage + +Command-Line Usage: |soi-textconv| +=================================== + +.. program:: |soi-textconv| + +Terse syntax command to convert |objects.inv| to |stdout|. Extends +:code:`git diff`. Comparing against partially binary +|objects.inv| versions, produces useful results. + +Rather than *These two binary files differ* + +Unlike |soi|, |soi-textconv| coding style is ``adapt to survive``. +Regardless of what's thrown at it, does what it can. + +Difference + +- when an inventory file is piped in from |stdin|, specifying "-" is optional + +- checks |stdin| even before parsing cli arguments + +---- + +**Usage** + +.. command-output:: sphobjinv-textconv --help + :ellipsis: 4 + +.. versionadded:: 2.4.0 + +.. seealso:: + + Step by step configuration, usage, and code samples + + :doc:`git_diff` + +**Positional Arguments** + +.. option:: infile + + Path (or URL, if :option:`--url` is specified) to file to be converted. + + If passed as ``-``, |soi-textconv| will attempt import of a plaintext or JSON + inventory from |stdin| (incompatible with :option:`--url`). + +**Flags** + +.. option:: -h, --help + + Display help message and exit. + +.. option:: -u, --url + + Treat :option:`infile` as a URL for download. Cannot be used when + :option:`infile` is passed as ``-``. + +.. option:: -e, --expand + + Expand any abbreviations in `uri` or `dispname` fields before writing to output; + see :ref:`here `. + +**Examples** + +Remote URL + +.. code-block:: shell + + export URL="https://github.com/bskinn/sphobjinv/raw/main/tests/resource/objects_attrs.inv" + sphobjinv-textconv "$URL" + +Local URL + +.. code-block:: shell + + sphobjinv-textconv --url "file:///home/pepe/Downloads/objects.inv" + +Piping in compressed inventories is not allowed + +.. code-block:: shell + + sphobjinv-textconv "-" < objects.inv + +^^ BAD ^^ + +.. code-block:: shell + + export URL="https://github.com/bskinn/sphobjinv/raw/main/tests/resource/objects_attrs.inv" + sphobjinv-textconv "-" < "$URL" + +plain text + +.. code-block:: shell + + export URL="https://github.com/bskinn/sphobjinv/raw/main/tests/resource/objects_attrs.inv" + sphobjinv convert -uq plain "$URL" "-" | sphobjinv-textconv + +JSON + +.. code-block:: shell + + sphobjinv-textconv < objects.json + +Expanding `uri` or `dispname` fields + +.. code-block:: shell + + sphobjinv-textconv -e objects.inv + +.. caution:: Caveat + + When an inventory is piped in from stdin, ``-e`` option is ignored diff --git a/doc/source/conf.py b/doc/source/conf.py index a89ab049..ee500b72 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -167,10 +167,16 @@ sphobjinv +.. |soi-textconv| raw:: html + + sphobjinv-textconv + .. |stdin| replace:: |cour|\ stdin\ |/cour| .. |stdout| replace:: |cour|\ stdout\ |/cour| +.. |stderr| replace:: |cour|\ stderr\ |/cour| + .. |cli:ALL| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.ALL` .. |cli:DEF_BASENAME| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.DEF_BASENAME` diff --git a/doc/source/index.rst b/doc/source/index.rst index ad8f24a2..45507a0d 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -101,7 +101,8 @@ The project source repository is on GitHub: `bskinn/sphobjinv syntax api/index CLI Implementation (non-API) - + cli/git_diff + cli/textconv Indices and Tables diff --git a/pyproject.toml b/pyproject.toml index 5f35b59b..7b6be0ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ Donate = "https://github.com/sponsors/bskinn" [project.scripts] sphobjinv = "sphobjinv.cli.core:main" +sphobjinv-textconv = "sphobjinv.cli.core_textconv:main" + [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/sphobjinv/cli/core_textconv.py b/src/sphobjinv/cli/core_textconv.py new file mode 100644 index 00000000..4ee2fe75 --- /dev/null +++ b/src/sphobjinv/cli/core_textconv.py @@ -0,0 +1,304 @@ +r"""*CLI entrypoint for* |soi-textconv|. + +|soi| is a toolkit for manipulation and inspection of +Sphinx |objects.inv| files. + +|soi-textconv| is a strictly limited subset of +|soi| expects an INFILE inventory, converts it, then writes to +|stdout|. Intended for use with :code:`git diff`. git, detect +changes, by first converting an (partially binary) inventory to +plain text. + +**Author** + Dave Faulkmore (msftcangoblowme@protonmail.com) + +**File Created** + 23 Aug 2024 + +**Copyright** + \(c) Brian Skinn 2016-2024 + +**Source Repository** + http://www.github.com/bskinn/sphobjinv + +**Documentation** + https://sphobjinv.readthedocs.io/en/stable + +**License** + Code: `MIT License`_ + + Docs & Docstrings: |CC BY 4.0|_ + + See |license_txt|_ for full license terms. + +**Configure git diff to understand .inv binary files** + +Currently there is no CLI command to configure git to recognize +.inv as a binary file and how to convert to plain text. + +So, for now, here is the step by step howto + +In ``.git/config`` (or $HOME/.config/git/config) append, + +.. code-block:: text + + [diff "inv"] + textconv = [absolute path to venv bin folder]/sphobjinv-textconv + +Note not whitespaces, one tab + +In ``.gitattributes`` append, + +.. code-block:: text + + *.inv binary diff=inv + +**Run non local tests** + +.. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + --cov-config=pyproject.toml --nonloc tests + +**Members** + +""" +import contextlib +import io +import os +import sys +from unittest.mock import patch + +from sphobjinv import Inventory +from sphobjinv.cli.convert import do_convert +from sphobjinv.cli.load import inv_local, inv_stdin, inv_url +from sphobjinv.cli.parser import getparser_textconv, PrsConst + + +def print_stderr_2(thing, params_b, *, end=os.linesep): + r"""Bypass :func:`print_strerr `. + + Use along with :func:`unittest.mock.patch` whenever calling + :mod:`sphobjinv.cli` internals. + + print_strerr is parser dependent, so cannot be used. + + Parameters + ---------- + thing + + *any* -- Object to be printed + + params + + |dict| or |None| -- User input parameters/values mapping + + end + + |str| -- String to append to printed content (default: ``\n``\ ) + + """ + kwargs = {"file": sys.stderr, "end": end} + if params_b is None: + args = (thing,) + else: + args = (thing, params_b) + + print(*args, **kwargs) + + +def _update_with_hardcoded(params): + r"""In-place (by reference) update parameter dict. + + Configuration will cause :func:`sphobjinv.cli.convert.do_convert` + to print to |stdout|. + + Parameters + ---------- + params + + |dict| -- User input parameters/values mapping + + """ + # hardcoded behavior -- print to stdout + params[PrsConst.OUTFILE] = "-" + + # hardcoded behavior -- inventory --> plain + params[PrsConst.MODE] = PrsConst.PLAIN + + # hardcoded behavior -- only applies to sphobjinv convert zlib + # see tests/test_cli TestConvertGood.test_cli_convert_expandcontract + params[PrsConst.CONTRACT] = False + + # Fallback + if not hasattr(params, PrsConst.EXPAND): + params[PrsConst.EXPAND] = False + else: # pragma: no cover + pass + + +def _wrap_inv_stdin(params): + """Don't even try to support inventories passed in |stdin|. + + .. code-block:: shell + + sphobjinv convert plain "-" "-" < tests/resource/objects_cclib.inv + + Raises :exc:`UnicodeDecodeError` when receives zlib inventory + + Parameters + ---------- + params + + |dict| -- User input parameters/values mapping + + Returns + ------- + status + + |bool| -- True valid inventory received on stdin otherwise False + + + Inventory -- either json or plain text + + .. code-block:: shell + + sphobjinv convert plain tests/resource/objects_cclib.inv "-" | sphobjinv-textconv + sphobjinv convert plain tests/resource/objects_cclib.inv "-" 2>/dev/null | \ + sphobjinv-textconv "-" 2>/dev/null + + """ + f = io.StringIO() + with patch("sphobjinv.cli.load.print_stderr", wraps=print_stderr_2): + with contextlib.redirect_stderr(f): + with contextlib.suppress(SystemExit): + inv = inv_stdin(params) + msg_err = f.getvalue().strip() + f.close() + + is_inv = "inv" in locals() and inv is not None and issubclass(type(inv), Inventory) + + if is_inv: + # Pipe in json or plain text?! Adapt to survive + params_b = {} + in_path = None + _update_with_hardcoded(params_b) + # check is inventory file + do_convert(inv, in_path, params_b) + ret = True + else: + # Not an inventory or a zlib inventory. Move on + ret = False + + return ret + + +def main(): + r"""Convert inventory file and print onto |stdout|. + + git requires can accept at most one positional argument, INFILE. + """ + if len(sys.argv) == 1: + # zlib inventory --> UnicodeDecodeError is known and unsupported + params = {} + # Can exit codes 0 or continues + is_inv = _wrap_inv_stdin(params) + if is_inv: + sys.exit(0) + else: + # If no args passed, stick in '-h' + sys.argv.append("-h") + + prs = getparser_textconv() + + # Parse commandline arguments, discarding any unknown ones + ns, _ = prs.parse_known_args() + params = vars(ns) + + # Print version &c. and exit if indicated + if params[PrsConst.VERSION]: + print(PrsConst.VER_TXT) + sys.exit(0) + + # Regardless of mode, insert extra blank line + # for cosmetics + print_stderr_2(os.linesep, params) + + # Generate the input Inventory based on --url or stdio or file. + # These inventory-load functions should call + # sys.exit(n) internally in error-exit situations + if params[PrsConst.URL]: + if params[PrsConst.INFILE] == "-": + prs.error("argument -u/--url not allowed with '-' as infile") + + # Bypass problematic sphobjinv.cli.ui:print_stderr + # sphobjinv-textconv --url 'file:///tests/resource/objects_cclib.inv' + f = io.StringIO() + with patch("sphobjinv.cli.load.print_stderr", wraps=print_stderr_2): + with contextlib.redirect_stderr(f): + with contextlib.suppress(SystemExit): + inv, in_path = inv_url(params) + msg_err = f.getvalue().strip() + f.close() + if len(msg_err) != 0 and msg_err.startswith("Error: URL mode"): + print_stderr_2(msg_err, None) + sys.exit(1) + elif params[PrsConst.INFILE] == "-": + """ + sphobjinv convert plain tests/resource/objects_cclib.inv "-" 2>/dev/null | \ + sphobjinv-textconv "-" 2>/dev/null + """ + try: + is_inv = _wrap_inv_stdin(params) + except UnicodeDecodeError: + """Piping in a zlib inventory is not supported + + In :func:`sphobjinv.cli.load.inv_stdin`, a call to + :func:`sys.stdin.read` raises an uncaught exception which + propagates up the stack and the traceback is displayed to + the end user. + + This is bad UX + + Place the call within a try-except block. The function should + raise one, not two, custom exception. Handling zlib inventory + and non-inventory for an empty file + + .. code-block:: shell + + sphobjinv-textconv "-" \ + 2>/dev/null < plain tests/resource/objects_cclib.inv + echo $? + + 1 + + """ + msg_err = "Invalid plaintext or JSON inventory format." + print_stderr_2(msg_err, None) + sys.exit(1) + else: + if is_inv: + # Cosmetic final blank line + print_stderr_2(os.linesep, params) + sys.exit(0) + else: # pragma: no cover + # No inventory + pass + else: + inv, in_path = inv_local(params) + + is_in_path = "in_path" in locals() and in_path is not None + if is_in_path: + _update_with_hardcoded(params) + + # check is inventory file + do_convert(inv, in_path, params) + + # Cosmetic final blank line + print_stderr_2(os.linesep, params) + else: # pragma: no cover + # No inventory + pass + + # Clean exit + sys.exit(0) diff --git a/src/sphobjinv/cli/parser.py b/src/sphobjinv/cli/parser.py index a4d012ec..2516dece 100644 --- a/src/sphobjinv/cli/parser.py +++ b/src/sphobjinv/cli/parser.py @@ -30,6 +30,8 @@ """ import argparse as ap +import os +import textwrap from sphobjinv.version import __version__ @@ -381,3 +383,103 @@ def getparser(): ) return prs + + +def getparser_textconv(): + """Generate argument parser for entrypoint |soi-textconv|. + + git requires textconv filters to accept only one positional + argument, INFILE, and nothing more. + + Returns + ------- + prs + + :class:`~argparse.ArgumentParser` -- Parser for commandline usage + of |soi-textconv| + + """ + description = ( + "Conversion of an inventory file to stdout.\n\n" + "textconv utility, for use with git, so git diff understands " + "inventory files.\n\n" + "Along with a .gitattributes file, allows git diff to convert " + "the partial binary inventory file to text.\n\n" + "Equivalent to\n\n" + "sphobjinv convert plain object.inv -" + ) + + lst_epilog = [ + "USAGE", + " ", + "Place in doc[s]/.gitattributes", + " ", + """[diff "inv"]""", + " textconv = sphobjinv-textconv", + " binary = true", + " ", + "Place .gitattributes file in your Sphinx doc[s] folder", + "Make a change to an inventory file, see differences: ", + " ", + "git diff objects.inv", + " ", + "or", + " ", + "git diff HEAD objects.inv", + " ", + "EXIT CODES", + " ", + "0 -- Successfully convert inventory to stdout or print version or help", + "1 -- parsing input file path", + "1 -- Unrecognized file format", + "1 -- URL mode on local file is invalid", + "1 -- No inventory found!", + ] + sep = os.linesep + epilog = sep.join(lst_epilog) + epilog += os.linesep + + prs = ap.ArgumentParser( + formatter_class=ap.RawTextHelpFormatter, + description=textwrap.dedent(description), + epilog=textwrap.dedent(epilog), + ) + + # For UX compatabilty with getparser. Not the intended use case + prs.add_argument( + "-" + PrsConst.VERSION[0], + "--" + PrsConst.VERSION, + help="Print package version & other info", + action="store_true", + ) + + # For UX compatabilty with getparser. Not the intended use case + prs.add_argument( + "-" + PrsConst.EXPAND[0], + "--" + PrsConst.EXPAND, + help="Expand all URI and display name abbreviations", + action="store_true", + ) + + # For UX compatabilty with getparser. Not the intended use case + help_text = ( + "Treat 'infile' as a URL for download. " + f"Cannot be used with --{PrsConst.URL}." + ) + prs.add_argument( + "-" + PrsConst.URL[0], + "--" + PrsConst.URL, + help=help_text, + action="store_true", + ) + + help_text = "Path to an inventory file to be converted and sent to stdout." + prs.add_argument( + PrsConst.INFILE, + nargs="?", + const=None, + default=None, + help=help_text, + ) + + return prs diff --git a/tests/resource/objects_attrs_plus_one_entry.inv b/tests/resource/objects_attrs_plus_one_entry.inv new file mode 100644 index 0000000000000000000000000000000000000000..65cc5670e36c90dbe81941655db9368092114ce3 GIT binary patch literal 1446 zcmV;X1zGwdAX9K?X>NERX>N99Zgg*Qc_4OWa&u{KZXhxWBOp+6Z)#;@bUGkmbaZla z3L_v^WpZ8b#rNMXCQiPX<{x4c-oy=&2Hm15Wedv2yCtjy4PF^H@n#w=q5pvZjJ&Y zjV)p+QV=O8-cz5Z57sB?@JF&_OBU%%A`ZWAW=IZah6&ZWA@%;Il10mb{6?54;N!Z~ z760U9=@m&6im>Y+&?qLwT5P1D3BQmUB=M8X;+VRj8I+;RRznn;cQxwYn+{2A*z#k07|zthh?1k@$mR^ zzkc{;zy;NG9++2k)+#2pVR~UF`7Y3h4Fg`N7;F}{U5>ytZum8PJR+sic8x zXh50G$@IR4K+2AChUXLJiMrl2@)w9eaMf!1#zR#r&~|Gc9<#@%vtd)fhKXghcrZ|# zS$8c}e>WebzLTvezBY}t*`h~I+{-{Mr#8R9hPGU2Rx(w#k&-mzFa zlk_<&mnlE5b1jsnBEQh_G5gd8p3};%PTiWc8Ea&cK56Baa&F@N0t;j6srIM6E~R*p z@{vbJ?J4(E|KEZNAvNZK;^Hux*WvBi47+%112fDPbk?*Y^Z83Q%lVGIEkZ&w*0M1b z?Vk$lThvr1GNtc;8x+k7FdB+!{7IKGi3%v}DzNWRp)G|9?-CFy1vXE%mJ}|9W^9uQ zDIkg~ZCq0xMXFgHXGP2GP09cxVMR{`_DAZX>bRsRq~tB>ST1H^8ZIWDsYXi*mP<{5 zhBIYkiM>!Jmgs?1U{Xy`a#w*Jxr*t=Rct4&V!Ln^(t%^Ui!Z&*CC)``RHuIEjeX(> zCxlhg13oN&b?DH?!v#obii0S!C_({HXAE5nDdMNhiq>^dg&7i=GRBH1(iS4&I=jq{ zj)F6hMdDqi%;YAHr?T89xhP&NJZ|+B)p-djYao*Lk@1i#Hss(=$8y5kkpZc0CIq2X3DqI7Xn$b9%RAgiY}`3pAC0LF$vWjhu*aDZ^;UasXTk35$OmbP( zv|$RjX$bG79^a3XLk~E1lL@AeTr?Y!w@S+@Ju=g?jtm*DC9q%AtSPbEVQUjOd#JQB z#4VRf(Pr;xMw%LI+F{eAz+Mm<;vNpmWQ$HabeeSAKHQekQ4sg|dE1k(PTW&wBVc@# z-DoBj;6sBb%rTEV@IvRp*KT+~XSf7vA2>&*hs00)Fzw;r{YSC|VPA1$1)bCxUYpOP z+sC1YqrD~H{3yXrq&!-S7dha)8zd$0dj85?k$X3vl=6LyK|xwb+)bv_ciH^x2?+dx za>*6ilmRE_%i}sE-wV<4?p& zy-&LdXX(59^MH#QL96k#vH}Nu6y&7@OdwdzOg?@vQ;EcEvSaW*By}>+^P{9L8HSv! z%l+7;5gb?W)Z>zYw+qW~@09Us6woFw$3!P{oM$)xM7NMAF0o7$p$W~y2RThQRH%)_ zIsYIzcgL^Ds>M`_v5=EH`^chUG3`TvtEXWGis%FvW$e+&jxun`AD@ z4GX~_o%#O@rRM)av$s1<-pf%o!J#w%igL95-#`CO7k+GksLt@X&UQin1INq9<1TBr AxBvhE literal 0 HcmV?d00001 diff --git a/tests/resource/objects_attrs_plus_one_entry.txt b/tests/resource/objects_attrs_plus_one_entry.txt new file mode 100644 index 00000000..67bb0ae8 --- /dev/null +++ b/tests/resource/objects_attrs_plus_one_entry.txt @@ -0,0 +1,134 @@ +# Sphinx inventory version 2 +# Project: attrs +# Version: 22.1 +# The remainder of this file is compressed using zlib. +attr py:module 0 index.html#module-$ - +attr.VersionInfo py:class 1 api.html#$ - +attr._make.Attribute py:class -1 api.html#attrs.Attribute - +attr._make.Factory py:class -1 api.html#attrs.Factory - +attr._version_info.VersionInfo py:class -1 api.html#attr.VersionInfo - +attr.asdict py:function 1 api.html#$ - +attr.assoc py:function 1 api.html#$ - +attr.astuple py:function 1 api.html#$ - +attr.attr.NOTHING py:data 1 api.html#$ - +attr.attr.cmp_using py:function 1 api.html#$ - +attr.attr.evolve py:function 1 api.html#$ - +attr.attr.fields py:function 1 api.html#$ - +attr.attr.fields_dict py:function 1 api.html#$ - +attr.attr.filters.exclude py:function 1 api.html#$ - +attr.attr.filters.include py:function 1 api.html#$ - +attr.attr.has py:function 1 api.html#$ - +attr.attr.resolve_types py:function 1 api.html#$ - +attr.attr.validate py:function 1 api.html#$ - +attr.attrs.frozen py:function 1 api.html#$ - +attr.attrs.mutable py:function 1 api.html#$ - +attr.attrs.setters.NO_OP py:data 1 api.html#$ - +attr.define py:function 1 api.html#$ - +attr.exceptions.AttrsAttributeNotFoundError py:exception -1 api.html#attrs.exceptions.AttrsAttributeNotFoundError - +attr.exceptions.DefaultAlreadySetError py:exception -1 api.html#attrs.exceptions.DefaultAlreadySetError - +attr.exceptions.FrozenAttributeError py:exception -1 api.html#attrs.exceptions.FrozenAttributeError - +attr.exceptions.FrozenError py:exception -1 api.html#attrs.exceptions.FrozenError - +attr.exceptions.FrozenInstanceError py:exception -1 api.html#attrs.exceptions.FrozenInstanceError - +attr.exceptions.NotAnAttrsClassError py:exception -1 api.html#attrs.exceptions.NotAnAttrsClassError - +attr.exceptions.NotCallableError py:exception -1 api.html#attrs.exceptions.NotCallableError - +attr.exceptions.PythonTooOldError py:exception -1 api.html#attrs.exceptions.PythonTooOldError - +attr.exceptions.UnannotatedAttributeError py:exception -1 api.html#attrs.exceptions.UnannotatedAttributeError - +attr.field py:function 1 api.html#$ - +attr.frozen py:function 1 api.html#$ - +attr.get_run_validators py:function 1 api.html#$ - +attr.ib py:function 1 api.html#$ - +attr.mutable py:function 1 api.html#$ - +attr.s py:function 1 api.html#$ - +attr.set_run_validators py:function 1 api.html#$ - +attrs py:module 0 index.html#module-$ - +attrs.Attribute py:class 1 api.html#$ - +attrs.Attribute.evolve py:method 1 api.html#$ - +attrs.Factory py:class 1 api.html#$ - +attrs.NOTHING py:data 1 api.html#$ - +attrs.asdict py:function 1 api.html#$ - +attrs.astuple py:function 1 api.html#$ - +attrs.cmp_using py:function 1 api.html#$ - +attrs.converters.default_if_none py:function 1 api.html#$ - +attrs.converters.optional py:function 1 api.html#$ - +attrs.converters.pipe py:function 1 api.html#$ - +attrs.converters.to_bool py:function 1 api.html#$ - +attrs.define py:function 1 api.html#$ - +attrs.evolve py:function 1 api.html#$ - +attrs.exceptions.AttrsAttributeNotFoundError py:exception 1 api.html#$ - +attrs.exceptions.DefaultAlreadySetError py:exception 1 api.html#$ - +attrs.exceptions.FrozenAttributeError py:exception 1 api.html#$ - +attrs.exceptions.FrozenError py:exception 1 api.html#$ - +attrs.exceptions.FrozenInstanceError py:exception 1 api.html#$ - +attrs.exceptions.NotAnAttrsClassError py:exception 1 api.html#$ - +attrs.exceptions.NotCallableError py:exception 1 api.html#$ - +attrs.exceptions.PythonTooOldError py:exception 1 api.html#$ - +attrs.exceptions.UnannotatedAttributeError py:exception 1 api.html#$ - +attrs.field py:function 1 api.html#$ - +attrs.fields py:function 1 api.html#$ - +attrs.fields_dict py:function 1 api.html#$ - +attrs.filters.exclude py:function 1 api.html#$ - +attrs.filters.include py:function 1 api.html#$ - +attrs.has py:function 1 api.html#$ - +attrs.make_class py:function 1 api.html#$ - +attrs.resolve_types py:function 1 api.html#$ - +attrs.setters.convert py:function 1 api.html#$ - +attrs.setters.frozen py:function 1 api.html#$ - +attrs.setters.pipe py:function 1 api.html#$ - +attrs.setters.validate py:function 1 api.html#$ - +attrs.validate py:function 1 api.html#$ - +attrs.validators.and_ py:function 1 api.html#$ - +attrs.validators.deep_iterable py:function 1 api.html#$ - +attrs.validators.deep_mapping py:function 1 api.html#$ - +attrs.validators.disabled py:function 1 api.html#$ - +attrs.validators.ge py:function 1 api.html#$ - +attrs.validators.get_disabled py:function 1 api.html#$ - +attrs.validators.gt py:function 1 api.html#$ - +attrs.validators.in_ py:function 1 api.html#$ - +attrs.validators.instance_of py:function 1 api.html#$ - +attrs.validators.is_callable py:function 1 api.html#$ - +attrs.validators.le py:function 1 api.html#$ - +attrs.validators.lt py:function 1 api.html#$ - +attrs.validators.matches_re py:function 1 api.html#$ - +attrs.validators.max_len py:function 1 api.html#$ - +attrs.validators.min_len py:function 1 api.html#$ - +attrs.validators.optional py:function 1 api.html#$ - +attrs.validators.provides py:function 1 api.html#$ - +attrs.validators.set_disabled py:function 1 api.html#$ - +api std:doc -1 api.html API Reference +api_setters std:label -1 api.html#api-setters Setters +api_validators std:label -1 api.html#api-validators Validators +asdict std:label -1 examples.html#$ Converting to Collections Types +changelog std:doc -1 changelog.html Changelog +comparison std:doc -1 comparison.html Comparison +converters std:label -1 init.html#$ Converters +custom-comparison std:label -1 comparison.html#$ Customization +dict classes std:term -1 glossary.html#term-dict-classes - +dunder methods std:term -1 glossary.html#term-dunder-methods - +examples std:doc -1 examples.html attrs by Example +examples_validators std:label -1 examples.html#examples-validators Validators +extending std:doc -1 extending.html Extending +extending_metadata std:label -1 extending.html#extending-metadata Metadata +genindex std:label -1 genindex.html Index +glossary std:doc -1 glossary.html Glossary +hashing std:doc -1 hashing.html Hashing +helpers std:label -1 api.html#$ Helpers +how std:label -1 how-does-it-work.html#$ How Does It Work? +how-does-it-work std:doc -1 how-does-it-work.html How Does It Work? +how-frozen std:label -1 how-does-it-work.html#$ Immutability +index std:doc -1 index.html attrs: Classes Without Boilerplate +init std:doc -1 init.html Initialization +license std:doc -1 license.html License and Credits +metadata std:label -1 examples.html#$ Metadata +modindex std:label -1 py-modindex.html Module Index +names std:doc -1 names.html On The Core API Names +overview std:doc -1 overview.html Overview +philosophy std:label -1 overview.html#$ Philosophy +py-modindex std:label -1 py-modindex.html Python Module Index +search std:label -1 search.html Search Page +slotted classes std:term -1 glossary.html#term-slotted-classes - +transform-fields std:label -1 extending.html#$ Automatic Field Transformation and Modification +types std:doc -1 types.html Type Annotations +validators std:label -1 init.html#$ Validators +version-info std:label -1 api.html#$ - +why std:doc -1 why.html Why not… +attrs.validators.set_cheat_mode py:function 1 api.html#$ - diff --git a/tests/test_api_good.py b/tests/test_api_good.py index d5b12805..5eac5523 100644 --- a/tests/test_api_good.py +++ b/tests/test_api_good.py @@ -494,6 +494,10 @@ def test_api_inventory_datafile_gen_and_reimport( fname = testall_inv_path.name scr_fpath = scratch_path / fname + skip_non_package = ("objects_attrs_plus_one_entry.inv",) + if fname in skip_non_package: + pytest.skip("Modified not original inventory") + # Drop most unless testall if not pytestconfig.getoption("--testall") and fname != "objects_attrs.inv": pytest.skip("'--testall' not specified") @@ -528,6 +532,10 @@ def test_api_inventory_matches_sphinx_ifile( fname = testall_inv_path.name scr_fpath = scratch_path / fname + skip_non_package = ("objects_attrs_plus_one_entry.inv",) + if fname in skip_non_package: + pytest.skip("Modified not original inventory") + # Drop most unless testall if not pytestconfig.getoption("--testall") and fname != "objects_attrs.inv": pytest.skip("'--testall' not specified") diff --git a/tests/test_api_good_nonlocal.py b/tests/test_api_good_nonlocal.py index 85bfb1d8..29f98f07 100644 --- a/tests/test_api_good_nonlocal.py +++ b/tests/test_api_good_nonlocal.py @@ -84,6 +84,10 @@ def test_api_inventory_many_url_imports( scr_fpath = scratch_path / fname # Drop most unless testall + skip_non_package = ("objects_attrs_plus_one_entry.inv",) + if fname in skip_non_package: + pytest.skip("Modified not original inventory") + if not pytestconfig.getoption("--testall") and fname != "objects_attrs.inv": pytest.skip("'--testall' not specified") diff --git a/tests/test_cli_textconv.py b/tests/test_cli_textconv.py new file mode 100644 index 00000000..2622a057 --- /dev/null +++ b/tests/test_cli_textconv.py @@ -0,0 +1,298 @@ +r"""*CLI tests for* ``sphobjinv-textconv``. + +``sphobjinv`` is a toolkit for manipulation and inspection of +Sphinx |objects.inv| files. + +``sphobjinv-textconv`` is a strictly limited subset of +``sphobjinv`` expects an INFILE inventory, converts it, then writes to +stdout. Intended for use with git diff. git, detect changes, by first +converting an (partially binary) inventory to plain text. + +**Author** + Dave Faulkmore (msftcangoblowme@protonmail.com) + +**File Created** + 23 Aug 2024 + +**Copyright** + \(c) Brian Skinn 2016-2024 + +**Source Repository** + http://www.github.com/bskinn/sphobjinv + +**Documentation** + https://sphobjinv.readthedocs.io/en/stable + +**License** + Code: `MIT License`_ + + Docs & Docstrings: |CC BY 4.0|_ + + See |license_txt|_ for full license terms. + +.. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + --cov-config=pyproject.toml --nonloc tests + +**Members** + +""" + + +import json +import os +import shlex +import subprocess as sp # noqa: S404 + +import pytest +from stdio_mgr import stdio_mgr + +from sphobjinv import Inventory +from sphobjinv import SourceTypes +from sphobjinv.fileops import readbytes + +CLI_TEST_TIMEOUT = 2 +# Is an entrypoint, but not a package +CLI_CMDS = ["sphobjinv-textconv"] + +pytestmark = [pytest.mark.cli, pytest.mark.local] + + +class TestTextconvMisc: + """Tests for miscellaneous CLI functions.""" + + @pytest.mark.timeout(CLI_TEST_TIMEOUT) + @pytest.mark.parametrize("cmd", CLI_CMDS) + def test_cli_textconv_help(self, cmd, run_cmdline_no_checks): + """Confirm that actual shell invocations do not error. + + .. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + --cov-config=pyproject.toml -k test_cli_textconv_help tests + + """ + runargs = shlex.split(cmd) + runargs.append("--help") + + with stdio_mgr() as (in_, out_, err_): + retcode, is_sys_exit = run_cmdline_no_checks(runargs) + str_out = out_.getvalue() + assert "sphobjinv-textconv" in str_out + + # Ideally, the only place sys.exit calls occur within a codebase is in + # entrypoint file(s). In this case, sphobjinv.cli.core + # + # Each unique custom Exception has a corresponding unique exit code. + # + # Testing looks at exit codes only. + # + # Not the error messages, which could change or be localized + # + # In command line utilities, relaying possible errors is common practice + # + # From an UX POV, running echo $? and getting 1 on error is + # useless and frustrating. + # + # Not relaying errors and giving exact feedback on how to rectify + # the issue is bad UX. + # + # So if the numerous exit codes of 1 looks strange. It is; but this is + # a separate issue best solved within a dedicated commit + assert f"EXIT CODES{os.linesep}" in str_out + + # Leave zero doubt about + # + # - what it's for + # - how to use + # - what to expect + assert f"USAGE{os.linesep}" in str_out + + @pytest.mark.timeout(CLI_TEST_TIMEOUT) + def test_cli_version_exits_ok(self, run_cmdline_textconv): + """Confirm --version exits cleanly.""" + run_cmdline_textconv(["-v"]) + + @pytest.mark.timeout(CLI_TEST_TIMEOUT) + def test_cli_noargs_shows_help(self, run_cmdline_textconv): + """Confirm help shown when invoked with no arguments.""" + with stdio_mgr() as (in_, out_, err_): + run_cmdline_textconv([]) + str_out = out_.getvalue() + assert "usage: sphobjinv-textconv" in str_out + + +class TestTextconvGood: + """Tests for expected-good textconv functionality.""" + + @pytest.mark.parametrize( + "in_ext", [".txt", ".inv", ".json"], ids=(lambda i: i.split(".")[-1]) + ) + @pytest.mark.timeout(CLI_TEST_TIMEOUT) + def test_cli_textconv_inventory_files( + self, + in_ext, + scratch_path, + run_cmdline_textconv, + misc_info, + ): + """Inventory files' path provided via cli. stdout is not captured. + + .. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + -k test_cli_textconv_inventory_files tests + + """ + src_path = scratch_path / (misc_info.FNames.INIT + in_ext) + + assert src_path.is_file() + + cli_arglist = [str(src_path)] + + # Confirm success, but sadly no stdout + run_cmdline_textconv(cli_arglist) + + # More than one positional arg. Expect additional positional arg to be ignored + cli_arglist = [str(src_path), "7"] + run_cmdline_textconv(cli_arglist) + + # Unknown keyword arg. Expect to be ignored + cli_arglist = [str(src_path), "--elephant-shoes", "42"] + run_cmdline_textconv(cli_arglist) + + +class TestTextconvFail: + """Tests for textconv expected-fail behaviors.""" + + def test_cli_textconv_url_bad( + self, + scratch_path, + misc_info, + run_cmdline_textconv, + run_cmdline_no_checks, + ): + """Confirm cmdline contract. Confirm local inventory URLs not allowed.""" + path_cmp = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.CMP) + + # --url instead of infile. local url not allowed + url_local_path = f"""file://{path_cmp!s}""" + run_cmdline_textconv(["-e", "--url", url_local_path], expect=1) + + +@pytest.mark.parametrize( + "data_format", + [SourceTypes.DictJSON, SourceTypes.BytesPlaintext], + ids=["json", "plaintext"], +) +def test_cli_textconv_via_subprocess( + data_format, + res_dec, + res_cmp, + misc_info, +): + """In a subprocess, plain inventory passed in thru stdin. + + .. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + --cov-config=pyproject.toml -k test_cli_textconv_via_subprocess tests + + """ + # prepare + retcode_expected = 0 + + soi_textconv_path = "sphobjinv-textconv" + + inv1 = Inventory(res_cmp) + if data_format is SourceTypes.DictJSON: + input_data = json.dumps(inv1.json_dict()) + elif data_format is SourceTypes.BytesPlaintext: + input_data = inv1.data_file().decode("utf-8") + + expected = inv1.data_file().decode("utf-8") + + # Act + cmds = ( + [soi_textconv_path], + [soi_textconv_path, "-"], + ) + for cmd in cmds: + try: + p_result = sp.run( + cmd, + shell=False, # noqa: S603 + input=input_data, + text=True, + capture_output=True, + ) + except (sp.CalledProcessError, sp.TimeoutExpired): # pragma: no cover + pytest.xfail() + else: + out = p_result.stdout + retcode = p_result.returncode + strlen_out = len(out) + strlen_in = len(expected) + # inventory file contains an additional newline + assert retcode == retcode_expected + assert strlen_in == strlen_out - 1 + + +class TestTextconvStdioFail: + """Piping in via stdin expect-fail behaviors.""" + + def test_cli_textconv_zlib_inv_stdin( + self, + res_cmp, + ): + """Piping in a zlib inventory is not supported. + + .. code-block:: shell + + sphobjinv-textconv "-" 2>/dev/null < tests/resource/objects_cclib.inv + echo $? + + 1 + + Run this test class method + + .. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + --cov-config=pyproject.toml -k test_cli_textconv_zlib_inv_stdin tests + + """ + expected_retcode = 1 + + # prepare + # byte stream usable by subprocess + bytes_cmp = readbytes(res_cmp) + + soi_textconv_path = "sphobjinv-textconv" + + cmd = [soi_textconv_path, "-"] + try: + sp.run( + cmd, + shell=False, # noqa: S603 + input=bytes_cmp, + text=False, + capture_output=True, + check=True, + ) + except (sp.CalledProcessError, FileNotFoundError) as e: # pragma: no cover + # Only coverage issue on Azure, in `Check 100% test execution`. + # No where else. If can figure out why, remove the pragma + retcode = e.returncode + b_err = e.stderr + str_err = b_err.decode("utf-8") + assert retcode == expected_retcode + assert "Invalid plaintext or JSON inventory format." in str_err + else: # pragma: no cover + # Supposed to fail, so this block is never evaluated + reason = ( + "Piping in zlib inventory via stdin is not supported. " + "Was expecting exit code 1" + ) + pytest.xfail(reason) diff --git a/tests/test_cli_textconv_nonlocal.py b/tests/test_cli_textconv_nonlocal.py new file mode 100644 index 00000000..b8ba3d59 --- /dev/null +++ b/tests/test_cli_textconv_nonlocal.py @@ -0,0 +1,113 @@ +r"""*Nonlocal CLI tests for* ``sphobjinv-textconv``. + +``sphobjinv`` is a toolkit for manipulation and inspection of +Sphinx |objects.inv| files. + +``sphobjinv-textconv`` is a strictly limited subset of +``sphobjinv`` expects an INFILE inventory, converts it, then writes to +stdout. Intended for use with git diff. git, detect changes, by first +converting an (partially binary) inventory to plain text. + +**Author** + Dave Faulkmore (msftcangoblowme@protonmail.com) + +**File Created** + 24 Aug 2024 + +**Copyright** + \(c) Brian Skinn 2016-2024 + +**Source Repository** + http://www.github.com/bskinn/sphobjinv + +**Documentation** + https://sphobjinv.readthedocs.io/en/stable + +**License** + Code: `MIT License`_ + + Docs & Docstrings: |CC BY 4.0|_ + + See |license_txt|_ for full license terms. + +.. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + --cov-config=pyproject.toml --nonloc tests + +**Members** + +""" +import pytest +from stdio_mgr import stdio_mgr + +CLI_TEST_TIMEOUT = 5 + +pytestmark = [pytest.mark.cli, pytest.mark.nonloc] + + +class TestTextconvOnlineBad: + """Tests for textconv, online, expected-fail behaviors.""" + + @pytest.mark.parametrize( + "url, cmd, expected, msg", + ( + ( + "http://sphobjinv.readthedocs.io/en/v2.0/objects.inv", + ["-e", "--url", "-"], + 2, + "argument -u/--url not allowed with '-' as infile", + ), + ), + ids=["both --url and infile '-' do allowed"], + ) + @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4) + def test_textconv_both_url_and_infile( + self, + url, + cmd, + expected, + msg, + run_cmdline_no_checks, + ): + """Online URL and INFILE "-", cannot specify both. + + .. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + --cov-config=pyproject.toml -k test_textconv_both_url_and_infile tests + + """ + # Both --url and INFILE "-". Excess args are discarded. + # In this case INFILE "-" + # For this test, URL cannot be local (file:///) + with stdio_mgr() as (in_, out_, err_): + retcode, is_sys_exit = run_cmdline_no_checks(cmd) + str_err = err_.getvalue() + assert retcode == expected + assert msg in str_err + + +class TestTextconvOnlineGood: + """Tests for textconv, online, expected-good functionality.""" + + @pytest.mark.parametrize( + "url, expected_retcode", + ( + ( + "http://sphobjinv.readthedocs.io/en/v2.0/objects.inv", + 0, + ), + ), + ids=["Remote zlib inventory URL"], + ) + @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4) + def test_textconv_online_url( + self, + url, + expected_retcode, + run_cmdline_textconv, + ): + """Valid nonlocal url.""" + cmd = ["--url", url] + run_cmdline_textconv(cmd, expect=expected_retcode)