From 19029ad6b73740403be4befa2ff9fc7bea671cbd Mon Sep 17 00:00:00 2001 From: msftcangoblowme Date: Mon, 26 Aug 2024 10:26:59 +0000 Subject: [PATCH] git diff support - feat: entrypoint sphobjinv-textconv ([#295]) - test: add sphobjinv-textconv unittests offline and online - test: add integration test. Demonstrate git diff objects.inv - test: add pytest module with class to interact with git - docs: add step by step guide to configure git. Both bash and python code examples - docs: added sphobjinv-textconv API docs --- .gitattributes | 1 + CHANGELOG.md | 17 + conftest.py | 62 ++++ doc/source/cli/git_diff.rst | 164 ++++++++++ .../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 | 32 +- src/sphobjinv/cli/core_textconv.py | 295 ++++++++++++++++++ src/sphobjinv/cli/parser.py | 96 ++++++ src/sphobjinv/version.py | 2 +- tests/test_cli_textconv.py | 282 +++++++++++++++++ tests/test_cli_textconv_nonlocal.py | 113 +++++++ tests/test_cli_textconv_with_git.py | 163 ++++++++++ tests/wd_wrapper.py | 235 ++++++++++++++ 17 files changed, 1578 insertions(+), 13 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/test_cli_textconv.py create mode 100644 tests/test_cli_textconv_nonlocal.py create mode 100644 tests/test_cli_textconv_with_git.py create mode 100644 tests/wd_wrapper.py diff --git a/.gitattributes b/.gitattributes index c6f20dbf..39f2e6e1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ tests/resource/objects_mkdoc_zlib0.inv binary tests/resource/objects_attrs.txt binary +*.inv binary diff=inv diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f7c2f8..65e20505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ and this project follows an extension of fourth number represents an administrative maintenance release with no code changes. +### [2.4.0] - 2024-08-26 + +#### Added + + * Entrypoint sphobjinv-textconv ([#295]) + +#### Tests + + * Unittests both offline and online + * Integration test to demonstrate git diff objects.inv + * add pytest module with class to interact with git + +#### Documentation + + * Added step by step guide to configure git. Both bash and python code examples + * Added sphobjinv-textconv API docs + ### [2.3.1.1] - 2024-05-21 #### Tests diff --git a/conftest.py b/conftest.py index cccab68f..490b13d2 100644 --- a/conftest.py +++ b/conftest.py @@ -264,6 +264,68 @@ def func(arglist, *, expect=0): # , suffix=None): 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 + + 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() + except SystemExit as e: + retcode = e.args[0] + ok = True + else: + 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_no_checks(monkeypatch): + """Return function to perform command line. So as to debug issues no tests.""" + from sphobjinv.cli.core_textconv import main + + 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() + except SystemExit as e: + retcode = e.args[0] + is_system_exit = True + else: + 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.""" diff --git a/doc/source/cli/git_diff.rst b/doc/source/cli/git_diff.rst new file mode 100644 index 00000000..b859c68d --- /dev/null +++ b/doc/source/cli/git_diff.rst @@ -0,0 +1,164 @@ +.. 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 + +Currently 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 + +Make one commit +"""""""""""""""" + +Commit these files: + +- objects_attrs.inv + +- objects_attrs.txt + +- .gitattributes + +.. code-block:: shell + + git add . + git 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 +"""""""""""""" + +.. code-block:: shell + + git diff HEAD objects_attrs.inv 2>/dev/null + +.. 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| + +.. seealso:: + + To see the entire process, check out the integration test + + ``tests/test_cli_textconv_with_git.py`` 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 14a04df8..c0e2a870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,25 @@ requires = [ [project] name = "sphobjinv" description = "Sphinx objects.inv Inspection/Manipulation Tool" +keywords = [ + "inspector", + "inventory", + "manager", + "sphinx", + "sphinx-doc", +] license = {text = "MIT License"} authors = [{name = "Brian Skinn", email = "brian.skinn@gmail.com"}] +requires-python = ">=3.8" +dependencies = [ + "attrs>=19.2", + "certifi", + "jsonschema>=3", +] +dynamic = [ + "readme", + "version", +] classifiers = [ "License :: OSI Approved", "License :: OSI Approved :: MIT License", @@ -33,24 +50,17 @@ classifiers = [ "Topic :: Utilities", "Development Status :: 5 - Production/Stable", ] -keywords = ["sphinx", "sphinx-doc", "inventory", "manager", "inspector"] -requires-python = ">=3.8" -dependencies = [ - "attrs>=19.2", - "certifi", - "jsonschema>=3.0", -] -dynamic = ["version", "readme"] - [project.urls] -Homepage = "https://github.com/bskinn/sphobjinv" Changelog = "https://github.com/bskinn/sphobjinv/blob/main/CHANGELOG.md" Docs = "https://sphobjinv.readthedocs.io/en/stable/" -Thank = "https://fosstodon.org/@btskinn" Donate = "https://github.com/sponsors/bskinn" +Homepage = "https://github.com/bskinn/sphobjinv" +Thank = "https://fosstodon.org/@btskinn" [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..c45f37e3 --- /dev/null +++ b/src/sphobjinv/cli/core_textconv.py @@ -0,0 +1,295 @@ +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 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="\n"): + 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 + + +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 + + """ + with ( + patch("sphobjinv.cli.load.print_stderr", wraps=print_stderr_2), + contextlib.redirect_stderr(io.StringIO()) as f, + contextlib.suppress(SystemExit), + ): + inv = inv_stdin(params) + msg_err = f.getvalue().strip() + + 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("\n", 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' + with ( + patch("sphobjinv.cli.load.print_stderr", wraps=print_stderr_2), + contextlib.redirect_stderr(io.StringIO()) as f, + contextlib.suppress(SystemExit), + ): + inv, in_path = inv_url(params) + msg_err = f.getvalue().strip() + 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("\n", params) + sys.exit(0) + 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("\n", params) + + # Clean exit + sys.exit(0) diff --git a/src/sphobjinv/cli/parser.py b/src/sphobjinv/cli/parser.py index a4d012ec..b51bcbb4 100644 --- a/src/sphobjinv/cli/parser.py +++ b/src/sphobjinv/cli/parser.py @@ -30,6 +30,7 @@ """ import argparse as ap +import textwrap from sphobjinv.version import __version__ @@ -381,3 +382,98 @@ 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 -" + ) + + epilog = ( + "USAGE\n" + " \n" + "Place in doc[s]/.gitattributes\n" + " \n" + """[diff "inv"]\n""" + " textconv = sphobjinv-textconv\n" + " binary = true\n" + " \n" + "Place .gitattributes file in your Sphinx doc[s] folder\n" + "Make a change to an inventory file, see differences: \n" + " \n" + "git diff objects.inv\n\n" + "or\n\n" + "git diff HEAD objects.inv\n" + " \n" + "EXIT CODES\n" + " \n" + "0 -- Successfully convert inventory to stdout or print version or help\n" + "1 -- parsing input file path\n" + "1 -- Unrecognized file format\n" + "1 -- URL mode on local file is invalid\n" + "1 -- No inventory found!\n" + ) + + 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/src/sphobjinv/version.py b/src/sphobjinv/version.py index d9a08884..60da4c95 100644 --- a/src/sphobjinv/version.py +++ b/src/sphobjinv/version.py @@ -29,4 +29,4 @@ """ -__version__ = "2.3.2.dev0" +__version__ = "2.4.0" diff --git a/tests/test_cli_textconv.py b/tests/test_cli_textconv.py new file mode 100644 index 00000000..03e1a957 --- /dev/null +++ b/tests/test_cli_textconv.py @@ -0,0 +1,282 @@ +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 shlex +import subprocess as sp # noqa: S404 +import sys +from pathlib import Path + +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_invocations(self, cmd): + """Confirm that actual shell invocations do not error.""" + runargs = shlex.split(cmd) + runargs.append("--help") + + out = sp.check_output(" ".join(runargs), shell=True).decode() # noqa: S602 + + assert "sphobjinv-textconv" in 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 "EXIT CODES\n" in out + + # Leave zero doubt about + # + # - what it's for + # - how to use + # - what to expect + assert "USAGE\n" in 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.""" + 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 + path_cmd = Path(sys.executable).parent.joinpath("sphobjinv-textconv") + cmd_path = str(path_cmd) + + 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 = ( + [cmd_path], + [cmd_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): + 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 < plain 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) + + path_cmd = Path(sys.executable).parent.joinpath("sphobjinv-textconv") + cmd_path = str(path_cmd) + + cmd = [cmd_path, "-"] + try: + sp.run( + cmd, + shell=False, # noqa: S603 + input=bytes_cmp, + text=False, + capture_output=True, + check=True, + ) + except sp.CalledProcessError as e: + 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: + 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) diff --git a/tests/test_cli_textconv_with_git.py b/tests/test_cli_textconv_with_git.py new file mode 100644 index 00000000..342a0ba6 --- /dev/null +++ b/tests/test_cli_textconv_with_git.py @@ -0,0 +1,163 @@ +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 re +import sys +from pathlib import Path + +from sphobjinv import DataObjStr +from sphobjinv.cli.load import import_infile +from sphobjinv.cli.write import write_plaintext + +from .wd_wrapper import ( # noqa: ABS101 + run, + WorkDir, +) + + +class TestTextconvIntegration: + """Prove git diff an compare |objects.inv| files.""" + + def test_textconv_git_diff( + self, + misc_info, + scratch_path, + ): + """Demonstrate git diff on a zlib inventory. + + .. code-block:: shell + + pytest --showlocals --cov=sphobjinv --cov-report=term-missing \ + --cov-config=pyproject.toml -k test_textconv_git_diff tests + + """ + # word placeholder --> \w+ + # Escape $ --> \$ + # Escape + --> \+ + expected_diff = ( + r"^diff --git a/objects.inv b/objects.inv\n" + r"index \w+..\w+ \w+\n" + r"--- a/objects.inv\n" + r"\+\+\+ b/objects.inv\n" + r"@@ -131,4 \+131,5 @@ types std:doc -1 types.html Type Annotations\n" + r" validators std:label -1 init.html#\$ Validators\n" + r" version-info std:label -1 api.html#\$ -\n" + r" why std:doc -1 why.html Why not…\n" + r"\+attrs.validators.set_cheat_mode py:function 1 api.html#\$ -\n" + r" \n$" + ) + + # prepare + # project folder + path_cwd = scratch_path + wd = WorkDir(path_cwd) + + path_soi = Path(sys.executable).parent.joinpath("sphobjinv") + soi_path = str(path_soi) + path_soi_textconv = Path(sys.executable).parent.joinpath("sphobjinv-textconv") + + # git init + wd("git init") + wd("git config user.email test@example.com") + wd('git config user.name "a test"') + + # inventories: .txt and .inv + # scratch_path copied: + # objects_attrs.{.txt|.inv.json} --> objects.{.txt|.inv.json} + path_cmp = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.CMP) + dst_cmp_path = str(path_cmp) + + path_dec = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.DEC) + dst_dec_path = str(path_dec) + + # .git/config append + path_git_config = path_cwd / ".git" / "config" + str_git_config = path_git_config.read_text() + lines = [ + """[diff "inv"]""", + f""" textconv = {path_soi_textconv!s}\n""", + ] + gc_textconv = "\n".join(lines) + str_git_config = f"{str_git_config}\n{gc_textconv}\n" + path_git_config.write_text(str_git_config) + + # .gitattributes + # Informs git: .inv are binary files and which cmd converts .inv --> .txt + path_ga = path_cwd / ".gitattributes" + path_ga.touch() + str_gitattributes = path_ga.read_text() + ga_textconv = "*.inv binary diff=inv" + str_gitattributes = f"{str_gitattributes}\n{ga_textconv}" + wd.write(".gitattributes", str_gitattributes) + + # commit (1st) + wd.add_command = "git add ." + wd.commit_command = "git commit --no-verify --no-gpg-sign -m test-{reason}" + wd.add_and_commit(reason="sphobjinv-textconv", signed=False) + + # Act + # make change to .txt inventory (path_dst_dec) + 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) + + # plain --> zlib + cmd = f"{soi_path} convert -q zlib {dst_dec_path} {dst_cmp_path}" + wd(cmd) + + # Compare last commit .inv with updated .inv + cmd = f"git diff HEAD {misc_info.FNames.INIT + misc_info.Extensions.CMP}" + sp_out = run(cmd, cwd=wd.cwd) + retcode = sp_out.returncode + out = sp_out.stdout + assert retcode == 0 + pattern = re.compile(expected_diff) + lst_matches = pattern.findall(out) + assert lst_matches is not None + assert isinstance(lst_matches, list) + assert len(lst_matches) == 1 diff --git a/tests/wd_wrapper.py b/tests/wd_wrapper.py new file mode 100644 index 00000000..647a8063 --- /dev/null +++ b/tests/wd_wrapper.py @@ -0,0 +1,235 @@ +"""Class for working with pytest and git. + +.. seealso: + + class WorkDir + `[source] `_ + `[license:MIT] `_ + +""" + +from __future__ import annotations + +import itertools +import os +import shlex +import subprocess as sp # noqa: S404 +from pathlib import Path + + +def run(cmd, cwd=None): + """Run a command. + + Parameters + ---------- + cmd + + |str| or |list| -- The command to run within a subprocess + + cwd + + |Path| or |str| or |None| -- base folder path. |None| current process cwd + + + Returns + ------- + CompletedProcess + + subprocess.CompletedProcess -- Subprocess results + + """ + if isinstance(cmd, str): + cmd = shlex.split(cmd) + else: + cmd = [os.fspath(x) for x in cmd] + + try: + p_out = sp.run(cmd, cwd=cwd, text=True, capture_output=True) # noqa: S603 + except sp.CalledProcessError: + ret = None + else: + ret = p_out + + return ret + + +class WorkDir: + """a simple model for working with git.""" + + #: set the git commit command + commit_command: str + #: if signed is True, use this alternative git commit command + signed_commit_command: str + #: set the git add command + add_command: str + + def __repr__(self) -> str: + """Representation capable of reinstanciating an instance copy. + + Does not remember the add, commit, or signed commit commands + + Returns + ------- + Representation str + + |str| -- representation str of class and state + + """ + return f"" + + def __init__(self, cwd: Path) -> None: + """Class instance constructor. + + Parameters + ---------- + cwd + + |Path| -- Base folder + + """ + self.cwd = cwd + self.__counter = itertools.count() + + def __call__(self, cmd: list[str] | str, **kw: object) -> str: + """Run a cmd. + + Parameters + ---------- + cmd + + list[str] or |str| -- Command to run + + kw + + Only applies if command is a |str|. :func:`str.format` parameters + + Returns + ------- + CompletedProcess + + subprocess.CompletedProcess -- Subprocess results + + """ + if kw: + assert isinstance(cmd, str), "formatting the command requires text input" + cmd = cmd.format(**kw) + + return run(cmd, cwd=self.cwd).stdout + + def write(self, name: str, content: str | bytes) -> Path: + """Create a file within the cwd. + + Parameters + ---------- + name + + |str| -- file name + + content + + |str| or |bytes| -- content to write to file + + Returns + ------- + AbsolutePath + + |Path| -- Absolute file path + + """ + path = self.cwd / name + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content, encoding="utf-8") + return path + + def _reason(self, given_reason: str | None) -> str: + """Format commit reason. + + Parameters + ---------- + given_reason + + |str| or |None| -- commit reason. If |None|, message will + be ``number-[instance count]``. + + Returns + ------- + FormattedReason + + |str| -- formatted reason + + """ + if given_reason is None: + return f"number-{next(self.__counter)}" + else: + return given_reason + + def add_and_commit( + self, reason: str | None = None, signed: bool = False, **kwargs: object + ) -> None: + """Add files and create a commit. + + Parameters + ---------- + reason + + |str| or None -- Default |None|. Reason for commit. If |None|, + use default reason + + signed + + |bool| -- Default |False|. If True use signed commit command + otherwise use not signed commit command. + + kwargs + + object -- Unused parameter. Probably a placeholder to conform to + abc method signature + + """ + self(self.add_command) + self.commit(reason=reason, signed=signed, **kwargs) + + def commit(self, reason: str | None = None, signed: bool = False) -> None: + """Commit message. + + Parameters + ---------- + reason + + |str| or None -- Default |None|. Reason for commit. If |None|, + use default reason + + signed + + |bool| -- Default |False|. If True use signed commit command + otherwise use not signed commit command. + + """ + reason = self._reason(reason) + self( + self.commit_command if not signed else self.signed_commit_command, + reason=reason, + ) + + def commit_testfile(self, reason: str | None = None, signed: bool = False) -> None: + """Commit a test.txt file. + + Parameters + ---------- + reason + + |str| or None -- Default |None|. Reason for commit. If |None|, + use default reason + + signed + + |bool| -- Default |False|. If True use signed commit command + otherwise use not signed commit command. + + """ + reason = self._reason(reason) + self.write("test.txt", f"test {reason}") + self(self.add_command) + self.commit(reason=reason, signed=signed)