Repository: bskinn/sphobjinv (language: python)

Issue:
Add printout of intersphinx_mapping entry for found-url
~Trivial for standard docs layout

~Impossible to write a general algorithm to properly construct it for nonstandard docs layouts.

**ALTHOUGH** -- if a user has pasted a link to the actual docset as the input argument for `suggest`, then we can pretty strongly infer that that is a valid page in the docset. We can then search the `uri`s in the inventory for one that matches the tail of the link passed in by the user. If the head of that link is the same as the location of the found `objects.inv`, then the `({objects_url}, None)` syntax will work. If not, then the `({docs_base_url}, {objects_url})` syntax will be needed.

OTOH, we can't be sure that all input URLs will be pasted from a page in the docs, so a _failure_ to match on this rubric ***doesn't mean*** that the root of the `objects.inv` URL ***isn't*** the correct docset root for the mapping...just that we can't be sure.

If the tail of the `objects.inv` URL is **not** `/objects.inv`, then we can be 100% sure that the mapping will not be `(..., None)`, but instead `(some_url, objectsinv_url)`.  The only way to determine `some_url` is if the user also provides a URL to an object in the docset, and then we can extract the base from that using the `Inventory` objects, presuming one matches. This might mean that it would be best to expose that base-finding function as public...

Implement as helper function in the API, exploited by the CLI? (**Yes. Standalone in a new module. DO NOT cross-implement as a method on `Inventory`, would lead to an import cycle.**) Should just need `objects_url` and `docs_link_url`? (**Yes, plus the retrieved `Inventory`.**)

----

TODO:

- [x] Convert objects.inv base extractor to public function
- [x] Compose docstrings for functions:
  - [x] objects.inv base extractor
  - [x] mapping inferrer
- [x] Implement CLI use of the new functions
  - Make sure the language is suitably tentative ("The following mapping SHOULD work for ...", "The following mapping MAY work for ...", "No candidate mapping for ... could be determined.")
- ~Add API documentation page~ Leaving the whole new module private for now (at `sphobjinv._intersphinx`)
- ~Add HOWTO docs page for finding mappings through API~ (Now, no public API)
- [x] Fix `extract_objectsinv_url_base()` (can't *just* do a blind `rpartition`... have to do the `rpartition` and then check if the `[0]` is empty, and handle accordingly)
  - Django inv is the main driver here
- [x] Add tests on some links to the sphobjinv RtD docset
- [x] Refactor to remove `_intersphinx.py`
  - [x] Add tag to commit before remove for later access if needed
- [x] Fill out coverage; add pragmas as needed
- [x] Check if need to update any usage examples, or if they're all using `.. programoutput::` and will auto-update
- [x] CHANGELOG entry
- [x] Fix #244 along the way
Add printout of intersphinx_mapping entry for found-url
~Trivial for standard docs layout

~Impossible to write a general algorithm to properly construct it for nonstandard docs layouts.

**ALTHOUGH** -- if a user has pasted a link to the actual docset as the input argument for `suggest`, then we can pretty strongly infer that that is a valid page in the docset. We can then search the `uri`s in the inventory for one that matches the tail of the link passed in by the user. If the head of that link is the same as the location of the found `objects.inv`, then the `({objects_url}, None)` syntax will work. If not, then the `({docs_base_url}, {objects_url})` syntax will be needed.

OTOH, we can't be sure that all input URLs will be pasted from a page in the docs, so a _failure_ to match on this rubric ***doesn't mean*** that the root of the `objects.inv` URL ***isn't*** the correct docset root for the mapping...just that we can't be sure.

If the tail of the `objects.inv` URL is **not** `/objects.inv`, then we can be 100% sure that the mapping will not be `(..., None)`, but instead `(some_url, objectsinv_url)`.  The only way to determine `some_url` is if the user also provides a URL to an object in the docset, and then we can extract the base from that using the `Inventory` objects, presuming one matches. This might mean that it would be best to expose that base-finding function as public...

Implement as helper function in the API, exploited by the CLI? (**Yes. Standalone in a new module. DO NOT cross-implement as a method on `Inventory`, would lead to an import cycle.**) Should just need `objects_url` and `docs_link_url`? (**Yes, plus the retrieved `Inventory`.**)

----

TODO:

- [x] Convert objects.inv base extractor to public function
- [x] Compose docstrings for functions:
  - [x] objects.inv base extractor
  - [x] mapping inferrer
- [x] Implement CLI use of the new functions
  - Make sure the language is suitably tentative ("The following mapping SHOULD work for ...", "The following mapping MAY work for ...", "No candidate mapping for ... could be determined.")
- ~Add API documentation page~ Leaving the whole new module private for now (at `sphobjinv._intersphinx`)
- ~Add HOWTO docs page for finding mappings through API~ (Now, no public API)
- [x] Fix `extract_objectsinv_url_base()` (can't *just* do a blind `rpartition`... have to do the `rpartition` and then check if the `[0]` is empty, and handle accordingly)
  - Django inv is the main driver here
- [x] Add tests on some links to the sphobjinv RtD docset
- [x] Refactor to remove `_intersphinx.py`
  - [x] Add tag to commit before remove for later access if needed
- [x] Fill out coverage; add pragmas as needed
- [x] Check if need to update any usage examples, or if they're all using `.. programoutput::` and will auto-update
- [x] CHANGELOG entry
- [x] Fix #244 along the way


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9810c55..fb36ac38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,12 @@ and this project strives to adhere to
 
 #### Added
 
+  * The CLI now prints an inferred `intersphinx_mapping` entry for a remote
+    docset as part of the 'suggest' mode output, where such inference is
+    possible. The output from this mapping inference was added to the relevant
+    tests, and a couple of unit tests on some basic pieces of functionality were
+    written. ([#149](https://github.com/bskinn/sphobjinv/issues/149))
+
   * The CLI now provides considerably more information about what is happening
     with the URLs it checks when trying to retrieve a remote inventory.
     ([#99](https://github.com/bskinn/sphobjinv/issues/99), plus more)
@@ -21,8 +27,30 @@ and this project strives to adhere to
     now results in a clean error-exit, instead of an exception.
     ([#239](https://github.com/bskinn/sphobjinv/issues/239))
 
+#### Documentation
+
+  * Add new 'CLI implementation' pages for the new modules, downstream of the
+    refactoring of the CLI 'convert' and 'suggest' code.
+
+  * Revise the intro paragraph of the 'CLI usage' page to more clearly emphasize
+    the two CLI subcommands and the links to their respective docs pages.
+
 #### Internal
 
+  * Refactor CLI code to place the 'convert' and 'suggest' implementations in
+    their own modules.
+
+  * Refactor CLI 'suggest' code to the main `do_suggest()` function and a
+    handful of sub-functions.
+
+  * Rename the `log_print()` CLI helper function to the more-descriptive
+    `print_stderr()`.
+
+  * Bump development Sphinx version to v5.3.
+
+  * Bump flake8 version to >=5, due to the absorption of flake8-colors
+    colorization functionality. The flake8/tox config was updated accordingly.
+
   * Bump pre-commit black hook to v22.3.0.
 
   * Remove PyPy and Python 3.6 from Azure Pipelines test matrix.
diff --git a/README.rst b/README.rst
index e02cd334..86794f03 100644
--- a/README.rst
+++ b/README.rst
@@ -56,10 +56,12 @@ For internal cross-references, locate ``objects.inv`` within ``build/html``::
 
     $ sphobjinv suggest doc/build/html/objects.inv as_rst -st 58
 
-    205 objects in inventory.
+    214 objects in inventory.
 
     11 results found at/above current threshold of 58.
 
+    Cannot infer intersphinx_mapping from a local objects.inv.
+
       Name                                                Score
     ---------------------------------------------------  -------
     :py:property:`sphobjinv.data.SuperDataObj.as_rst`      60
@@ -80,8 +82,8 @@ The ``-s`` argument in the above shell command indicates to print the
 ``fuzzywuzzy`` match score along with each search result, and ``-t 50``
 changes the reporting threshold for the match score.
 
-For external references, just find the API documentation wherever it lives on the web,
-and pass ``sphobjinv suggest`` a URL from within the documentation set
+For external references, just find the API documentation wherever it lives on
+the web, and pass ``sphobjinv suggest`` a URL from within the documentation set
 with the ``--url/-u`` flag. For example, say I need to know how to
 cross-reference the ``linspace`` function from numpy (see
 `here <https://numpy.org/doc/1.18/reference/generated/numpy.linspace.html>`__)::
@@ -101,6 +103,10 @@ cross-reference the ``linspace`` function from numpy (see
 
     8 results found at/above current threshold of 75.
 
+    The intersphinx_mapping for this docset is LIKELY:
+
+      (https://numpy.org/doc/1.19/, None)
+
       Name                                                           Score
     --------------------------------------------------------------  -------
     :py:function:`numpy.linspace`                                     90
@@ -114,8 +120,8 @@ cross-reference the ``linspace`` function from numpy (see
 
 .. end shell command
 
-**NOTE** that the results from ``sphobjinv suggest`` are printed using the longer
-*block directives*, whereas cross-references must be composed using the
+**NOTE** that the results from ``sphobjinv suggest`` are printed using the
+longer *block directives*, whereas cross-references must be composed using the
 *inline directives*. Thus, the above ``linspace()`` function must be
 cross-referenced as ``:func:`numpy.linspace```, **not**
 ``:function:`numpy.linspace```.
@@ -143,13 +149,13 @@ inventory creation/modification::
     >>> import sphobjinv as soi
     >>> inv = soi.Inventory('doc/build/html/objects.inv')
     >>> print(inv)
-    <Inventory (fname_zlib): sphobjinv v2.2, 205 objects>
+    <Inventory (fname_zlib): sphobjinv v2.3, 214 objects>
     >>> inv.project
     'sphobjinv'
     >>> inv.version
-    '2.2'
+    '2.3'
     >>> inv.objects[0]
-    DataObjStr(name='sphobjinv.cli.core', domain='py', role='module', priority='0', uri='cli/implementation/core.html#module-$', dispname='-')
+    DataObjStr(name='sphobjinv.cli.convert', domain='py', role='module', priority='0', uri='cli/implementation/convert.html#module-$', dispname='-')
 
 The API also enables straightforward re-export of an inventory,
 for subsequent use with ``intersphinx`` cross-references.
diff --git a/doc/source/cli/convert.rst b/doc/source/cli/convert.rst
index caf15b77..5b91342d 100644
--- a/doc/source/cli/convert.rst
+++ b/doc/source/cli/convert.rst
@@ -1,11 +1,11 @@
 .. Description of convert commandline usage
 
-Command-Line Usage: "convert" Mode
-==================================
+Command-Line Usage: "convert" Subcommand
+========================================
 
 .. program:: sphobjinv convert
 
-The |cour|\ convert\ |/cour| subparser is used for all conversions of
+The |cour|\ convert\ |/cour| subcommand is used for all conversions of
 "version 2" Sphinx inventory
 files among plaintext, zlib-compressed, and (unique to |soi|) JSON formats.
 The |soi| CLI can read and write inventory data from local files
diff --git a/doc/source/cli/implementation/convert.rst b/doc/source/cli/implementation/convert.rst
new file mode 100644
index 00000000..cd68000b
--- /dev/null
+++ b/doc/source/cli/implementation/convert.rst
@@ -0,0 +1,7 @@
+.. Module API page for cli/convert.py
+
+sphobjinv.cli.convert
+=====================
+
+.. automodule:: sphobjinv.cli.convert
+    :members:
diff --git a/doc/source/cli/implementation/index.rst b/doc/source/cli/implementation/index.rst
index 55946645..a174e6f3 100644
--- a/doc/source/cli/implementation/index.rst
+++ b/doc/source/cli/implementation/index.rst
@@ -6,12 +6,14 @@ sphobjinv.cli (non-API)
 .. toctree::
     :maxdepth: 1
 
+    convert
     core
     load
     parser
     paths
+    suggest
     ui
     write
-    
 
-.. .. |argparse| replace:: :mod:`argparse`
\ No newline at end of file
+
+.. .. |argparse| replace:: :mod:`argparse`
diff --git a/doc/source/cli/implementation/suggest.rst b/doc/source/cli/implementation/suggest.rst
new file mode 100644
index 00000000..3424d1b5
--- /dev/null
+++ b/doc/source/cli/implementation/suggest.rst
@@ -0,0 +1,7 @@
+.. Module API page for cli/suggest.py
+
+sphobjinv.cli.suggest
+=====================
+
+.. automodule:: sphobjinv.cli.suggest
+    :members:
diff --git a/doc/source/cli/index.rst b/doc/source/cli/index.rst
index ae6a4a22..1974dbdc 100644
--- a/doc/source/cli/index.rst
+++ b/doc/source/cli/index.rst
@@ -3,17 +3,23 @@
 Command-Line Usage
 ==================
 
-The CLI for |soi| is implemented using two subparsers, one each for the
-:doc:`convert <convert>` and :doc:`suggest <suggest>` sub-functions.
-More information about the implementation of these features can be
-found :doc:`here <implementation/index>` and in the documentation for the
+The CLI for |soi| is implemented using two subcommands:
+
+  - A :doc:`convert <convert>` subcommand, which handles conversion of
+    inventories between supported formats (currently zlib-compressed,
+    plaintext, and JSON).
+  - A :doc:`suggest <suggest>` subcommand, which provides suggestions for
+    objects in an inventory matching a desired search term.
+
+More information about the underlying implementation of these subcommands can
+be found :doc:`here <implementation/index>` and in the documentation for the
 :class:`~sphobjinv.inventory.Inventory` object, in particular the
 :meth:`~sphobjinv.inventory.Inventory.data_file` and
 :meth:`~sphobjinv.inventory.Inventory.suggest` methods.
 
 Some notes on these CLI docs:
 
- * CLI examples are executed in a sandboxed directory pre-loaded with
+ * CLI docs examples are executed in a sandboxed directory pre-loaded with
    |cour|\ objects_attrs.inv\ |/cour| (from, e.g.,
    `here <https://github.com/bskinn/sphobjinv/blob/main/
    tests/resource/objects_attrs.inv>`__).
@@ -54,5 +60,3 @@ The options for the parent |soi| command are:
 
     "convert" Mode <convert>
     "suggest" Mode <suggest>
-
-
diff --git a/doc/source/cli/suggest.rst b/doc/source/cli/suggest.rst
index 5f2f7b8a..26cfc967 100644
--- a/doc/source/cli/suggest.rst
+++ b/doc/source/cli/suggest.rst
@@ -1,7 +1,7 @@
 .. Description of suggest commandline usage
 
-Command-Line Usage: "suggest" Mode
-==================================
+Command-Line Usage: "suggest" Subcommand
+========================================
 
 .. program:: sphobjinv suggest
 
@@ -67,7 +67,8 @@ If download of JSON files by URL is desirable, please
     Path (or URL, if :option:`--url` is specified) to file to be searched.
 
     If passed as ``-``, |soi| will attempt import of a plaintext or JSON
-    inventory from ``stdin`` (incompatible with :option:`--url`).
+    inventory from ``stdin``. This is incompatible with :option:`--url`,
+    and automatically enables :option:`--all`.
 
 .. option:: search
 
diff --git a/requirements-ci.txt b/requirements-ci.txt
index 7db3e2dd..3fc79e1f 100644
--- a/requirements-ci.txt
+++ b/requirements-ci.txt
@@ -10,7 +10,7 @@ pytest-check>=0.4
 pytest-cov
 pytest-ordering
 pytest-timeout
-sphinx==4.3.1
+sphinx==5.3.0
 sphinx-issues
 sphinx-removed-in
 sphinx-rtd-theme
diff --git a/requirements-dev.txt b/requirements-dev.txt
index d0e2f11a..6667e62b 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -15,7 +15,7 @@ pytest-ordering
 pytest-timeout
 restview
 rope
-sphinx==4.3.1
+sphinx==5.3.0
 sphinx-autobuild
 sphinx-issues
 sphinx-removed-in
diff --git a/requirements-flake8.txt b/requirements-flake8.txt
index 83d8350e..833ef9ee 100644
--- a/requirements-flake8.txt
+++ b/requirements-flake8.txt
@@ -1,11 +1,10 @@
 colorama
-flake8>=3.7
+flake8>=5
 flake8-2020
 flake8-absolute-import
 flake8-bandit
 flake8-bugbear
 flake8-builtins
-flake8-colors
 flake8-comprehensions
 flake8-docstrings>=1.3.1
 flake8-eradicate
diff --git a/requirements-rtd.txt b/requirements-rtd.txt
index 739e1e36..2195463a 100644
--- a/requirements-rtd.txt
+++ b/requirements-rtd.txt
@@ -1,5 +1,5 @@
 attrs>=19.2
-sphinx==4.3.1
+sphinx==5.3.0
 sphinx-issues
 sphinx-removed-in
 sphinx-rtd-theme
diff --git a/src/sphobjinv/cli/convert.py b/src/sphobjinv/cli/convert.py
new file mode 100644
index 00000000..48c9f9b1
--- /dev/null
+++ b/src/sphobjinv/cli/convert.py
@@ -0,0 +1,69 @@
+r"""``sphobjinv`` *module for CLI convert functionality*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    20 Oct 2022
+
+**Copyright**
+    \(c) Brian Skinn 2016-2022
+
+**Source Repository**
+    https://github.com/bskinn/sphobjinv
+
+**Documentation**
+    https://sphobjinv.readthedocs.io/en/latest
+
+**License**
+    The MIT License; see |license_txt|_ for full license terms
+
+**Members**
+
+"""
+
+from sphobjinv.cli.parser import PrsConst
+from sphobjinv.cli.write import write_file, write_stdout
+
+
+def do_convert(inv, in_path, params):
+    r"""Carry out the conversion operation, including writing output.
+
+    If |cli:OVERWRITE| is passed and the output file
+    (the default location, or as passed to |cli:OUTFILE|)
+    exists, it will be overwritten without a prompt. Otherwise,
+    the user will be queried if it is desired to overwrite
+    the existing file.
+
+    If |cli:QUIET| is passed, nothing will be
+    printed to |cour|\ stdout\ |/cour|
+    (potentially useful for scripting),
+    and any existing output file will be overwritten
+    without prompting.
+
+    Parameters
+    ----------
+    inv
+
+        |Inventory| -- Inventory object to be output in the format
+        indicated by |cli:MODE|.
+
+    in_path
+
+        |str| -- For a local input file, its absolute path.
+        For a URL, the (possibly truncated) URL text.
+
+    params
+
+        |dict| -- Parameters/values mapping from the active subparser
+
+    """
+    if params[PrsConst.OUTFILE] == "-" or (
+        params[PrsConst.INFILE] == "-" and params[PrsConst.OUTFILE] is None
+    ):
+        write_stdout(inv, params)
+    else:
+        write_file(inv, in_path, params)
diff --git a/src/sphobjinv/cli/core.py b/src/sphobjinv/cli/core.py
index f7f311f0..08316fb6 100644
--- a/src/sphobjinv/cli/core.py
+++ b/src/sphobjinv/cli/core.py
@@ -27,155 +27,11 @@
 
 import sys
 
+from sphobjinv.cli.convert import do_convert
 from sphobjinv.cli.load import inv_local, inv_stdin, inv_url
 from sphobjinv.cli.parser import getparser, PrsConst
-from sphobjinv.cli.ui import log_print, yesno_prompt
-from sphobjinv.cli.write import write_file, write_stdout
-
-
-def do_convert(inv, in_path, params):
-    r"""Carry out the conversion operation, including writing output.
-
-    If |cli:OVERWRITE| is passed and the output file
-    (the default location, or as passed to |cli:OUTFILE|)
-    exists, it will be overwritten without a prompt. Otherwise,
-    the user will be queried if it is desired to overwrite
-    the existing file.
-
-    If |cli:QUIET| is passed, nothing will be
-    printed to |cour|\ stdout\ |/cour|
-    (potentially useful for scripting),
-    and any existing output file will be overwritten
-    without prompting.
-
-    Parameters
-    ----------
-    inv
-
-        |Inventory| -- Inventory object to be output in the format
-        indicated by |cli:MODE|.
-
-    in_path
-
-        |str| -- For a local input file, its absolute path.
-        For a URL, the (possibly truncated) URL text.
-
-    params
-
-        |dict| -- Parameters/values mapping from the active subparser
-
-    """
-    if params[PrsConst.OUTFILE] == "-" or (
-        params[PrsConst.INFILE] == "-" and params[PrsConst.OUTFILE] is None
-    ):
-        write_stdout(inv, params)
-    else:
-        write_file(inv, in_path, params)
-
-
-def do_suggest(inv, params):
-    r"""Perform the suggest call and output the results.
-
-    Results are printed one per line.
-
-    If neither |cli:INDEX| nor |cli:SCORE| is specified,
-    the results are output without a header.
-    If either or both are specified,
-    the results are output in a lightweight tabular format.
-
-    If the number of results exceeds
-    |cli:SUGGEST_CONFIRM_LENGTH|,
-    the user will be queried whether to display
-    all of the returned results
-    unless |cli:ALL| is specified.
-
-    No |cli:QUIET| option is available here, since
-    a silent mode for suggestion output is nonsensical.
-
-    Parameters
-    ----------
-    inv
-
-        |Inventory| -- Inventory object to be output in the format
-        indicated by |cli:MODE|.
-
-    params
-
-        |dict| -- Parameters/values mapping from the active subparser
-
-    """
-    with_index = params[PrsConst.INDEX]
-    with_score = params[PrsConst.SCORE]
-    results = inv.suggest(
-        params[PrsConst.SEARCH],
-        thresh=params[PrsConst.THRESH],
-        with_index=with_index,
-        with_score=with_score,
-    )
-
-    log_print(f"{inv.count} objects in inventory.\n", params)
-
-    if len(results) == 0:
-        log_print(
-            (
-                "No results found with score at/above current threshold of "
-                f"{params[PrsConst.THRESH]}."
-            ),
-            params,
-        )
-        return
-    else:
-        log_print(
-            (
-                f"{len(results)} results found at/above current threshold of "
-                f"{params[PrsConst.THRESH]}.\n"
-            ),
-            params,
-        )
-
-    # Query if the results are long enough, but not if '--all' has been
-    # passed or if the data is coming via stdin (reading from stdin breaks
-    # the terminal interactions)
-    if (
-        len(results) > PrsConst.SUGGEST_CONFIRM_LENGTH
-        and not params[PrsConst.ALL]
-        and params[PrsConst.INFILE] != "-"
-    ):
-        resp = yesno_prompt(f"Display all {len(results)} results (Y/N)?")
-        if resp.lower() == "n":
-            log_print("\nExiting...", params)
-            sys.exit(0)
-
-    # Field widths in output
-    score_width = 7
-    index_width = 7
-
-    if with_index or with_score:
-        rst_width = max(len(res[0]) for res in results)
-    else:
-        rst_width = max(len(res) for res in results)
-
-    rst_width += 2
-
-    if with_index:
-        if with_score:
-            fmt = f"{{0: <{rst_width}}}  {{1: ^{score_width}}}  {{2: ^{index_width}}}"
-            print(fmt.format("  Name", "Score", "Index"))
-            print(fmt.format("-" * rst_width, "-" * score_width, "-" * index_width))
-            print("\n".join(fmt.format(*_) for _ in results))
-        else:
-            fmt = f"{{0: <{rst_width}}}  {{1: ^{index_width}}}"
-            print(fmt.format("  Name", "Index"))
-            print(fmt.format("-" * rst_width, "-" * index_width))
-            print("\n".join(fmt.format(*_) for _ in results))
-    else:
-        if with_score:
-            fmt = f"{{0: <{rst_width}}}  {{1: ^{score_width}}}"
-            print(fmt.format("  Name", "Score"))
-            print(fmt.format("-" * rst_width, "-" * score_width))
-            print("\n".join(fmt.format(*_) for _ in results))
-        else:
-            print("\n".join(str(_) for _ in results))
+from sphobjinv.cli.suggest import do_suggest
+from sphobjinv.cli.ui import print_stderr
 
 
 def main():
@@ -188,7 +44,8 @@ def main():
     Creates the |Inventory| from the indicated source
     and method.
 
-    Invokes :func:`do_convert` or :func:`do_suggest`
+    Invokes :func:`~sphobjinv.cli.convert.do_convert` or
+    :func:`~sphobjinv.cli.suggest.do_suggest`
     per the subparser name stored in |cli:SUBPARSER_NAME|.
 
     """
@@ -196,9 +53,11 @@ def main():
     if len(sys.argv) == 1:
         sys.argv.append("-h")
 
-    # Parse commandline arguments
+    # Parse commandline arguments, discarding any unknown ones
+    # I forget why I set it up to discard these, it might be
+    # more confusing than it's worth....
     prs = getparser()
-    ns, args_left = prs.parse_known_args()
+    ns, _ = prs.parse_known_args()
     params = vars(ns)
 
     # Print version &c. and exit if indicated
@@ -212,7 +71,7 @@ def main():
 
     # Regardless of mode, insert extra blank line
     # for cosmetics
-    log_print(" ", params)
+    print_stderr(" ", params)
 
     # Generate the input Inventory based on --url or stdio or file.
     # These inventory-load functions should call
@@ -234,7 +93,7 @@ def main():
         do_suggest(inv, params)
 
     # Cosmetic final blank line
-    log_print(" ", params)
+    print_stderr(" ", params)
 
     # Clean exit
     sys.exit(0)
diff --git a/src/sphobjinv/cli/load.py b/src/sphobjinv/cli/load.py
index fa87f226..c5105cd3 100644
--- a/src/sphobjinv/cli/load.py
+++ b/src/sphobjinv/cli/load.py
@@ -35,7 +35,7 @@
 from sphobjinv import Inventory, readjson, urlwalk, VersionError
 from sphobjinv.cli.parser import PrsConst
 from sphobjinv.cli.paths import resolve_inpath
-from sphobjinv.cli.ui import err_format, log_print
+from sphobjinv.cli.ui import err_format, print_stderr
 
 
 def import_infile(in_path):
@@ -107,14 +107,14 @@ def inv_local(params):
     try:
         in_path = resolve_inpath(params[PrsConst.INFILE])
     except Exception as e:
-        log_print("\nError while parsing input file path:", params)
-        log_print(err_format(e), params)
+        print_stderr("\nError while parsing input file path:", params)
+        print_stderr(err_format(e), params)
         sys.exit(1)
 
     # Attempt import
     inv = import_infile(in_path)
     if inv is None:
-        log_print("\nError: Unrecognized file format", params)
+        print_stderr("\nError: Unrecognized file format", params)
         sys.exit(1)
 
     return inv, in_path
@@ -163,13 +163,13 @@ def attempt_inv_load(url, params):
         try:
             inv = Inventory(url=url)
         except HTTPError as e:
-            log_print(f"  ... HTTP error: {e.code} {e.reason}.", params)
+            print_stderr(f"  ... HTTP error: {e.code} {e.reason}.", params)
         except URLError:  # pragma: no cover
-            log_print("  ... error attempting to retrieve URL.", params)
+            print_stderr("  ... error attempting to retrieve URL.", params)
         except VersionError:
-            log_print("  ... no recognized inventory.", params)
+            print_stderr("  ... no recognized inventory.", params)
         except ValueError:
-            log_print(
+            print_stderr(
                 (
                     "  ... file found but inventory could not be loaded. "
                     "(Did you forget https:// ?)"
@@ -177,33 +177,33 @@ def attempt_inv_load(url, params):
                 params,
             )
         else:
-            log_print("  ... inventory found.", params)
+            print_stderr("  ... inventory found.", params)
 
         return inv
 
     # Disallow --url mode on local files
     if in_file.startswith("file:/"):
-        log_print("\nError: URL mode on local file is invalid", params)
+        print_stderr("\nError: URL mode on local file is invalid", params)
         sys.exit(1)
 
-    log_print(f"Attempting {in_file} ...", params)
+    print_stderr(f"Attempting {in_file} ...", params)
     inv = attempt_inv_load(in_file, params)
 
     if inv:
         url = in_file
     else:
         for url in urlwalk(in_file):
-            log_print(f'Attempting "{url}" ...', params)
+            print_stderr(f'Attempting "{url}" ...', params)
             inv = attempt_inv_load(url, params)
             if inv:
                 break
 
     # Cosmetic line break
-    log_print(" ", params)
+    print_stderr(" ", params)
 
     # Success or no?
     if not inv:
-        log_print("No inventory found!", params)
+        print_stderr("No inventory found!", params)
         sys.exit(1)
 
     params.update({PrsConst.FOUND_URL: url})
@@ -250,5 +250,5 @@ def inv_stdin(params):
     except (AttributeError, UnicodeEncodeError, TypeError):
         pass
 
-    log_print("Invalid plaintext or JSON inventory format.", params)
+    print_stderr("Invalid plaintext or JSON inventory format.", params)
     sys.exit(1)
diff --git a/src/sphobjinv/cli/suggest.py b/src/sphobjinv/cli/suggest.py
new file mode 100644
index 00000000..dc949a5b
--- /dev/null
+++ b/src/sphobjinv/cli/suggest.py
@@ -0,0 +1,282 @@
+r"""``sphobjinv`` *module for CLI suggest functionality*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    20 Oct 2022
+
+**Copyright**
+    \(c) Brian Skinn 2016-2022
+
+**Source Repository**
+    https://github.com/bskinn/sphobjinv
+
+**Documentation**
+    https://sphobjinv.readthedocs.io/en/latest
+
+**License**
+    The MIT License; see |license_txt|_ for full license terms
+
+**Members**
+
+"""
+
+import sys
+import urllib.parse as urlparse
+
+from sphobjinv.cli.parser import PrsConst
+from sphobjinv.cli.ui import print_stderr, yesno_prompt
+
+
+def do_suggest(inv, params):
+    r"""Perform the suggest call and output the results.
+
+    Results are printed one per line.
+
+    If neither |cli:INDEX| nor |cli:SCORE| is specified,
+    the results are output without a header.
+    If either or both are specified,
+    the results are output in a lightweight tabular format.
+
+    If the number of results exceeds
+    |cli:SUGGEST_CONFIRM_LENGTH|,
+    the user will be queried whether to display
+    all of the returned results
+    unless |cli:ALL| is specified.
+
+    No |cli:QUIET| option is available here, since
+    a silent mode for suggestion output is nonsensical.
+
+    Parameters
+    ----------
+    inv
+
+        |Inventory| -- Inventory object to be output in the format
+        indicated by |cli:MODE|.
+
+    params
+
+        |dict| -- Parameters/values mapping from the active subparser
+
+    """
+    with_index = params[PrsConst.INDEX]
+    with_score = params[PrsConst.SCORE]
+    results = inv.suggest(
+        params[PrsConst.SEARCH],
+        thresh=params[PrsConst.THRESH],
+        with_index=with_index,
+        with_score=with_score,
+    )
+
+    print_stderr(f"{inv.count} objects in inventory.\n", params)
+
+    print_stderr_result_count(params, results)
+
+    print_stderr_inferred_mapping(params)
+
+    # The query here for printing the full list only occurs in some
+    # circumstances; see the function docstring.
+    confirm_print_if_long_list(params, results)
+
+    print_results_table(with_index, with_score, results)
+
+
+def print_stderr_result_count(params, results):
+    """Report the count of found objects from the suggest search."""
+    if len(results) == 0:
+        print_stderr(
+            (
+                "No results found with score at/above current threshold of "
+                f"{params[PrsConst.THRESH]}."
+            ),
+            params,
+        )
+        sys.exit(0)
+    else:
+        print_stderr(
+            (
+                f"{len(results)} result"
+                f"{'' if len(results) == 1 else 's'}"
+                " found at/above current threshold of "
+                f"{params[PrsConst.THRESH]}.\n"
+            ),
+            params,
+        )
+
+
+def confirm_print_if_long_list(params, results):
+    """Check if the results list is too long and query user if to proceed.
+
+    Skip the check if ``--all`` has been passed.
+
+    Also skip the check if receiving data from ``stdin``, as a stream
+    interaction here fouls the data flow...I forget exactly how.
+
+    """
+    if (
+        len(results) > PrsConst.SUGGEST_CONFIRM_LENGTH
+        and not params[PrsConst.ALL]
+        and params[PrsConst.INFILE] != "-"
+    ):
+        resp = yesno_prompt(f"Display all {len(results)} results (Y/N)?")
+        if resp.lower() == "n":
+            print_stderr("\nExiting...", params)
+            sys.exit(0)
+
+        # Extra cosmetic newline
+        print_stderr("", params)
+
+
+def print_results_table(with_index, with_score, results):
+    """Prepare and print the table of suggest search results."""
+    # Field widths in output
+    score_width = 7
+    index_width = 7
+
+    # This is necessary because the results are returned as a
+    # list[str] if neither index nor score is included; but are
+    # returned as a list[tuple[...]] if one or both of index/score
+    # is included.
+    if with_index or with_score:
+        rst_width = max(len(res[0]) for res in results)
+    else:
+        rst_width = max(len(res) for res in results)
+
+    rst_width += 2
+
+    # For now, in each case the formatting for each row is dynamically
+    # stored in `fmt`, and then `fmt` is used to actually format each row.
+
+    # TODO: Consider replacing this with a *real* table formatting tool
+    if with_index:
+        if with_score:
+            fmt = f"{{0: <{rst_width}}}  {{1: ^{score_width}}}  {{2: ^{index_width}}}"
+            print(fmt.format("  Name", "Score", "Index"))
+            print(fmt.format("-" * rst_width, "-" * score_width, "-" * index_width))
+            print("\n".join(fmt.format(*res) for res in results))
+        else:
+            fmt = f"{{0: <{rst_width}}}  {{1: ^{index_width}}}"
+            print(fmt.format("  Name", "Index"))
+            print(fmt.format("-" * rst_width, "-" * index_width))
+            print("\n".join(fmt.format(*res) for res in results))
+    else:
+        if with_score:
+            fmt = f"{{0: <{rst_width}}}  {{1: ^{score_width}}}"
+            print(fmt.format("  Name", "Score"))
+            print(fmt.format("-" * rst_width, "-" * score_width))
+            print("\n".join(fmt.format(*res) for res in results))
+        else:
+            print("\n".join(str(res) for res in results))
+
+
+def print_stderr_inferred_mapping(params):
+    """Print as good of an `intersphinx_mapping` entry as can be determined."""
+    if not params[PrsConst.URL]:
+        print_stderr(
+            "Cannot infer intersphinx_mapping from a local objects.inv.\n", params
+        )
+        return
+
+    input_url = params[PrsConst.INFILE]
+    inv_url = params[PrsConst.FOUND_URL]
+
+    reduced_inv_url = extract_objectsinv_url_base(inv_url)
+
+    if input_url == inv_url:
+        # User provided an exact URL to an inventory
+        # The tail of input_url thus should not match anything in the inventory,
+        # so we can't say anything about what the URL base of the docset is.
+        if inv_url == reduced_inv_url:
+            # The inventory URL does not end with the standard /objects.inv,
+            # so we don't even have *that* point of reference to work with.
+            print_stderr(
+                (
+                    "Cannot infer intersphinx_mapping for this docset using "
+                    "the provided input URL.\n"
+                ),
+                params,
+            )
+        else:
+            # The inventory URL *does* end with standard /objects.inv,
+            # so there's a reasonable chance we know the mapping.
+            print_stderr(
+                (
+                    "The intersphinx_mapping for this docset is PROBABLY:\n\n"
+                    f"  ({reduced_inv_url}, None)\n"
+                ),
+                params,
+            )
+    else:
+        # User provided a URL from the docset that reduced to a base, atop which
+        # an inventory at .../objects.inv was found.
+        if inv_url == reduced_inv_url:
+            # This should never happen, because the only way we should be in
+            # this outer else is if inv_url *DOES* end in /objects.inv
+            print_stderr(  # pragma: no cover
+                (
+                    "ERROR: Inconsistent internal state "
+                    "during intersphinx_mapping inference.\n"
+                ),
+                params,
+            )
+        else:
+            # Here we're *very* confident that we've go the mapping.
+            print_stderr(
+                (
+                    "The intersphinx_mapping for this docset is LIKELY:\n\n"
+                    f"  ({reduced_inv_url}, None)\n"
+                ),
+                params,
+            )
+
+
+def extract_objectsinv_url_base(objectsinv_url):
+    """Infer a base URL for the provided ``objects.inv`` inventory URL.
+
+    If this function is a no-op, then the resulting base is NOT RELIABLE,
+    because the URL did not end with ``/objects.inv``.
+
+    If this function *does* make a change, then the resulting base is
+    RELATIVELY RELIABLE, since the only change that should occur is
+    stripping of a ``/objects.inv`` suffix, which strongly implies but
+    does not guarantee that the URL came from a Sphinx docset in the
+    standard multi-page HTML layout.
+
+    Parameters
+    ----------
+    objectsinv_url
+
+        |str| -- URL from which to attempt docset base inference
+
+    Returns
+    -------
+    trimmed
+
+        |str| -- URL after attempt to trim a trailing ``/objects.inv``
+
+    """
+    trimmed = _strip_url_to_netloc_path(objectsinv_url, with_scheme=True)
+    base = trimmed.rpartition("/objects.inv")[0]
+
+    if base:
+        return f"{base}/"
+    else:
+        return objectsinv_url
+
+
+def _strip_url_to_netloc_path(url, *, with_scheme=False):
+    """Reduce a URL to only netloc and path, optionally with scheme."""
+    parts = urlparse.urlsplit(url)
+    trimmed = parts._replace(
+        query="",
+        fragment="",
+    )
+
+    if not with_scheme:
+        trimmed = trimmed._replace(scheme="")
+
+    return urlparse.urlunsplit(trimmed)
diff --git a/src/sphobjinv/cli/ui.py b/src/sphobjinv/cli/ui.py
index 168a1a6c..21ee6959 100644
--- a/src/sphobjinv/cli/ui.py
+++ b/src/sphobjinv/cli/ui.py
@@ -30,7 +30,7 @@
 from sphobjinv.cli.parser import PrsConst
 
 
-def log_print(thing, params, *, end="\n"):
+def print_stderr(thing, params, *, end="\n"):
     r"""Print `thing` to stderr if not in quiet mode.
 
     Quiet mode is indicated by the value at the |cli:QUIET| key
diff --git a/src/sphobjinv/cli/write.py b/src/sphobjinv/cli/write.py
index e828c49f..45386395 100644
--- a/src/sphobjinv/cli/write.py
+++ b/src/sphobjinv/cli/write.py
@@ -31,7 +31,7 @@
 
 from sphobjinv.cli.parser import PrsConst
 from sphobjinv.cli.paths import resolve_outpath
-from sphobjinv.cli.ui import err_format, log_print, yesno_prompt
+from sphobjinv.cli.ui import err_format, print_stderr, yesno_prompt
 from sphobjinv.fileops import writebytes, writejson
 from sphobjinv.zlib import compress
 
@@ -193,7 +193,7 @@ def write_stdout(inv, params):
 
         print(json.dumps(json_dict))
     else:
-        log_print("Error: Only plaintext and JSON can be emitted to stdout.", params)
+        print_stderr("Error: Only plaintext and JSON can be emitted to stdout.", params)
         sys.exit(1)
 
 
@@ -229,15 +229,15 @@ def write_file(inv, in_path, params):
         out_path = resolve_outpath(params[PrsConst.OUTFILE], in_path, params)
     except Exception as e:  # pragma: no cover
         # This may not actually be reachable except in exceptional situations
-        log_print("\nError while constructing output file path:", params)
-        log_print(err_format(e), params)
+        print_stderr("\nError while constructing output file path:", params)
+        print_stderr(err_format(e), params)
         sys.exit(1)
 
     # If exists, must handle overwrite
     if os.path.isfile(out_path) and not params[PrsConst.OVERWRITE]:
         if params[PrsConst.INFILE] == "-":
             # If reading from stdin, just alert and don't overwrite
-            log_print("\nFile exists. To overwrite, supply '-o'. Exiting...", params)
+            print_stderr("\nFile exists. To overwrite, supply '-o'. Exiting...", params)
             sys.exit(0)
         # This could be written w/o nesting via elif, but would be harder to read.
         else:
@@ -245,7 +245,7 @@ def write_file(inv, in_path, params):
                 # If not a stdin read, confirm overwrite; or, just clobber if QUIET
                 resp = yesno_prompt("File exists. Overwrite (Y/N)? ")
                 if resp.lower() == "n":
-                    log_print("\nExiting...", params)
+                    print_stderr("\nExiting...", params)
                     sys.exit(0)
 
     # Write the output file
@@ -267,12 +267,12 @@ def write_file(inv, in_path, params):
         if mode == PrsConst.JSON:
             write_json(inv, out_path, params)
     except Exception as e:
-        log_print("\nError during write of output file:", params)
-        log_print(err_format(e), params)
+        print_stderr("\nError during write of output file:", params)
+        print_stderr(err_format(e), params)
         sys.exit(1)
 
     # Report success, if not QUIET
-    log_print(
+    print_stderr(
         "Conversion completed.\n"
         f"'{in_path if in_path else 'stdin'}' converted to '{out_path}' ({mode}).",
         params,
diff --git a/src/sphobjinv/error.py b/src/sphobjinv/error.py
index 6731d5cd..939ac1ca 100644
--- a/src/sphobjinv/error.py
+++ b/src/sphobjinv/error.py
@@ -37,3 +37,5 @@ class VersionError(SphobjinvError):
     |objects.inv| files (see :doc:`here </syntax>`).
 
     """
+
+    # TODO: Add SOI prefix to this class name as part of the exceptions refactor
diff --git a/src/sphobjinv/version.py b/src/sphobjinv/version.py
index 33537b3b..211308cc 100644
--- a/src/sphobjinv/version.py
+++ b/src/sphobjinv/version.py
@@ -25,4 +25,4 @@
 
 """
 
-__version__ = "2.2.3.dev0"
+__version__ = "2.3.dev0"
diff --git a/tox.ini b/tox.ini
index 707cb204..6ff6b857 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,7 @@ isolated_build=True
 envlist=
     py3{6,7,8,9,10,11}-sphx_latest-attrs_latest-jsch_latest
     py310-sphx_dev-attrs_dev-jsch_dev
-    py310-sphx_{1_6_x,1_x,2_x,3_x,dev}-attrs_latest-jsch_latest
+    py310-sphx_{1_6_x,1_x,2_x,3_x,4_x,dev}-attrs_latest-jsch_latest
     py310-sphx_latest-attrs_{19_2,19_3,20_1,20_2,20_3,21_2,21_3,dev}-jsch_latest
     py310-sphx_latest-attrs_latest-jsch_{3_0,3_1,3_2,4_0,4_1,dev}
     py36-sphx_1_6_x-attrs_19_2-jsch_3_0
@@ -23,6 +23,7 @@ deps=
     sphx_1_x:     sphinx<2
     sphx_2_x:     sphinx<3
     sphx_3_x:     sphinx<4
+    sphx_4_x:     sphinx<5
     sphx_2_3_1:   sphinx==2.3.1
     sphx_2_4_0:   sphinx==2.4.0
     sphx_3_2_1:   sphinx==3.2.1
@@ -75,7 +76,7 @@ basepython=
 skip_install=True
 deps=-rrequirements-flake8.txt
 commands=
-    python -c 'import sys, colorama; from flake8.main.cli import main; colorama.init(); sys.argv = ["flake8", "conftest.py", "tests", "src"]; main()'
+    flake8 conftest.py src tests
 
 [testenv:flake8-noqa]
 skip_install=True
@@ -88,7 +89,7 @@ commands=
 skip_install=True
 deps=interrogate
 commands=
-    interrogate conftest.py tests src
+    interrogate {posargs} conftest.py tests src
 
 [testenv:sdist_install]
 commands=
@@ -101,6 +102,7 @@ markers =
   nonloc: Tests requiring Internet access
   cli: Command-line interface tests
   api: Direct API tests
+  intersphinx: Tests on intersphinx-related functionality
   readme: Testing shell examples in README.rst
   fixture: Trivial tests for test suite fixtures
   testall: Tests that use *all* objects_xyz.inv files in tests/resource, if --testall is specified
@@ -123,7 +125,7 @@ exclude =
 ignore = W503
 show_source = True
 max_line_length = 88
-format = ${cyan}%(path)s${reset}:${yellow}%(row)d${reset}:${green}%(col)d${reset} ${red}(%(code)s)${reset} %(text)s
+format = %(cyan)s%(path)s%(reset)s:%(yellow)s%(row)d%(reset)s:%(green)s%(col)d%(reset)s %(red)s(%(code)s)%(reset)s %(text)s
 rst-roles =
     attr,
     class,
@@ -133,6 +135,7 @@ rst-roles =
     func,
     meth,
     mod,
+    option,
     ref,
 rst-directives =
     doctest,


Test patch:
diff --git a/tests/test_api_fail.py b/tests/test_api_fail.py
index 0dc975ad..aff99b11 100644
--- a/tests/test_api_fail.py
+++ b/tests/test_api_fail.py
@@ -178,7 +178,7 @@ def test_apifail_inventory_no_object_invs(self, check):
             soi.Inventory(d)
 
     def test_apifail_compressed_inv_with_win_newlines(self, unix2dos, res_cmp):
-        """Confirm that a compressed inventory with Windows newlines does not decompress.
+        """Confirm that a compressed inventory with Windows newlines doesn't decompress.
 
         This should *never* happen, except in a pathological circumstance where
         unix2dos was specifically run on a compressed inventory.
diff --git a/tests/test_cli_nonlocal.py b/tests/test_cli_nonlocal.py
index 01fa1f9a..3861fff8 100644
--- a/tests/test_cli_nonlocal.py
+++ b/tests/test_cli_nonlocal.py
@@ -166,7 +166,7 @@ class TestSuggest:
 
     @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
     def test_cli_suggest_from_url(self, misc_info, run_cmdline_test):
-        """Confirm name-only suggest works from URL."""
+        """Confirm reST-only suggest output works from URL."""
         with stdio_mgr() as (in_, out_, err_):
             run_cmdline_test(
                 [
@@ -180,29 +180,48 @@ def test_cli_suggest_from_url(self, misc_info, run_cmdline_test):
             )
             assert p_instance_of.search(out_.getvalue())
 
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://sphobjinv.readthedocs.io/en/v2.0/modules/",
+            "http://sphobjinv.readthedocs.io/en/v2.0/modules/cmdline.html",
+            (
+                "http://sphobjinv.readthedocs.io/en/v2.0/modules/"
+                "cmdline.html#sphobjinv.cmdline.do_convert"
+            ),
+        ],
+    )
     @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-    def test_cli_suggest_from_dir_noanchor(self, run_cmdline_test):
-        """Confirm name-only suggest works from docpage URL."""
-        url = "http://sphobjinv.readthedocs.io/en/v2.0/modules/"
+    def test_cli_suggest_from_docset_urls(self, url, run_cmdline_test, check):
+        """Confirm reST-only suggest output works from URLs within a docset."""
         with stdio_mgr() as (in_, out_, err_):
             run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
-            assert p_inventory.search(out_.getvalue())
+
+            check.is_true(p_inventory.search(out_.getvalue()))
+            check.is_in("LIKELY", err_.getvalue())
+            check.is_in(
+                "(http://sphobjinv.readthedocs.io/en/v2.0/, None)", err_.getvalue()
+            )
 
     @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-    def test_cli_suggest_from_page_noanchor(self, run_cmdline_test):
-        """Confirm name-only suggest works from docpage URL."""
-        url = "http://sphobjinv.readthedocs.io/en/v2.0/modules/cmdline.html"
+    def test_cli_suggest_from_typical_objinv_url(self, run_cmdline_test, check):
+        """Confirm reST-only suggest works for direct objects.inv URL."""
+        url = "http://sphobjinv.readthedocs.io/en/v2.0/objects.inv"
         with stdio_mgr() as (in_, out_, err_):
             run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
-            assert p_inventory.search(out_.getvalue())
+
+            check.is_true(p_inventory.search(out_.getvalue()))
+            check.is_in("PROBABLY", err_.getvalue())
+            check.is_in(
+                "(http://sphobjinv.readthedocs.io/en/v2.0/, None)", err_.getvalue()
+            )
 
     @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-    def test_cli_suggest_from_page_withanchor(self, run_cmdline_test):
-        """Confirm name-only suggest works from docpage URL."""
-        url = (
-            "http://sphobjinv.readthedocs.io/en/v2.0/modules/"
-            "cmdline.html#sphobjinv.cmdline.do_convert"
-        )
+    def test_cli_suggest_from_django_objinv_url(self, run_cmdline_test, check):
+        """Confirm reST-only suggest works for direct objects.inv URL."""
+        url = "https://docs.djangoproject.com/en/4.1/_objects/"
         with stdio_mgr() as (in_, out_, err_):
-            run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
-            assert p_inventory.search(out_.getvalue())
+            run_cmdline_test(["suggest", "-u", url, "route", "-a"])
+
+            check.is_true(re.search("DATABASE_ROUTERS", out_.getvalue()))
+            check.is_in("Cannot infer intersphinx_mapping", err_.getvalue())
diff --git a/tests/test_flake8_ext.py b/tests/test_flake8_ext.py
index 00bae72f..a13fa20d 100644
--- a/tests/test_flake8_ext.py
+++ b/tests/test_flake8_ext.py
@@ -58,7 +58,7 @@ def test_flake8_version_output(check):
 
     # This is fragile if anything ends up not having a prefix that needs
     # stripping
-    plugins = [p.partition("-")[-1] for p in plugins]
+    plugins = [p.partition("flake8-")[-1] for p in plugins]
 
     flake8_ver_output = sp.check_output(  # noqa: S607,S603
         ["flake8", "--version"], universal_newlines=True
@@ -66,4 +66,4 @@ def test_flake8_version_output(check):
 
     for p in plugins:
         with check.check(msg=p):
-            assert p in flake8_ver_output.replace("_", "-")
+            assert p in flake8_ver_output.replace("_", "-").replace("\n", "")
diff --git a/tests/test_intersphinx.py b/tests/test_intersphinx.py
new file mode 100644
index 00000000..925f07cf
--- /dev/null
+++ b/tests/test_intersphinx.py
@@ -0,0 +1,76 @@
+r"""*Tests for intersphinx-related functionality for* ``sphobjinv``.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    21 Jun 2022
+
+**Copyright**
+    \(c) Brian Skinn 2016-2022
+
+**Source Repository**
+    http://www.github.com/bskinn/sphobjinv
+
+**Documentation**
+    http://sphobjinv.readthedocs.io
+
+**License**
+    The MIT License; see |license_txt|_ for full license terms
+
+**Members**
+
+"""
+
+import pytest
+
+import sphobjinv.cli.suggest as soi_cli_suggest
+
+
+pytestmark = [pytest.mark.intersphinx, pytest.mark.local]
+
+
+@pytest.mark.parametrize(
+    ("uri", "trimmed", "with_scheme"),
+    [
+        ("cli/implementation/parser.html#$", "cli/implementation/parser.html", False),
+        (
+            (
+                "https://sphobjinv.readthedocs.io/en/stable/api/"
+                "enum.html#sphobjinv.enum.HeaderFields"
+            ),
+            "//sphobjinv.readthedocs.io/en/stable/api/enum.html",
+            False,
+        ),
+        (
+            (
+                "https://sphobjinv.readthedocs.io/en/stable/api/"
+                "enum.html#sphobjinv.enum.HeaderFields"
+            ),
+            "https://sphobjinv.readthedocs.io/en/stable/api/enum.html",
+            True,
+        ),
+    ],
+)
+def test_strip_netloc_path(uri, trimmed, with_scheme):
+    """Confirm that object URI trimming is working."""
+    assert trimmed == soi_cli_suggest._strip_url_to_netloc_path(
+        uri, with_scheme=with_scheme
+    )
+
+
+@pytest.mark.parametrize(
+    ("url", "trimmed"),
+    [
+        (
+            "https://sphobjinv.readthedocs.io/en/latest/objects.inv",
+            "https://sphobjinv.readthedocs.io/en/latest/",
+        )
+    ],
+)
+def test_extract_objinv_url_base(url, trimmed):
+    """Confirm that inventory URL trimming is working."""
+    assert trimmed == soi_cli_suggest.extract_objectsinv_url_base(url)
diff --git a/tests/test_readme.py b/tests/test_readme.py
index 9070cd3a..a512738e 100644
--- a/tests/test_readme.py
+++ b/tests/test_readme.py
@@ -30,6 +30,7 @@
 import re
 import shlex
 import subprocess as sp  # noqa: S404
+import sys
 from pathlib import Path
 
 
@@ -61,15 +62,18 @@
     "pypy" in platform.python_implementation().lower(),
     reason="Inconsistent suggest results on PyPy",
 )
-def test_readme_shell_cmds(ensure_doc_scratch, check):
+def test_readme_shell_cmds(ensure_doc_scratch, is_win, check):
     """Perform testing on README shell command examples."""
+    if is_win and sys.version_info < (3, 9):  # pragma: no cover
+        pytest.skip("Windows mishandles stdout/stderr for Python < 3.9")
+
     text = Path("README.rst").read_text()
 
     chk = dt.OutputChecker()
 
     dt_flags = dt.ELLIPSIS | dt.NORMALIZE_WHITESPACE
 
-    for i, mch in enumerate(p_shell.finditer(text)):
+    for mch in p_shell.finditer(text):
         cmd = mch.group("cmd")
         out = mch.group("out")
 
@@ -81,5 +85,5 @@ def test_readme_shell_cmds(ensure_doc_scratch, check):
 
         msg = "\n\nExpected:\n" + out + "\n\nGot:\n" + result
 
-        with check.check(msg=f"match_{i}"):
+        with check.check():
             assert chk.check_output(out, result, dt_flags), msg