Repository: bskinn/sphobjinv (language: python)

Issue:
Fix new flake8-pie lints

**Is the PR a fix or a feature?**
Lint fix

**Describe the changes in the PR**
- Removes some unnecessary `pass` statements and explicit inheritance from `object`
- Some internal `type(...) is ...` checks were replaced with `isinstance(...)`

**Does this PR close any issues?**
No.

**Does the PR change/update the following, if relevant?**
<!--
  All changes to code, even internal-only changes, should have
  a CHANGELOG entry. Documentation changes are usually only required
  if public-facing code or CLI behavior changes. Test coverage
  should be maintained at 100%, and all lints should be obeyed.

  Please specifically note if you want to use "# noqa",
  "# pragma: no cover", or similar flags/settings to disable
  any coverage/linting.
-->

- ~Documentation~
- ~Tests~
- [x] CHANGELOG


Add known POSIX distributions packaging sphobjinv to docs
Might as well advise potential users that it's available as a system package!

Should add it to README, also.
Complete repo information documents & metadata
- [x] `CONTRIBUTING.md`
- [x] `ISSUE_TEMPLATE`
- [x] `PR_TEMPLATE`
- [x] Project URLs in `setup.py`
- ~Tidelift mention at bottom of README~ (not required, so not doing for now)
Reverse loosening of `priority` constraints in regex & tests
Per discussion at #181, Sphinx will silently ignore object entries with non-integer priority values

- [x] Code
- [x] Tests
  - [x] Remove `objects.inv` files with non-integer `priority` from main tests
  - ~Establish consistent nomenclature to identify `objects.inv` with non-integer `priority` and add fails-to-load tests for them~
    - [x] More complicated than necessary. With the new custom-built data line testing, it was easy to put invalid `priority` cases in.
- [x] Docs
  - Handled a/o 6632dd4
- [x] CHANGELOG
Revise CLI to make read-from-stdin incompatible with '-u'
Ambiguous/inconsistent set of inputs, otherwise.

- [x] Implementation
- [x] Tests
- [x] Docs
Inject source URL into CLI 'convert' JSON output when using '-u'
Per suggestion at https://github.com/bskinn/sphobjinv/pull/136#issuecomment-717083136

- [x] Implementation
- [x] Tests
- [x] Docs
Treat outpath `-` as stdout and inpath `-` as stdin
Would be cool if specifying `-` there would not create a file called `-` but write to stdout.

One concern: You currently log to stdout instead of stderr. Therefore currently doing `sphobjinv convert json ... /dev/stdout` results in the json being mixed with log messages. This can be fixed in two ways:

1. Do all logging on stderr (I like doing that anyway in my CLI stuff)
2. make `-` as outpath imply `-q`
DataObjStr permits to set invalid priority
While generating a objects.inv from [Mozilla MDN reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects), I reused their "priority" settings to generate `DataObjStr` objects (usually `0.5`), which is invalid as per https://sphobjinv.readthedocs.io/en/latest/syntax.html?highlight=priority#sphinx-objects-inv-v2-syntax

There is no such validation in the data model, and that could be a nice addition :)
Eliminate spurious extra newlines in CLI output when piping
I *think* it should be enough to suppress "cosmetic newlines" in an invocation where convert output is going to stdout?
Complete repo information documents & metadata
- [x] `CONTRIBUTING.md`
- [x] `ISSUE_TEMPLATE`
- [x] `PR_TEMPLATE`
- [x] Project URLs in `setup.py`
- ~Tidelift mention at bottom of README~ (not required, so not doing for now)
Names can have spaces after all
I'm currently writing an objects.inv parser in non-Python and stumbled into something that you might be interested in: contrary to <https://sphobjinv.readthedocs.io/en/stable/syntax.html>, the name field can indeed contain spaces.

I've encountered it in my very own project, that has the following line:

```
dict classes std:term -1 glossary.html#term-dict-classes -
```

I tried loading https://www.attrs.org/en/stable/objects.inv with sphobjinv and it seems to silently ignore these entries.

Maybe you can just steal [their regex](https://github.com/sphinx-doc/sphinx/blob/307a0e580fffa92ece92f08dc1be3a7db4a90dd1/sphinx/util/inventory.py#L122-L123).


Discussion / hints:

Fix patch:
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..89af26e4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+tests/resource/objects_mkdoc_zlib0.inv binary
+
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000..e6d2cf33
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,12 @@
+# These are supported funding model platforms
+
+github: [bskinn] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: # Replace with a single Ko-fi username
+tidelift: pypi/sphobjinv # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: bskinn # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+otechie: # Replace with a single Otechie username
+custom: ['https://paypal.me/btskinn'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..62e883fb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,56 @@
+---
+name: Bug Report
+about: Create a bug report to help improve the project
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Brief description**
+<!-- Clear and concise description of the bug. -->
+
+**Expected behavior**
+<!-- Description of what you expected to happen. -->
+
+**Actual behavior**
+<!-- Description of what actually happened. -->
+
+**To reproduce**
+<!--
+  Steps to reproduce the behavior:
+  1. Open '...'
+  2. Type '...'
+  3. See error
+-->
+
+**Attachments**
+<!--
+  If possible, please paste in an inventory file that
+  demonstrates the bug, or provide a link to a relevant
+  inventory on web.
+
+  If relevant, paste in screenshot(s) demonstrating
+  the bug.
+-->
+
+**System information**
+ - Device: 
+ - OS:
+
+**Python environment**
+
+*Python*
+<!--
+  Please paste the output of `$ {python} --version --version`
+  executed in the relvant environment.
+-->
+
+*Libraries*
+<!--
+  Please paste the output of `$ pip list` or `$ pip freeze`
+  executed in the relevant environment.
+--> 
+
+**Additional information**
+<!-- Add any other context about the problem here. -->
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..e3bd8e47
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+<!-- A clear and concise description of what the problem is. E.g., I'm always frustrated when [...] -->
+
+**Describe the solution you'd like**
+<!-- A clear and concise description of what you want to happen. -->
+
+**Describe alternatives you've considered**
+<!-- A clear and concise description of any alternative solutions or features you've considered, if any. -->
+
+**Additional context**
+<!--
+  Add any other context or screenshots about the feature request here. If there's a
+  specific inventory file that helps demonstrate the behavior you want, attach it here.
+-->
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..63805947
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,40 @@
+<!-- THANKS FOR YOUR CONTRIBUTION!! -->
+
+**Is the PR a fix or a feature?**
+<!-- Please indicate the type of change the PR will make. -->
+
+**Describe the changes in the PR**
+<!--
+  Whatever level of detail is necessary to describe the changes well.
+  A simple reference to associated issue(s) may suffice here
+  (e.g., "Closes #113"), if the description/discussion there is
+  complete enough.
+-->
+
+**Does this PR close any issues?**
+<!--
+  If not indicated in the above PR description, indicate here
+  which issue(s) are closed by the PR, if any; e.g.:
+
+  Closes #112 and closes #117.
+
+  Please use a separate 'closes' with each issue number,
+  to be sure that Github links the PR correctly to each closed issue.
+-->
+
+**Does the PR change/update the following, if relevant?**
+<!--
+  All changes to code, even internal-only changes, should have
+  a CHANGELOG entry. Documentation changes are usually only required
+  if public-facing code or CLI behavior changes. Test coverage
+  should be maintained at 100%, and all lints should be obeyed.
+
+  Please specifically note if you want to use "# noqa",
+  "# pragma: no cover", or similar flags/settings to disable
+  any coverage/linting.
+-->
+
+- [ ] Documentation
+- [ ] Tests
+- [ ] CHANGELOG
+
diff --git a/.gitignore b/.gitignore
index 0d3c0d88..e456df96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,3 +78,6 @@ doc/scratch/*
 # Mutmut
 .mutmut-cache
 
+# VS Code
+.vscode
+
diff --git a/.readthedocs.yml b/.readthedocs.yml
index ba81f82f..0fe88ff5 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -14,7 +14,7 @@ formats: all
 
 # Python and requirements
 python:
-  version: 3.7
+  version: 3.8
   install:
     - requirements: requirements-rtd.txt
     - method: pip
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index c07f1789..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-dist: xenial
-install:
- - PYTHON_MAJOR=$( python -c 'import sys; print(sys.version_info.major)' )
- - PYTHON_MINOR=$( python -c 'import sys; print(sys.version_info.minor)' )
- - pip install -U pip setuptools
- - pip install -U --force-reinstall -r requirements-travis.txt
- - if (( ( $PYTHON_MAJOR > 3 ) || ( $PYTHON_MAJOR == 3 && $PYTHON_MINOR >= 6 ) )); then pip install -r requirements-flake8.txt && flake8 --version; fi
- - sh -c 'cd doc; make html; mkdir scratch'
-language: python
-python:
- - 3.5
- - 3.6
- - 3.7
- - 3.8-dev
-script:
- - python --version
- - pip list
- - pytest --cov=src --testall --nonloc
- - tox -e sdist_install
- - if (( $PYTHON_MAJOR == 3 && $PYTHON_MINOR == 7 )); then tox -e flake8; else echo "No flake8."; fi
- - if (( $PYTHON_MAJOR == 3 && $PYTHON_MINOR == 7 )); then sh -c 'cd doc; make doctest'; else echo "No doctest."; fi
- - if (( $PYTHON_MAJOR == 3 && $PYTHON_MINOR == 7 )); then codecov; else echo "No codecov."; fi
-
diff --git a/AUTHORS.md b/AUTHORS.md
index 7842b0f5..c8ddace8 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -16,8 +16,8 @@ As I started to use it regularly in my own documentation work, it became clear t
 from also implementing functionality to assist with object searches, especially in large documentation sets.
 Also, it seemed likely that a robust API for creation/manipulation of inventory contents would be useful, in order to
 assist with things like scraping a non-Sphinx website to generate an objects.inv for cross-referencing in other docs.
-This led to the current object-oriented API of `sphobjinv` v2.0.
+This led to the current object-oriented API of `sphobjinv` v2.x.
 
-While there are [a number](https://github.com/bskinn/sphobjinv/issues) of possible enhancements to the project,
+While there are [numerous](https://github.com/bskinn/sphobjinv/issues) possible enhancements to the project,
 I'm quite satisfied with its ease of use and usefulness, at least for my purposes, and thus consider it fully stable.
 I'm always glad to receive feature requests and (especially) bug reports, though.
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b35507c8..a807e50e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,71 +7,220 @@ and this project strives to adhere to
 [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
 
+### [2.1] - 2021-04-14
+
+#### Added
+
+  * Python 3.10 support was officially added.
+
+#### Changed
+
+  * The User-Agent header sent by `Inventory` when making an HTTP(S) request
+    now identifies `sphobjinv` and its version (anticipate no API or
+    behavior change).
+
+  * An extraneous newline was removed before tables printed in the
+    'suggest' CLI mode (cosmetic change).
+
+#### Fixed
+
+  * Previously, `sphobjinv.Inventory` would ignore entries in `objects.inv`
+    that contained spaces within `name`
+    (see [#181](https://github.com/bskinn/sphobjinv/issues/181));
+    this is now fixed.
+
+#### Removed
+
+  * Python 3.5 is no longer supported.
+
+  * The relaxation of the integer constraint on the `priority` field
+    introduced in v2.1b1 has been *reverted*, as `objects.inv` data lines
+    with such non-integer `priority` values are skipped by Sphinx.
+
+#### Internal
+
+  * Where possible, string interpolation has been refactored to use
+    f-strings.
+
+  * A 'speedup' `extras_require` entry has been added to allow simple installation
+    of `python-Levenshtein` for Linux and MacOS platforms, as
+    `pip install sphobjinv[speedup]`. This extra does nothing on Windows, since
+    compilation machinery is anticipated not to be available for most users.
+
+  * `objects_mkdoc_zlib0.inv`, which was compressed at `zlib` level 0,
+    has been added to the test resources directory.
+
+  * This file had to be flagged as binary in `.gitattributes` in order to avoid
+      git EOL auto-conversion on Windows.
+
+  * The CLI functionality was refactored from the single `sphobjinv.cmdline` module
+    into a dedicated set of `sphobjinv.cli.*` submodules.
+
+  * Some internal `type(...) is ...` checks were replaced with `isinstance(...)`
+
+#### Testing
+
+  * Added *significant* body of new tests to confirm inventory compatibility
+    with both `sphobjinv` and Sphinx itself.
+
+    * Consistency checks added both for data within `sphobjinv.Inventory` instances
+      **AND** as emitted from `sphinx.ext.inventory.InventoryFile.load()`.
+
+    * The tests in `tests/test_valid_objects.py` strive to bracket as precisely
+      as possible what content is allowed on an `objects.inv` data line,
+      in addition to providing guidance on what is allowable, but discouraged.
+
+      `docs/source/syntax.rst` was also edited to reflect this guidance.
+
+  * Additional tests have been added to probe corner cases involving Windows EOLs.
+
+  * A test was added to ensure that the schema in `sphobjinv.schema` is in fact
+    a valid JSON schema.
+
+  * Multiple asserts/checks per test method have been converted to use
+    `pytest-check` instead of `pytest-subtests`, due to some inconsistent
+    behavior with the latter.
+
+  * `tox` environments and dependencies were updated, and some flake8 configuration
+    was adjusted.
+
+#### Administrative
+
+  * Standard development Python version bumped to 3.9.
+
+  * Standard development Sphinx version bumped to 3.5.0.
+
+  * Added `[skip ci]` flag in commit text for skipping Github Actions CI.
+
+  * RtD upgraded to use Python 3.8.
+
+  * Added 'radio Sphinx' logo to RtD docs.
+
+  * Drafted `CONTRIBUTING.md` and added PR & issue templates.
+
+  * Tranferred most project metadata from `setup.py` to `setup.cfg`.
+
+
+### [2.1b1] - 2020-11-13
+
+#### Fixed
+
+  * Equality tests on Inventory and DataObjStr/DataObjBytes instances
+    now work correctly.
+
+  * Non-integer and non-numeric values for `priority` are now accepted
+    during `Inventory` instantiation, consistent with what is allowed
+    by `DataObjStr` and `DataObjBytes` instantiation.
+
+
+### [2.1a2] - 2020-10-27
+
+#### Added
+
+  * When an inventory is retrieved via CLI from a remote URL with `-u`,
+    the resolved location of the inventory is included in generated JSON
+    at `json_dict.metadata.url`.
+
+#### Changed
+
+  * CLI logging messages are now emitted to stderr instead of stdout.
+
+
+### [2.1a1] - 2020-10-26
+
+#### Added
+
+  * A hyphen can now be passed as the CLI input and/or output file name
+    to instruct sphobjinv to use stdin and/or stdout, respectively.
+
+  * The `fileops` and `inventory` APIs are now tested to work with
+    both strings and `pathlib.Path` objects, where they interact
+    with the filesystem.
+
+#### Refactored
+
+  * Patterns in regular expressions are now defined with raw strings
+    to improve readability.
+
+
 ### [2.0.1] - 2020-01-26
 
 #### Fixed
 
- * attr.s usage on Inventory changed to use eq=False where possible,
-   per the deprecation of the cmp argument
- * URL inventory retrieval now sends a User-Agent header, to avoid
-   403 FORBIDDEN errors on some docs servers
+  * attr.s usage on Inventory changed to use eq=False where possible,
+    per the deprecation of the cmp argument.
+
+  * URL inventory retrieval now sends a User-Agent header, to avoid
+    403 FORBIDDEN errors on some docs servers.
 
 
 ### [2.0.1rc1] - 2019-02-01
 
 #### Fixed
 
- * Sphinx *can* generate inventories with empty-string values for
-   `project` and `version`; `sphobjinv` now can import such
-   inventories without error.
+  * Sphinx *can* generate inventories with empty-string values for
+    `project` and `version`; `sphobjinv` now can import such
+    inventories without error.
 
 
 ### [2.0.0] - 2018-08-16
 
 #### Added
 
- * Loading remote inventories from the CLI now will
-   perform an automatic walk along the directory structure of the
-   provided URL, searching for the objects.inv file of the
-   documentation set.
- * The above URL walking functionality is exposed for API use
-   at sphobjinv.fileops.urlwalk.
+  * Loading remote inventories from the CLI now will
+    perform an automatic walk along the directory structure of the
+    provided URL, searching for the objects.inv file of the
+    documentation set.
+  * The above URL walking functionality is exposed for API use
+    at sphobjinv.fileops.urlwalk.
 
 #### Changed
 
- * Instances of DataObjStr and DataObjBytes are NO LONGER IMMUTABLE.
-   Working with Inventory instances was going to be far too cumbersome
-   with immutable DataObjStr instances in .objects.
+  * Instances of DataObjStr and DataObjBytes are NO LONGER IMMUTABLE.
+    Working with Inventory instances was going to be far too cumbersome
+    with immutable DataObjStr instances in .objects.
 
 
 ### [2.0.0rc1] - 2018-05-18
 
 #### Fixed
 
- * API code no longer contains any `sys.exit` calls;
-   CLI interactions should now be properly segregated from the internal API
+  * API code no longer contains any `sys.exit` calls;
+    CLI interactions should now be properly segregated from the internal API.
 
 #### Added
 
- * API
-   * Data for individual objects encapsulated in the new `.data.DataObjStr` and `.data.DataObjBytes` classes
-     * Instances of these objects provide granular access to the contained data
-     * Instances are *immutable*, but expose an `evolve()` method for creating new, (optionally) modified instances.
-   * Entire inventory contents, as a `list` of `DataObjStr`, encapsulated in `.inventory.Inventory` instances
-     * `Inventory` instances are anticipated to be the primary point of user interface to inventory data
-     * `.suggest()` method added, exploiting fuzzy string searching by [`fuzzywuzzy`](https://pypi.org/project/fuzzywuzzy) for rapid searching of inventories for objects of interest
+  * API
+    * Data for individual objects encapsulated in the new `.data.DataObjStr` and
+    * `.data.DataObjBytes` classes
+    * Instances of these objects provide granular access to the contained data
+    * Instances are *immutable*, but expose an `evolve()` method for creating new,
+      (optionally) modified instances.
+   * Entire inventory contents, as a `list` of `DataObjStr`, encapsulated in
+     `.inventory.Inventory` instances
+     * `Inventory` instances are anticipated to be the primary point of user interface to
+       inventory data
+     * `.suggest()` method added, exploiting fuzzy string searching by
+       [`fuzzywuzzy`](https://pypi.org/project/fuzzywuzzy) for rapid searching of
+       inventories for objects of interest
      * Import of remote `objects.inv` files via `urllib.request` and `certifi` implemented.
-   * Helper methods are provided for working with the `objects.inv` shorthands for object URI and display-name information, on both `DataObj...` and `Inventory` instances.
+   * Helper methods are provided for working with the `objects.inv` shorthands for object
+     URI and display-name information, on both `DataObj...` and `Inventory` instances.
    * `readjson`/`writejson` helper functions added to `.fileops`
-   * JSON schema added to `.schema` for use with [`jsonschema`](https://pypi.org/project/jsonschema) to validate incoming data during `Inventory` instantiation from  JSON
+   * JSON schema added to `.schema` for use with
+     [`jsonschema`](https://pypi.org/project/jsonschema) to validate incoming data
+     during `Inventory` instantiation from  JSON
  * Command-line interface
-   * Add `suggest` subparser, for recommendation of matching objects within an input inventory
+   * Add `suggest` subparser, for recommendation of matching objects
+     within an input inventory
    * Add ability to read and write JSON inventories
-   * Add ability to use a remote `objects.inv` file (arbitrary filename) as input, specified by URL
+   * Add ability to use a remote `objects.inv` file (arbitrary filename) as input,
+     specified by URL
    * Add arguments to:
      * Expand/contract the `objects.inv` URI and 'display name' abbreviations
      * Force overwrite of an existing output file without prompting
-     * Silence all output (mainly intended for scripting applications); includes automatic overwriting of an existing output file
+     * Silence all output (mainly intended for scripting applications);
+       includes automatic overwriting of an existing output file
      * Various arguments to control the output of a `sphobjinv suggest` invocation
 
 #### Changed
@@ -79,10 +228,14 @@ and this project strives to adhere to
  * `readfile`/`writefile` changed to `readbytes`/`writebytes`
    and moved to new `.fileops` submodule
  * `decode`/`encode` changed to `decompress`/`compress` and moved to new `.zlib` submodule
- * `p_data`/`p_comments` regex patterns renamed with `pb_` prefix to indicate they are `bytes`, not `str` patterns
+ * `p_data`/`p_comments` regex patterns renamed with `pb_` prefix to indicate they are
+   `bytes`, not `str` patterns
  * CLI
-   * Commands for (de)compressing `objects.inv` files changed to a more general `convert` subparser, due to addition of JSON output option
-   * Default filename/extension assumptions removed from input file argument, as multiple input formats make the logic here complicated enough it's not worth messing with
+   * Commands for (de)compressing `objects.inv` files changed to a more general `convert`
+     subparser, due to addition of JSON output option
+   * Default filename/extension assumptions removed from input file argument,
+     as multiple input formats make the logic here complicated enough
+     it's not worth messing with
 
 ### [1.0.0] - 2016-05-24
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..cf562b5b
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,281 @@
+Welcome!
+--------
+
+Thanks for your interest in contributing to `sphobjinv`!
+The aim of this document is to provide the information you need
+to get started smoothly on a contribution.
+
+If you have any questions, please drop me a line on Twitter
+([@btskinn](https://twitter.com/btskinn)) or open an
+[issue](https://github.com/bskinn/sphobjinv/issues).
+
+
+Table of Contents
+-----------------
+
+<!--TOC-->
+
+- [Project Setup](#project-setup)
+- [Working with git](#working-with-git)
+- [Tests](#tests)
+- [Linting](#linting)
+- [Type Hints](#type-hints)
+- [Documentation](#documentation)
+- [Continuous Integration](#continuous-integration)
+- [CHANGELOG](#changelog)
+- [Issue & PR Templates](#issue--pr-templates)
+- [License](#license)
+
+<!--TOC-->
+
+
+## Project Setup
+
+Start by forking the repo and cloning locally:
+
+```
+$ git clone https://github.com/{you}/sphobjinv
+```
+
+Then, create a virtual environment for the project,
+in whatever location you prefer. Any Python interpreter 3.6+ *should* work fine.
+
+I prefer to use `virtualenv` and create in `./env`:
+
+```
+$ python3.9 -m virtualenv env --prompt="(sphobjinv) "
+```
+
+Activate the environment:
+
+```
+=== Linux/Mac
+$ source env/bin/activate
+
+=== Windows
+> env\scripts\activate
+```
+
+The next step is to upgrade/install the development requirements:
+
+```
+(sphobjinv) $ python -m pip install -U pip setuptools wheel
+(sphobjinv) $ pip install -r requirements-dev.txt
+```
+
+Finally, you'll need to build the Sphinx docs,
+as some of the tests interact with them:
+
+```
+(sphobjinv) $ cd doc
+(sphobjinv) doc $ make html
+```
+
+
+## Working with git
+
+There's no way I can fit a whole git tutorial in here, so this
+just highlights a couple of key functionalities you'll need.
+
+First, always hack on a bugfix or feature in a new branch:
+
+```
+$ git checkout -b description-of-change
+```
+
+This makes it a lot simpler to get your repo fork up to date
+after `master` receives further commits.
+
+To bring your fork's `master` up to date, you first need to
+add the main repo as a new git remote (one-time task):
+
+```
+$ git remote add upstream https://github.com/bskinn/sphobjinv
+```
+
+Then, any time you need to refresh the fork's master:
+
+```
+$ git fetch --all
+$ git checkout master
+$ git merge upstream/master  # (should merge without incident)
+$ git push  # (should push to your fork without incident)
+```
+
+
+## Tests
+
+`sphobjinv` uses the [`pytest`](https://github.com/pytest-dev/pytest)
+framework for most of its automated tests. From a properly configured
+virtual environment, a simple no-arguments invocation is all
+that is required:
+
+```
+$ pytest
+```
+
+The test suite defaults to running only local tests,
+those that do **NOT** require network access. To include
+the nonlocal tests, run with the `--nonloc` flag:
+
+```
+$ pytest --nonloc
+```
+
+When putting together a PR, at minimum, please add/augment the test suite
+as necessary to maintain 100% test coverage. To the extent possible,
+please go beyond this and add tests that check potential edge cases,
+bad/malformed/invalid inputs, etc. For bugfixes, add one or more
+focused regression tests that cover the bug behavior being fixed.
+
+There are some situations where it may make sense to use a
+`# pragma: no cover` to ignore coverage on certain line(s) of code.
+Please start a discussion in the issue or PR comments before
+adding such a pragma.
+
+Note that while [`tox`](https://github.com/tox-dev/tox/) *is*
+configured for the project, it is **not** set up to be an everyday test runner.
+Instead, it's used to execute a matrix of test environments
+checking for the compatibility of different Python and  dependency
+versions. You can run it if you want, but you'll need
+working versions of all of Python 3.6 through 3.10
+installed and on `PATH` as `python3.6`, `python3.7`, etc.
+The nonlocal test suite is run for each `tox` environment, so
+it's best to use at most two parallel sub-processes to avoid oversaturating
+your network bandwidth; e.g.:
+
+```
+$ tox -rp2
+```
+
+
+## Linting
+
+The project uses a number of lints, which are checked using
+[`flake8`](https://gitlab.com/pycqa/flake8) in CI.
+To run the lints locally, it's easiest to use `tox`:
+
+```
+$ tox -e flake8
+```
+
+In some limited circumstances, it may be necessary to add 
+[`# noqa`](https://flake8.pycqa.org/en/stable/user/violations.html#in-line-ignoring-errors)
+or [`per_file_ignores`](https://flake8.pycqa.org/en/stable/user/options.html#cmdoption-flake8-per-file-ignores)
+exclusions to the `flake8` lints.
+Please note these for discussion in an issue/PR comment
+as soon as you think they might be needed.
+
+Additionally, the CI for pull requests is set up to check that all
+modules, functions, classes and methods have docstrings
+using the [`interrogate`](https://pypi.org/project/interrogate/) package.
+There's a `tox` environment for running this check, also:
+
+```
+$ tox -e interrogate
+```
+
+
+## Type Hints
+
+I'd like to [roll out typing](https://github.com/bskinn/sphobjinv/issues/132)
+on the project at some point in the near future, and add
+[`mypy`](https://github.com/python/mypy) checking to CI.
+(This would be a great PR to put together, for anyone interested....)
+For now, types on contributed code are welcomed, but optional.
+Once the codebase is typed, though, they will be a required part of
+any PR touching code.
+
+
+## Documentation
+
+All of the project documentation (except the README) is
+generated via [Sphinx](https://github.com/sphinx-doc/sphinx),
+and should be updated for (at minimum) any behavior changes
+in the codebase. API changes should be documented in the
+relevant docstring(s), and possibly in the prose portions
+of the documentation as well. Please use the modified
+[NumPy-style](https://numpydoc.readthedocs.io/en/latest/format.html)
+formatting for docstrings that is already in use in the project.
+
+A large number of reStructuredText substitutions are defined in the `rst_epilog`
+setting within `conf.py`, to make the documentation source
+more readable. Feel free to add more entries there.
+
+To run any of the Sphinx builders, first change to the `/doc` directory
+in the repository tree. In most cases, a plain `make html` invocation
+is sufficient to build the docs properly,
+as Sphinx does its best to detect which files were changed and
+rebuild only the minimum portion of the documentation necessary.
+If the docs seem not to be rendering correctly, try a clean build:
+
+```
+=== Linux/Mac
+doc $ make clean html
+
+=== Windows
+doc> make -Ea
+```
+
+It's also a good idea to build the complete docs every once in a while with
+its ['nitpicky' option](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-nitpicky),
+in order to detect any broken cross-references, as these will fail
+the [Azure CI pipeline](#continuous-integration):
+
+```
+=== Linux/Mac
+doc $ O=-n make clean html
+
+=== Windows
+doc> make html -Ean
+```
+
+You can also run the doctests with `make doctest`
+and the link validity checker with `make linkcheck`.
+
+
+## Continuous Integration
+
+Both Github Actions and Azure Pipelines are set up for the project,
+and should run on any forks of the repository.
+
+Github Actions runs the test suite on Linux for Python 3.6 through 3.9,
+as well as the `flake8` lints and the Sphinx doctests and link-validity testing,
+and is configured to run on all commits. The workflow can be skipped
+per-commit by including `[skip ci]` in the commit message.
+
+The Azure Pipelines CI runs an extensive matrix of cross-platform and
+cross-Python-version tests, as well as numerous other checks.
+Due to its length, it is configured to run only on release branches
+and PRs to `master` or `stable`. It cannot be skipped.
+
+
+## CHANGELOG
+
+The project [`CHANGELOG`](https://github.com/bskinn/sphobjinv/blob/master/CHANGELOG.md)
+should be updated for the majority of contributions. No tooling is in place
+(e.g., [`towncrier`](https://github.com/twisted/towncrier)) for automated collation
+of news items into `CHANGELOG`;
+all changes should be documented manually, directly in the `CHANGELOG`.
+
+Any PR that touches the project code *must* include a `CHANGELOG` entry.
+Contributions that make changes just to the test suite should usually also include
+a `CHANGELOG` entry, except for very minor or cosmetic changes. Other changes of note
+(packaging/build tooling, test/lint tooling/plugins, tool settings, etc.)
+may also warrant a `CHANGELOG` bullet, depending on the situation.
+When in doubt, ask!
+
+
+## Issue & PR Templates
+
+The project is configured with a PR template and a couple of
+issue templates, to hopefully make it easier to provide all
+the information needed to act on code contributions, bug reports,
+and feature requests. If the templates don't fit the issue/PR
+you want to create, though, then don't use them.
+
+
+## License
+
+All contributions will take on the MIT License of the project at large.
+
diff --git a/LICENSE.txt b/LICENSE.txt
index f35ea56a..83a8cff5 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,6 +1,6 @@
 The MIT License (MIT)
 
-Copyright (c) 2016-2020 Brian Skinn
+Copyright (c) 2016-2021 Brian Skinn
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/MANIFEST.in b/MANIFEST.in
index de0a14a7..bf8e0a28 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,1 +1,1 @@
-include LICENSE.txt README.rst CHANGELOG.md pyproject.toml
+include AUTHORS.md CHANGELOG.md CONTRIBUTING.md LICENSE.txt pyproject.toml README.rst
diff --git a/README.rst b/README.rst
index c99ab963..6205c742 100644
--- a/README.rst
+++ b/README.rst
@@ -3,8 +3,9 @@ sphobjinv: Manipulate and inspect Sphinx objects.inv files
 
 **Current Development Version:**
 
-.. image:: https://img.shields.io/travis/com/bskinn/sphobjinv?label=travis-ci&logo=travis
-    :target: https://travis-ci.com/bskinn/sphobjinv
+.. image:: https://img.shields.io/github/workflow/status/bskinn/sphobjinv/ci-tests?logo=github
+    :alt: GitHub Workflow Status
+    :target: https://github.com/bskinn/sphobjinv/actions
 
 .. image:: https://codecov.io/gh/bskinn/sphobjinv/branch/master/graph/badge.svg
     :target: https://codecov.io/gh/bskinn/sphobjinv
@@ -51,7 +52,8 @@ For internal cross-references, locate ``objects.inv`` within ``build/html``::
       Name                                                     Score
     --------------------------------------------------------  -------
     :py:method:`sphobjinv.data.SuperDataObj.as_rst`             60
-    :py:function:`sphobjinv.cmdline.getparser`                  50
+    :std:doc:`cli/implementation/parser`                        57
+    :py:module:`sphobjinv.cli.parser`                           50
     :py:method:`sphobjinv.data.SuperDataObj.as_str`             50
     :py:method:`sphobjinv.inventory.Inventory.objects_rst`      50
 
@@ -61,34 +63,39 @@ 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 documentation wherever it lives on the web,
+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 ``Axis`` class from matplotlib (see
-`here <https://matplotlib.org/api/axis_api.html?highlight=axis#module-matplotlib.axis>`__)::
+cross-reference the ``linspace`` function from numpy (see
+`here <https://numpy.org/doc/1.18/reference/generated/numpy.linspace.html>`__)::
 
-    $ sphobjinv suggest https://matplotlib.org/api/ticker_api.html axis -su
+    $ sphobjinv suggest https://numpy.org/doc/1.19/reference/index.html linspace -su
 
     No inventory at provided URL.
-    Attempting "https://matplotlib.org/api/ticker_api.html/objects.inv" ...
-    Attempting "https://matplotlib.org/api/objects.inv" ...
-    Attempting "https://matplotlib.org/objects.inv" ...
+    Attempting "https://numpy.org/doc/1.19/reference/index.html/objects.inv" ...
+    Attempting "https://numpy.org/doc/1.19/reference/objects.inv" ...
+    Attempting "https://numpy.org/doc/1.19/objects.inv" ...
     Remote inventory found.
 
 
-      Name                           Score
-    ------------------------------  -------
-    :py:module:`matplotlib.axis`      90
-    :std:doc:`api/axis_api`           90
-    :std:label:`axis-container`       90
+      Name                                                           Score
+    --------------------------------------------------------------  -------
+    :py:function:`numpy.linspace`                                     90
+    :py:method:`numpy.polynomial.chebyshev.Chebyshev.linspace`        90
+    :py:method:`numpy.polynomial.hermite.Hermite.linspace`            90
+    :py:method:`numpy.polynomial.hermite_e.HermiteE.linspace`         90
+    :py:method:`numpy.polynomial.laguerre.Laguerre.linspace`          90
+    :py:method:`numpy.polynomial.legendre.Legendre.linspace`          90
+    :py:method:`numpy.polynomial.polynomial.Polynomial.linspace`      90
+    :std:doc:`reference/generated/numpy.linspace`                     90
 
 .. 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
-*inline directives*. Thus, the above ``redirect()`` function must be
-cross-referenced as ``:func:`flask.redirect```, **not**
-``:function:`flask.redirect```.
+*inline directives*. Thus, the above ``linspace()`` function must be
+cross-referenced as ``:func:`numpy.linspace```, **not**
+``:function:`numpy.linspace```.
 
 **Need to edit an inventory after it's created, or compose one from scratch?**
 
@@ -113,13 +120,13 @@ inventory creation/modification::
     >>> import sphobjinv as soi
     >>> inv = soi.Inventory('doc/build/html/objects.inv')
     >>> print(inv)
-    <Inventory (fname_zlib): sphobjinv v2.0, 181 objects>
+    <Inventory (fname_zlib): sphobjinv v2.1, 205 objects>
     >>> inv.project
     'sphobjinv'
     >>> inv.version
-    '2.0'
+    '2.1'
     >>> inv.objects[0]
-    DataObjStr(name='sphobjinv.cmdline', domain='py', role='module', priority='0', uri='cli/implementation.html#module-$', dispname='-')
+    DataObjStr(name='sphobjinv.cli.core', domain='py', role='module', priority='0', uri='cli/implementation/core.html#module-$', dispname='-')
 
 The API also enables straightforward re-export of an inventory,
 for subsequent use with ``intersphinx`` cross-references.
@@ -132,14 +139,14 @@ for more details.
 Full documentation is hosted at
 `Read The Docs <http://sphobjinv.readthedocs.io/en/latest/>`__.
 
-Available on `PyPI <https://pypi.python.org/pypi/sphobjinv>`__
+Available on `PyPI <https://pypi.org/project/sphobjinv>`__
 (``pip install sphobjinv``).
 
 Source on `GitHub <https://github.com/bskinn/sphobjinv>`__.  Bug reports
 and feature requests are welcomed at the
 `Issues <https://github.com/bskinn/sphobjinv/issues>`__ page there.
 
-Copyright (c) Brian Skinn 2016-2020
+Copyright (c) Brian Skinn 2016-2021
 
 License: The MIT License. See `LICENSE.txt <https://github.com/bskinn/sphobjinv/blob/master/LICENSE.txt>`__
 for full license terms.
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
new file mode 100644
index 00000000..98328427
--- /dev/null
+++ b/azure-pipelines.yml
@@ -0,0 +1,222 @@
+trigger:
+- release-*
+
+pr:
+  branches:
+    include:
+    - master
+    - stable
+
+
+stages:
+- stage: code_quality
+  displayName: Check code quality
+
+  jobs:
+  - job: flake8
+    displayName: Run all flake8 checks
+
+    pool:
+      vmImage: 'Ubuntu-latest'
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+
+    - script: pip install -U tox
+      displayName: Install tox
+
+    - script: pip install -r requirements-dev.txt
+      displayName: Install full dev requirements (CI & flake8)
+
+    - script: pytest -k flake8 --flake8_ext
+      displayName: Confirm all plugins active
+
+    - script: tox -e flake8
+      displayName: Lint the codebase
+
+  - job: interrogate
+    displayName: Run interrogate docstrings check
+
+    pool:
+      vmImage: 'Ubuntu-latest'
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+
+    - script: pip install interrogate
+      displayName: Install interrogate
+
+    - script: interrogate -vv -f 100 src tests conftest.py
+      displayName: Run interrogate
+
+
+- stage: main_tests
+  displayName: Run main code tests
+
+  jobs:
+  - template: azure-coretest.yml
+    parameters:
+      pythons:
+        py36:
+          spec: '3.6'
+        py37:
+          spec: '3.7'
+        py38:
+          spec: '3.8'
+        py39:
+          spec: '3.9'
+        pypy3:
+          spec: 'pypy3'
+      platforms: [linux, windows, macOs]
+
+  - job: Levenshtein
+    strategy:
+      matrix:
+        linux:
+          platform: Ubuntu-latest
+        macOs:
+          platform: macOS-latest
+
+    pool:
+      vmImage: $[ variables.platform ]
+
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+        architecture: 'x64'
+      displayName: Use Python 3.9
+
+    - script: pip install -U --force-reinstall -r requirements-ci.txt -e .[speedup]
+      displayName: Install CI requirements, plus python-Levenshtein
+
+    - script: pip list
+      displayName: Show full environment contents
+
+    - script: cd doc; make html; mkdir scratch
+      displayName: Build  the documentation for tests to work against (Linux/macOs)
+
+    - script: pytest --nonloc -k "not readme"
+      displayName: Run pytest without README tests
+
+
+- stage: aux_tests
+  displayName: Run auxiliary tests
+
+  jobs:
+  - job: docs_build
+    displayName: Check for doc build warnings
+
+    pool:
+      vmImage: 'Ubuntu-latest'
+
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+
+    - script: pip install . -r requirements-rtd.txt
+      displayName: Install project, plus docs requirements
+
+    - script: cd doc; make html -Wn --keep-going
+      displayName: Build docs with warnings as errors
+
+
+  - job: doctest
+    displayName: Run doctest suite
+
+    pool:
+      vmImage: 'Ubuntu-latest'
+
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+
+    - script: pip install -r requirements-dev.txt
+      displayName: Install full dev requirements
+
+    - script: cd doc; make doctest
+      displayName: Run doctests
+
+  - job: linkcheck
+    displayName: Run docs link-check suite
+
+    pool:
+      vmImage: 'Ubuntu-latest'
+
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+
+    - script: pip install . -r requirements-rtd.txt
+      displayName: Install doc requirements and local project
+
+    - script: cd doc; make linkcheck
+      displayName: Run link check
+
+
+  - job: testdir_coverage
+    displayName: Confirm all test code was executed
+
+    pool:
+      vmImage: 'Ubuntu-latest'
+
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+
+    - script: pip install -r requirements-dev.txt
+      displayName: Install full dev requirements
+
+    - script: cd doc; make html; mkdir scratch
+      displayName: Build docset
+
+    - script: pytest --cov=. --nonloc --flake8_ext
+      displayName: Run pytest with coverage on the entire project tree
+
+    - script: coverage report --include="tests/*" --fail-under=100
+      displayName: Check 100% test execution
+
+  - job: contributing_toc
+    displayName: Confirm CONTRIBUTING.md TOC is up to date
+
+    pool:
+      vmImage: 'Ubuntu-latest'
+
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+
+    - script: pip install -r requirements-ci.txt
+      displayName: Install CI requirements
+
+    - script: python -m md_toc -p github CONTRIBUTING.md
+      displayName: Run md-toc on CONTRIBUTING
+
+    - script: if [[ $( git status | grep modified | wc -l ) -ne 0 ]]; then exit 1; fi
+      displayName: Fail if md-toc modified CONTRIBUTING
+
+  - job: noqa_info
+    displayName: Report flake8-noqa results for info (never fails)
+
+    pool:
+      vmImage: 'Ubuntu-latest'
+
+    steps:
+    - task: UsePythonVersion@0
+      inputs:
+        versionSpec: '3.9'
+
+    - script: pip install tox flake8-noqa -r requirements-flake8.txt
+      displayName: Install requirements
+
+    - script: tox -e flake8-noqa
+      displayName: Run never-fail flake8 with noqa check
+
+
diff --git a/doc/Makefile b/doc/Makefile
index d0c3cbf1..7b680a8c 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -14,6 +14,10 @@ help:
 
 .PHONY: help Makefile
 
+# Custom for sphinx-autobuild
+livehtml:
+	sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)"/html -nb html $(SPHINXOPTS) $(O)
+
 # Catch-all target: route all unknown targets to Sphinx using the new
 # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
 %: Makefile
diff --git a/doc/make.bat b/doc/make.bat
index 613f65fc..b9c03e59 100644
--- a/doc/make.bat
+++ b/doc/make.bat
@@ -25,8 +25,9 @@ if errorlevel 9009 (
 	exit /b 1
 )
 
+
 if "%1" == "livehtml" (
-	sphinx-autobuild -b html %SOURCEDIR% %BUILDDIR/html %SPHINXOPTS% %O% %2
+	sphinx-autobuild %SOURCEDIR% %BUILDDIR%/html -nb html %SPHINXOPTS% %O% %2
         goto end
 )
 
diff --git a/doc/source/_static/mouseover_example.png b/doc/source/_static/mouseover_example.png
index 1502f976..07e60cba 100644
Binary files a/doc/source/_static/mouseover_example.png and b/doc/source/_static/mouseover_example.png differ
diff --git a/doc/source/_static/soi-logo.png b/doc/source/_static/soi-logo.png
new file mode 100644
index 00000000..95933b18
Binary files /dev/null and b/doc/source/_static/soi-logo.png differ
diff --git a/doc/source/_static/soi-logo_duo.png b/doc/source/_static/soi-logo_duo.png
new file mode 100644
index 00000000..8e176140
Binary files /dev/null and b/doc/source/_static/soi-logo_duo.png differ
diff --git a/doc/source/_static/soi-logo_duo_border.png b/doc/source/_static/soi-logo_duo_border.png
new file mode 100644
index 00000000..a60917b9
Binary files /dev/null and b/doc/source/_static/soi-logo_duo_border.png differ
diff --git a/doc/source/_static/soi-logo_duo_border.xcf b/doc/source/_static/soi-logo_duo_border.xcf
new file mode 100644
index 00000000..08e35189
Binary files /dev/null and b/doc/source/_static/soi-logo_duo_border.xcf differ
diff --git a/doc/source/api/inventory.rst b/doc/source/api/inventory.rst
index c2523670..60e09767 100644
--- a/doc/source/api/inventory.rst
+++ b/doc/source/api/inventory.rst
@@ -6,7 +6,3 @@ sphobjinv.inventory
 .. automodule:: sphobjinv.inventory
     :members:
 
-
-.. |DataObjStr| replace:: :class:`~sphobjinv.data.DataObjStr`
-
-
diff --git a/doc/source/api_usage.rst b/doc/source/api_usage.rst
index b9f429aa..380101ac 100644
--- a/doc/source/api_usage.rst
+++ b/doc/source/api_usage.rst
@@ -23,6 +23,14 @@ Inspecting the contents of an existing inventory is handled entirely by the
     >>> inv.count
     56
 
+The location of the inventory file to import can also be provided as
+a :class:`pathlib.Path`, instead of as a string:
+
+.. doctest:: api_inspect
+
+    >>> soi.Inventory(Path('objects_attrs.inv')).project
+    'attrs'
+
 The individual objects contained in the inventory are represented by instances
 of the :class:`~sphobjinv.data.DataObjStr` class, which are stored in
 a |list| in the :attr:`~sphobjinv.inventory.Inventory.objects` attribute:
@@ -61,6 +69,46 @@ Remote |objects.inv| files can also be retrieved via URL, with the *url* keyword
     >>> print(inv4)
     <Inventory (url): attrs v17.2, 56 objects>
 
+Comparing Inventories
+---------------------
+
+|Inventory| instances compare equal when they have the same :attr:`~sphobjinv.inventory.Inventory.project` and
+:attr:`~sphobjinv.inventory.Inventory.version`, and when all the members of
+:attr:`~sphobjinv.inventory.Inventory.objects` are identical between the two instances:
+
+.. doctest:: api_compare
+
+    >>> inv = soi.Inventory("objects_attrs.inv")
+    >>> inv2 = soi.Inventory(inv.data_file())
+    >>> inv is inv2
+    False
+    >>> inv == inv2
+    True
+    >>> inv2.project = "foo"
+    >>> inv == inv2
+    False
+
+Individual |DataObjStr| and (|DataObjBytes|) instances compare equal if all of
+:attr:`~sphobjinv.data.SuperDataObj.name`, :attr:`~sphobjinv.data.SuperDataObj.domain`,
+:attr:`~sphobjinv.data.SuperDataObj.role`, :attr:`~sphobjinv.data.SuperDataObj.priority`,
+:attr:`~sphobjinv.data.SuperDataObj.uri`, and :attr:`~sphobjinv.data.SuperDataObj.dispname`
+are equal:
+
+.. doctest:: api_compare
+
+    >>> obj1 = inv.objects[0]
+    >>> obj2 = inv.objects[1]
+    >>> obj1 == obj1
+    True
+    >>> obj1 == obj2
+    False
+    >>> obj1 == obj1.evolve(name="foo")
+    False
+
+.. versionchanged:: 2.1
+    Previously, |Inventory| instances would only compare equal to themselves,
+    and comparison attempts on |SuperDataObj| subclass instances would raise :exc:`RecursionError`.
+
 Modifying an Inventory
 ----------------------
 
diff --git a/doc/source/cli/convert.rst b/doc/source/cli/convert.rst
index 75f21691..ac491796 100644
--- a/doc/source/cli/convert.rst
+++ b/doc/source/cli/convert.rst
@@ -8,16 +8,14 @@ Command-Line Usage: "convert" Mode
 The |cour|\ convert\ |/cour| subparser is used for all conversions of
 "version 2" Sphinx inventory
 files among plaintext, zlib-compressed, and (unique to |soi|) JSON formats.
-Currently, the |soi| CLI can read inventory data from local files
-in any of these three formats, as well as the standard zlib-compressed format
+The |soi| CLI can read and write inventory data from local files
+in any of these three formats, as well as read the standard zlib-compressed format
 from files in remote locations (see :option:`--url`).
-At the moment, the only output
-method supported is writing to a local file, again in all three formats.
 
-.. note::
+As of v2.1, the |soi| CLI can also read/write inventories at ``stdin``/``stdout``
+in the plaintext and JSON formats; see :ref:`below <cli_usage_json_added>`.
 
-    If reading from |stdin| or writing to |stdout| would be useful to you,
-    please leave a note at :issue:`74` so I can gauge interest.
+----
 
 Basic file conversion to the default output filename is straightforward:
 
@@ -30,6 +28,7 @@ Basic file conversion to the default output filename is straightforward:
     Conversion completed.
     '...objects_attrs.inv' converted to '...objects_attrs.txt' (plain).
     <BLANKLINE>
+    <BLANKLINE>
     >>> print(file_head('objects_attrs.txt', head=6))
     # Sphinx inventory version 2
     # Project: attrs
@@ -44,9 +43,9 @@ file:
 .. doctest:: convert_main
 
     >>> cli_run('sphobjinv convert plain objects_attrs.inv', inp='n\n')
-    <BLANKLINE>
     File exists. Overwrite (Y/N)? n
     <BLANKLINE>
+    <BLANKLINE>
     Exiting...
     <BLANKLINE>
     >>> cli_run('sphobjinv convert plain objects_attrs.inv objects_attrs_foo.txt')
@@ -54,11 +53,12 @@ file:
     Conversion completed.
     '...objects_attrs.inv' converted to '...objects_attrs_foo.txt' (plain).
     <BLANKLINE>
+    <BLANKLINE>
 
 If you don't provide an output file extension, the |soi| defaults
 (`.inv`/`.txt`/`.json`) will be used.
 
-If you want to pull an input file directly from the Web, use
+If you want to pull an input file directly from the internet, use
 :option:`--url` (note that the base filename is **not** inferred from the
 indicated URL):
 
@@ -71,6 +71,7 @@ indicated URL):
     Conversion completed.
     'https://github.com/b[...]ce/objects_attrs.inv' converted to '...objects.txt' (plain).
     <BLANKLINE>
+    <BLANKLINE>
     >>> print(file_head('objects.txt', head=6))
     # Sphinx inventory version 2
     # Project: attrs
@@ -99,22 +100,50 @@ it will automatically find and use the correct |objects.inv|:
     Conversion completed.
     '...objects.inv' converted to '...objects.txt' (plain).
     <BLANKLINE>
+    <BLANKLINE>
 
 |soi| only supports download of zlib-compressed |objects.inv| files by URL.
 Plaintext download by URL is unreliable, presumably due to encoding problems.
-If download of JSON files by URL is desirable, please
+If processing of JSON files by API URL is desirable, please
 `submit an issue <https://github.com/bskinn/sphobjinv/issues>`__.
 
+.. versionadded:: 2.1
+    The URL at which a remote inventory is found is now included
+    in JSON output:
+
+    .. doctest:: json-url
+
+        >>> cli_run('sphobjinv convert json -qu https://docs.python.org/3/ objects.json')
+        <BLANKLINE>
+        >>> data = json.loads(Path('objects.json').read_text())
+        >>> data["metadata"]["url"]
+        'https://docs.python.org/3/objects.inv'
+
+.. _cli_usage_json_added:
+
+.. versionadded:: 2.1
+    JSON and plaintext inventories can now be read from ``stdin`` and
+    written to ``stdout``, by using the special value ``-`` in the invocation.
+    E.g., to print to ``stdout``:
+
+    .. doctest:: stdio
+
+        >>> cli_run('sphobjinv co plain objects_attrs.inv -')
+        # Sphinx inventory version 2
+        # Project: attrs
+        # Version: 17.2
+        # The remainder of this file is compressed using zlib.
+        attr.Attribute py:class 1 api.html#$ -
+        attr.Factory py:class 1 api.html#$ -
+        attr.asdict py:function 1 api.html#$ -
+        ...
+
 
 **Usage**
 
-.. doctest:: convert_usage
+.. command-output:: sphobjinv convert --help
+    :ellipsis: 4
 
-    >>> cli_run('sphobjinv convert --help', head=4)
-    usage: sphobjinv convert [-h] [-e | -c] [-o] [-q] [-u]
-                             {zlib,plain,json} infile [outfile]
-    <BLANKLINE>
-    Convert intersphinx inventory to zlib-compressed, plaintext, or JSON formats.
 
 **Positional Arguments**
 
@@ -128,14 +157,22 @@ If download of JSON files by URL is desirable, please
 
     Path (or URL, if :option:`--url` is specified) to file to be converted.
 
+    If passed as ``-``, |soi| will attempt import of a plaintext or JSON
+    inventory from ``stdin`` (incompatible with :option:`--url`).
+
 .. option:: outfile
 
     *(Optional)* Path to desired output file. Defaults to same directory
     and main file name as input file but with extension
     |cour|\ .inv/.txt/.json\ |/cour|, as appropriate for the output format.
+
     A bare path is accepted here, using the default output
     file name/extension.
 
+    If passed as ``-``, or if omitted when `infile` is passed as ``-``,
+    |soi| will emit plaintext or JSON (but *not*
+    zlib-compressed) inventory contents to ``stdout``.
+
 **Flags**
 
 .. option:: -h, --help
@@ -149,12 +186,13 @@ If download of JSON files by URL is desirable, please
 
 .. option:: -q, --quiet
 
-    Suppress all output to `stdout`, regardless of success or failure.
+    Suppress all status message output, regardless of success or failure.
     Useful for scripting/automation.  Implies :option:`--overwrite`.
 
 .. option:: -u, --url
 
-    Treat :option:`infile` as a URL for download.
+    Treat :option:`infile` as a URL for download. Cannot be used when
+    :option:`infile` is passed as ``-``.
 
 .. option:: -e, --expand
 
diff --git a/doc/source/cli/implementation.rst b/doc/source/cli/implementation.rst
deleted file mode 100644
index a4fcc4be..00000000
--- a/doc/source/cli/implementation.rst
+++ /dev/null
@@ -1,10 +0,0 @@
-.. Module API page for cmdline.py
-
-sphobjinv.cmdline (non-API)
-===========================
-
-.. automodule:: sphobjinv.cmdline
-    :members:
-    :member-order: groupwise
-
-.. |argparse| replace:: :mod:`argparse`
\ No newline at end of file
diff --git a/doc/source/cli/implementation/core.rst b/doc/source/cli/implementation/core.rst
new file mode 100644
index 00000000..c6fd0a53
--- /dev/null
+++ b/doc/source/cli/implementation/core.rst
@@ -0,0 +1,8 @@
+.. Module API page for cli/core.py
+
+sphobjinv.cli.core
+==================
+
+.. automodule:: sphobjinv.cli.core
+    :members:
+
diff --git a/doc/source/cli/implementation/index.rst b/doc/source/cli/implementation/index.rst
new file mode 100644
index 00000000..55946645
--- /dev/null
+++ b/doc/source/cli/implementation/index.rst
@@ -0,0 +1,17 @@
+.. Module API page for CLI submodule code
+
+sphobjinv.cli (non-API)
+=======================
+
+.. toctree::
+    :maxdepth: 1
+
+    core
+    load
+    parser
+    paths
+    ui
+    write
+    
+
+.. .. |argparse| replace:: :mod:`argparse`
\ No newline at end of file
diff --git a/doc/source/cli/implementation/load.rst b/doc/source/cli/implementation/load.rst
new file mode 100644
index 00000000..2cdec7ec
--- /dev/null
+++ b/doc/source/cli/implementation/load.rst
@@ -0,0 +1,8 @@
+.. Module API page for cli/load.py
+
+sphobjinv.cli.load
+==================
+
+.. automodule:: sphobjinv.cli.load
+    :members:
+
diff --git a/doc/source/cli/implementation/parser.rst b/doc/source/cli/implementation/parser.rst
new file mode 100644
index 00000000..a7a18aa1
--- /dev/null
+++ b/doc/source/cli/implementation/parser.rst
@@ -0,0 +1,8 @@
+.. Module API page for cli/parser.py
+
+sphobjinv.cli.parser
+====================
+
+.. automodule:: sphobjinv.cli.parser
+    :members:
+
diff --git a/doc/source/cli/implementation/paths.rst b/doc/source/cli/implementation/paths.rst
new file mode 100644
index 00000000..dad6f38b
--- /dev/null
+++ b/doc/source/cli/implementation/paths.rst
@@ -0,0 +1,8 @@
+.. Module API page for cli/paths.py
+
+sphobjinv.cli.paths
+===================
+
+.. automodule:: sphobjinv.cli.paths
+    :members:
+
diff --git a/doc/source/cli/implementation/ui.rst b/doc/source/cli/implementation/ui.rst
new file mode 100644
index 00000000..bd59146d
--- /dev/null
+++ b/doc/source/cli/implementation/ui.rst
@@ -0,0 +1,8 @@
+.. Module API page for cli/ui.py
+
+sphobjinv.cli.ui
+================
+
+.. automodule:: sphobjinv.cli.ui
+    :members:
+
diff --git a/doc/source/cli/implementation/write.rst b/doc/source/cli/implementation/write.rst
new file mode 100644
index 00000000..f1eb55a8
--- /dev/null
+++ b/doc/source/cli/implementation/write.rst
@@ -0,0 +1,8 @@
+.. Module API page for cli/write.py
+
+sphobjinv.cli.write
+===================
+
+.. automodule:: sphobjinv.cli.write
+    :members:
+
diff --git a/doc/source/cli/index.rst b/doc/source/cli/index.rst
index fd88a46d..bcb1559d 100644
--- a/doc/source/cli/index.rst
+++ b/doc/source/cli/index.rst
@@ -6,7 +6,7 @@ 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>` and in the documentation for the
+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.
@@ -15,8 +15,8 @@ Some notes on these CLI docs:
 
  * CLI 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/master/sphobjinv/
-   test/resource/objects_attrs.inv>`__).
+   `here <https://github.com/bskinn/sphobjinv/blob/master/
+   tests/resource/objects_attrs.inv>`__).
 
  * :class:`~pathlib.Path` (from :mod:`pathlib`)
    is imported into the namespace before all tests.
@@ -38,46 +38,14 @@ The options for the parent |soi| command are:
 
     Show help message and exit
 
-.. doctest:: soi_base
-
-    >>> cli_run('sphobjinv --help')
-    usage: sphobjinv [-h] [-v] {convert,suggest} ...
-    <BLANKLINE>
-    Format conversion for and introspection of intersphinx 'objects.inv' files.
-    <BLANKLINE>
-    optional arguments:
-      -h, --help         show this help message and exit
-      -v, --version      Print package version & other info
-    <BLANKLINE>
-    Subcommands:
-      {convert,suggest}  Execution mode. Type 'sphobjinv [mode] -h' for more
-                         information on available options. Mode names can be
-                         abbreviated to their first two letters.
-        convert (co)     Convert intersphinx inventory to zlib-compressed,
-                         plaintext, or JSON formats.
-        suggest (su)     Fuzzy-search intersphinx inventory for desired object(s).
-    <BLANKLINE>
+.. program-output:: sphobjinv --help
 
 
 .. option:: -v, --version
 
     Print package version & other info
 
-.. doctest:: soi_base
-
-    >>> cli_run('sphobjinv --version')
-    <BLANKLINE>
-    sphobjinv v2.0.1
-    <BLANKLINE>
-    Copyright (c) Brian Skinn 2016-2020
-    License: The MIT License
-    <BLANKLINE>
-    Bug reports & feature requests: https://github.com/bskinn/sphobjinv
-    Documentation: http://sphobjinv.readthedocs.io
-    <BLANKLINE>
-    <BLANKLINE>
-
-
+.. program-output:: sphobjinv --version
 
 
 .. toctree::
diff --git a/doc/source/cli/suggest.rst b/doc/source/cli/suggest.rst
index 8f029ec8..67de7e29 100644
--- a/doc/source/cli/suggest.rst
+++ b/doc/source/cli/suggest.rst
@@ -5,107 +5,73 @@ Command-Line Usage: "suggest" Mode
 
 .. program:: sphobjinv suggest
 
-The |cour|\ suggest\ |/cour| subparser is used to query an inventory for objects
+The |cour|\ suggest\ |/cour| subcommand is used to query an inventory for objects
 fuzzy-matching a given search string. Fuzzy-matching is carried out via the
 |fuzzywuzzy|_ library, against the Restructured Text-like representation of each
 object exposed by :attr:`SuperDataObj.as_rst <sphobjinv.data.SuperDataObj.as_rst>`:
 
-.. doctest:: suggest_main
-
-    >>> cli_run('sphobjinv suggest objects_attrs.inv instance')
-    <BLANKLINE>
-    :py:exception:`attr.exceptions.FrozenInstanceError`
-    :py:function:`attr.validators.instance_of`
-    <BLANKLINE>
+.. command-output:: sphobjinv suggest objects_attrs.inv instance
+   :cwd: /../../tests/resource
 
 The |fuzzywuzzy|_ match score and the index of the object within the inventory can
 be printed by passing the :option:`--score` and :option:`--index` options,
 respectively:
 
-.. doctest:: suggest_main
-
-    >>> cli_run('sphobjinv suggest objects_attrs.inv instance -s -i')  # doctest: +NORMALIZE_WHITESPACE
-    <BLANKLINE>
-      Name                                                  Score    Index
-    -----------------------------------------------------  -------  -------
-    :py:exception:`attr.exceptions.FrozenInstanceError`      90        9
-    :py:function:`attr.validators.instance_of`               90       23
-    <BLANKLINE>
+.. command-output:: sphobjinv suggest objects_attrs.inv instance -s -i
+   :cwd: /../../tests/resource
 
 If too few or too many matches are returned, the reporting threshold can be changed
 via :option:`--thresh`:
 
-.. doctest:: suggest_main
-
-    >>> cli_run('sphobjinv suggest objects_attrs.inv instance -s -i -t 48')  # doctest: +NORMALIZE_WHITESPACE
-    <BLANKLINE>
-      Name                                                  Score    Index
-    -----------------------------------------------------  -------  -------
-    :py:exception:`attr.exceptions.FrozenInstanceError`      90        9
-    :py:function:`attr.validators.instance_of`               90       23
-    :std:doc:`license`                                       51       47
-    :py:function:`attr.filters.include`                      48       13
-    <BLANKLINE>
+.. command-output:: sphobjinv suggest objects_attrs.inv instance -s -i -t 48
+   :cwd: /../../tests/resource
 
 Remote |objects.inv| files can be retrieved for inspection by passing the
 :option:`--url` flag:
 
-.. doctest:: suggest_main
-
-    >>> cli_run('sphobjinv suggest https://github.com/bskinn/sphobjinv/raw/master/tests/resource/objects_attrs.inv instance -u -t 48')  # doctest: +NORMALIZE_WHITESPACE
-    <BLANKLINE>
-    Remote inventory found.
-    <BLANKLINE>
-    :py:exception:`attr.exceptions.FrozenInstanceError`
-    :py:function:`attr.validators.instance_of`
-    :std:doc:`license`
-    :py:function:`attr.filters.include`
-    <BLANKLINE>
+.. command-output:: sphobjinv suggest https://github.com/bskinn/sphobjinv/raw/master/tests/resource/objects_attrs.inv instance -u -t 48
+   :cwd: /../../tests/resource
 
 The URL provided **MUST** have the leading protocol specified (here,
 |cour|\ https\ ://\ |/cour|).
 
-It is not necessary to locate the |objects.inv| file before running |soi|;
+It is usually not necessary to locate the |objects.inv| file before running |soi|;
 for most Sphinx documentation sets, if you provide a URL to any page in the docs,
 it will automatically find and use the correct |objects.inv|:
 
-.. doctest:: suggest_main
-
-    >>> cli_run('sphobjinv suggest -u https://sphobjinv.readthedocs.io/en/v2.0rc1/cmdline.html compress')  # doctest: +NORMALIZE_WHITESPACE
-    <BLANKLINE>
-    No inventory at provided URL.
-    Attempting "https://sphobjinv.readthedocs.io/en/v2.0rc1/cmdline.html/objects.inv" ...
-    Attempting "https://sphobjinv.readthedocs.io/en/v2.0rc1/objects.inv" ...
-    Remote inventory found.
-    <BLANKLINE>
-    :py:function:`sphobjinv.zlib.compress`
-    :py:function:`sphobjinv.zlib.decompress`
-    <BLANKLINE>
+.. command-output:: sphobjinv suggest -u https://sphobjinv.readthedocs.io/en/v2.0rc1/cmdline.html compress
+   :cwd: /../../tests/resource
 
 |soi| only supports download of zlib-compressed |objects.inv| files by URL.
 Plaintext download by URL is unreliable, presumably due to encoding problems.
 If download of JSON files by URL is desirable, please
 `submit an issue <https://github.com/bskinn/sphobjinv/issues>`__.
 
+.. versionadded:: 2.1
+    The |soi| CLI can now read JSON and plaintext inventories from ``stdin``
+    by passing the special ``-`` argument for `infile`:
 
-**Usage**
+    .. command-output:: sphobjinv suggest -s - valid < objects_attrs.txt
+        :cwd: /../../tests/resource
+        :shell:
 
-.. doctest:: suggest_usage
+**Usage**
 
-    >>> cli_run('sphobjinv suggest --help', head=3)
-    usage: sphobjinv suggest [-h] [-a] [-i] [-s] [-t {0-100}] [-u] infile search
-    <BLANKLINE>
-    Fuzzy-search intersphinx inventory for desired object(s).
+.. command-output:: sphobjinv suggest --help
+   :ellipsis: 4
 
 **Positional Arguments**
 
 .. option:: infile
 
-    Path (or URL, if :option:`--url` is specified) to file to be converted.
+    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`).
 
 .. option:: search
 
-    Search term for |fuzzywuzzy|_ matching
+    Search term for |fuzzywuzzy|_ matching.
 
 **Flags**
 
@@ -117,7 +83,7 @@ If download of JSON files by URL is desirable, please
 
     Display all search results without prompting, regardless of the number of hits.
     Otherwise, prompt if number of results exceeds
-    :data:`sphobjinv.cmdline.SUGGEST_CONFIRM_LENGTH`.
+    :attr:`~sphobjinv.cli.parser.PrsConst.SUGGEST_CONFIRM_LENGTH`.
 
 .. option:: -i, --index
 
@@ -133,11 +99,12 @@ If download of JSON files by URL is desirable, please
 
     Change the |fuzzywuzzy|_ match quality threshold (0-100; higher values
     yield fewer results). Default is specified in
-    :data:`sphobjinv.cmdline.DEF_THRESH`.
+    :attr:`~sphobjinv.cli.parser.PrsConst.DEF_THRESH`.
 
 .. option:: -u, --url
 
-    Treat :option:`infile` as a URL for download.
+    Treat :option:`infile` as a URL for download. Cannot be used when
+    :option:`infile` is passed as ``-``.
 
 
 
diff --git a/doc/source/conf.py b/doc/source/conf.py
index d1175f86..dac34c0c 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -18,11 +18,13 @@
 # -- Project information -----------------------------------------------------
 
 project = 'sphobjinv'
-copyright = '2016-2020, Brian Skinn'
+copyright = '2016-2021, Brian Skinn'
 author = 'Brian Skinn'
 
-# The full version, including alpha/beta/rc tags
+# The full version for `release`, including alpha/beta/rc tags
 from sphobjinv import __version__ as release
+
+# Just major.minor for `version`
 version = ".".join(release.split(".")[:2])
 
 
@@ -36,6 +38,7 @@
     "sphinx.ext.doctest",
     "sphinx.ext.intersphinx",
     "sphinx.ext.napoleon",
+    "sphinxcontrib.programoutput",
     "sphinx_issues",
 ]
 
@@ -63,8 +66,11 @@
 # Ignore package prefix when sorting modules
 modindex_common_prefix = ["sphobjinv."]
 
-# Common epilogue
+
+# -- Common epilogue definition  ------------------------------------------------
+
 rst_epilog = r"""
+
 .. |extlink| image:: /_static/extlink.svg
 
 .. |dag| replace:: :math:`^\dagger`
@@ -93,6 +99,8 @@
 
 .. |dict| replace:: :obj:`dict`
 
+.. |Path| replace:: :obj:`~pathlib.Path`
+
 .. |re.compile| replace:: :func:`re.compile`
 
 .. |re| replace:: :doc:`re <python:library/re>`
@@ -103,6 +111,12 @@
 
 .. |Inventory| replace:: :class:`~sphobjinv.inventory.Inventory`
 
+.. |DataObjStr| replace:: :class:`~sphobjinv.data.DataObjStr`
+
+.. |DataObjBytes| replace:: :class:`~sphobjinv.data.DataObjBytes`
+
+.. |SuperDataObj| replace:: :class:`~sphobjinv.data.SuperDataObj`
+
 .. |license_txt| replace:: LICENSE.txt
 
 .. _license_txt: https://github.com/bskinn/sphobjinv/blob/master/LICENSE.txt
@@ -121,7 +135,7 @@
 
 .. |cour| raw:: html
 
-    <span style="font-family:courier;font-size:90%">
+    <span style="font-family:courier, monospace;font-size:90%">
 
 .. |/cour| raw:: html
 
@@ -135,20 +149,56 @@
 
 .. |isphxmap| replace:: ``intersphinx_mapping``
 
-.. _isphxmap: http://www.sphinx-doc.org/en/stable/ext/intersphinx.html#confval-intersphinx_mapping
+.. _isphxmap: https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#confval-intersphinx_mapping
 
 .. |soi| raw:: html
 
-    <span style="font-family:courier; font-size: 90%; font-weight: bold;">sphobjinv</span>
+    <span style="font-family:courier, monospace; font-size: 90%; font-weight: bold;">sphobjinv</span>
 
 .. |stdin| replace:: |cour|\ stdin\ |/cour|
 
 .. |stdout| replace:: |cour|\ stdout\ |/cour|
 
+.. |cli:ALL| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.ALL`
+
+.. |cli:DEF_BASENAME| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.DEF_BASENAME`
+
+.. |cli:DEF_OUT_EXT| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.DEF_OUT_EXT`
+
+.. |cli:FOUND_URL| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.FOUND_URL`
+
+.. |cli:INDEX| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.INDEX`
+
+.. |cli:INFILE| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.INFILE`
+
+.. |cli:MODE| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.MODE`
+
+.. |cli:OUTFILE| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.OUTFILE`
+
+.. |cli:OVERWRITE| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.OVERWRITE`
+
+.. |cli:QUIET| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.QUIET`
+
+.. |cli:SCORE| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.SCORE`
+
+.. |cli:SUBPARSER_NAME| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.SUBPARSER_NAME`
+
+.. |cli:SUGGEST_CONFIRM_LENGTH| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.SUGGEST_CONFIRM_LENGTH`
+
+.. |cli:URL| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.URL`
+
+.. |cli:VERSION| replace:: :attr:`~sphobjinv.cli.parser.PrsConst.VERSION`
+
+.. |resolve_inpath| replace:: :func:`~sphobjinv.cli.paths.resolve_inpath`
+
 """
 
-# Universal doctest setup code
+
+# -- doctest setup code  --------------------------------------------
+
+
 doctest_global_setup = """\
+import json
 import os
 from pathlib import Path
 import shutil as sh
@@ -193,7 +243,7 @@ def cli_run(argstr, *, inp='', head=None):
     '''
     import sys
 
-    import sphobjinv.cmdline as cli
+    import sphobjinv.cli as cli
     from stdio_mgr import stdio_mgr
 
     old_argv = sys.argv
@@ -221,8 +271,7 @@ def file_head(fn, *, head=None):
     if not p.is_file():
         return "Not a file."
 
-    with p.open() as f:
-        text = f.read()
+    text = p.read_text()
 
     # If head==None, then just returns a complete slice
     lines = text.splitlines()[:head]
@@ -243,10 +292,16 @@ def file_head(fn, *, head=None):
 
 intersphinx_mapping = {
     "python": ("https://docs.python.org/3", None),
-    "sphinx": ("http://www.sphinx-doc.org/en/master", None),
+    "sphinx": ("https://www.sphinx-doc.org/en/master", None),
 }
 
 
+# -- Options for linkcheck  --------------------------------------------------
+
+linkcheck_ignore = [r"^https?://(\w+[.])?twitter[.]com.*$"]
+linkcheck_anchors_ignore = [r"^L\d+$", r"^L\d+-L\d+$"]
+
+
 # -- Options for HTML output -------------------------------------------------
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
@@ -261,3 +316,7 @@ def file_head(fn, *, head=None):
 
 # Output file basename
 htmlhelp_basename = "sphobjinv"
+
+# Location of the favicon and logo images
+html_favicon = "_static/soi-logo.png"
+html_logo = "_static/soi-logo_duo_border.png"
diff --git a/doc/source/customfile.rst b/doc/source/customfile.rst
index 3fbfc3d6..d0ec127e 100644
--- a/doc/source/customfile.rst
+++ b/doc/source/customfile.rst
@@ -71,10 +71,10 @@ can be found at the GitHub repo
 
             The `role` values here must be the **full** role names ("`block directives`"),
             described as the "directives" in the `Sphinx documentation for
-            domains <http://www.sphinx-doc.org/en/1.7/domains.html#the-python-domain>`__,
+            domains <https://www.sphinx-doc.org/en/1.7/domains.html#the-python-domain>`__,
             and not the abbreviated forms ("`inline directives`")
             `used when constructing cross-references
-            <http://www.sphinx-doc.org/en/1.7/domains.html#cross-referencing-python-objects>`__.
+            <https://www.sphinx-doc.org/en/1.7/domains.html#cross-referencing-python-objects>`__.
 
             Thus, for example, a :class:`~sphobjinv.data.DataObjStr` corresponding
             to a method on a class should be constructed with
@@ -146,10 +146,10 @@ can be found at the GitHub repo
                 'python': ('https://docs.python.org/3.5', None),
 
                 # Django puts its objects.inv file in a non-standard location
-                'django': ('http://docs.djangoproject.com/en/dev/', 'https://docs.djangoproject.com/en/dev/_objects/'),
+                'django': ('https://docs.djangoproject.com/en/dev/', 'https://docs.djangoproject.com/en/dev/_objects/'),
 
                 # Drawing the Sphinx objects.inv from a local copy, but referring to the 1.7 web docs
-                'sphinx': ('http://www.sphinx-doc.org/en/1.7/', '/path/to/local/objects.inv',
+                'sphinx': ('https://www.sphinx-doc.org/en/1.7/', '/path/to/local/objects.inv',
             }
 
     .. MAKE SURE TO UPDATE THESE TWO STEP REFERENCES IF NUMBERING CHANGES!!
diff --git a/doc/source/index.rst b/doc/source/index.rst
index afb804e0..e9b91cca 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -31,12 +31,39 @@ In particular, |soi| was developed to satisfy two primary use cases:
  #. Assembly of new |objects.inv| files in order to allow |isphx| cross-referencing
     of other documentation sets that were not created by Sphinx.
 
+For more background on the mechanics of the Sphinx data model and
+Sphinx cross-references generally, see
+`this talk <https://www.youtube.com/watch?v=CfInPYkbTZE>`__ from PyOhio 2019.
+
 ----
 
 Install |soi| via |cour|\ pip\ |/cour|::
 
     $ pip install sphobjinv
 
+Alternatively, |soi| is packaged with
+`multiple POSIX distributions <https://repology.org/projects/?search=sphobjinv>`__,
+including:
+
+  * Alpine Linux: ``py3-sphobjinv`` (`info <https://pkgs.alpinelinux.org/packages?name=py3-sphobjinv>`__)
+
+  * Arch Linux: ``python-sphobjinv`` (`info <https://archlinux.org/packages/community/any/python-sphobjinv/>`__)
+
+  * Fedora: ``python-sphobjinv`` (`info <https://src.fedoraproject.org/rpms/python-sphobjinv>`__)
+
+  * Gentoo GURU: ``dev-python/sphobjinv`` (`info <https://gpo.zugaina.org/Overlays/guru/dev-python/sphobjinv>`__)
+
+  * Manjaro: ``python-sphobjinv``
+
+  * OpenEuler: ``python-sphobjinv``
+
+  * openSUSE: ``python-sphobjinv`` (`info <https://build.opensuse.org/package/show/openSUSE:Leap:15.2:Update/python-sphobjinv>`__)
+
+  * Parabola: ``python-sphobjinv`` (`info <https://www.parabola.nu/packages/?q=python-sphobjinv>`__)
+
+  * pkgsrc: ``textproc/py-sphobjinv`` (`info <https://pkgsrc.se/textproc/py-sphobjinv>`__)
+
+
 The package is configured for use both as a
 :doc:`command-line script <cli/index>` and as a
 :doc:`Python package <api_usage>`.
@@ -46,7 +73,7 @@ accelerates the the "suggest" functionality; see
 :doc:`here <levenshtein>` for more information.
 
 The project source repository is on GitHub: `bskinn/sphobjinv
-<https://www.github.com/bskinn/sphobjinv>`__.
+<https://github.com/bskinn/sphobjinv>`__.
 
 
 .. Contents
@@ -62,7 +89,7 @@ The project source repository is on GitHub: `bskinn/sphobjinv
    levenshtein
    syntax
    api/index
-   CLI Implementation (non-API) <cli/implementation>
+   CLI Implementation (non-API) <cli/implementation/index>
 
 
 
@@ -73,3 +100,9 @@ Indices and Tables
 * :ref:`modindex`
 * :ref:`search`
 
+
+.. raw:: html
+
+   <small><em>Logo adapted from freely-available image at
+   https://heypik.com/images/hand-drawn-cartoon-color-tourist-attraction-sphinx_8ZU4501.html
+   (retrieved July 2019; now defunct).</em></small>
diff --git a/doc/source/levenshtein.rst b/doc/source/levenshtein.rst
index bca7e326..ba155cb4 100644
--- a/doc/source/levenshtein.rst
+++ b/doc/source/levenshtein.rst
@@ -20,14 +20,19 @@ used if it is available.
 Installation
 ------------
 
-On Linux, simple installation via
-|cour|\ pip install python-Levenshtein\ |/cour| should work
-properly in the vast majority of cases.
-This may work relatively smoothly for MacOS as well, but
-first-hand	confirmation was not (`yet <https://twitter.com/btskinn/status/1024503861443276801>`__)
-available as of this writing.
-
-On Windows, again as of this writing,
+On Linux and MacOS, installing |soi| with the ``speedup`` extra
+should work properly to install |python-Levenshtein|_
+in the vast majority of cases::
+
+    $ pip install sphobjinv[speedup]
+
+If you need to work with a version of |soi| prior to v2.1,
+the ``speedup`` extra is not available and direct installation
+is required::
+
+    $ pip install sphobjinv==2.0.1 python-Levenshtein
+
+On Windows, as of this writing
 `no binary wheel is available on PyPI <https://pypi.org/project/python-Levenshtein/0.12.0/#files>`__.
 So, the easiest way to install is to download a wheel from
 `Christoph Gohlke's repository <https://www.lfd.uci.edu/~gohlke/pythonlibs/#python-levenshtein>`__
diff --git a/doc/source/syntax.rst b/doc/source/syntax.rst
index 2be524d4..3ae2f495 100644
--- a/doc/source/syntax.rst
+++ b/doc/source/syntax.rst
@@ -5,12 +5,22 @@ Sphinx objects.inv v2 Syntax
 
 After decompression, "version 2" Sphinx |objects.inv| files
 follow a syntax that, to the best of this author's ability to determine,
-is completely undocumented. The below
-syntax is believed to be accurate as of Jun 2018 (Sphinx v1.7.4).
+is not included in the Sphinx documentation. The below
+syntax is believed to be accurate as of Feb 2021 (Sphinx v3.4.3).
+
 Based upon a quick ``git diff`` of the `Sphinx repository
-<https://github.com/sphinx-doc/sphinx>`__, it is thought to be accurate for all
+<https://github.com/sphinx-doc/sphinx>`__, it is thought to be valid for all
 Sphinx versions >=1.0b1 that make use of this "version 2" |objects.inv| format.
 
+**NOTE** that the previous version of the syntax presented here has been
+shown to be inaccurate (see :issue:`181`), in that it *is*
+permitted for the |{name}|_ field to contain whitespace.
+The descriptions below have been updated to reflect this and to provide
+more detailed information on the constraints governing each field
+of an |objects.inv| data line.
+
+----
+
 **The first line** `must be exactly
 <https://github.com/sphinx-doc/sphinx/blob/f7b3292d87e9a2b7eae0b4ef72e87779beefc699/sphinx/util/inventory.py#L105-L106>`__:
 
@@ -18,6 +28,8 @@ Sphinx versions >=1.0b1 that make use of this "version 2" |objects.inv| format.
 
     # Sphinx inventory version 2
 
+----
+
 **The second and third lines** `must obey
 <https://github.com/sphinx-doc/sphinx/blob/f7b3292d87e9a2b7eae0b4ef72e87779beefc699/sphinx/util/inventory.py#L133-L134>`__
 the template:
@@ -27,6 +39,8 @@ the template:
     # Project: <project name>
     # Version: <full version number>
 
+The version number should *not* include a leading "v".
+
 .. _syntax-mouseover-example:
 
 The above project name and version are used to populate mouseovers for
@@ -34,15 +48,17 @@ the |isphx| cross-references:
 
     .. image:: _static/mouseover_example.png
 
+----
+
 **The fourth line** `must contain
 <https://github.com/sphinx-doc/sphinx/blob/f7b3292d87e9a2b7eae0b4ef72e87779beefc699/sphinx/util/inventory.py#L136-L137>`__
-the string 'zlib' somewhere in it, but for the purposes of consistency it should
-be exactly:
+the string ``zlib`` somewhere within it, but for consistency it should be exactly:
 
 .. code-block:: none
 
     # The remainder of this file is compressed using zlib.
 
+----
 
 **All remaining lines** of the file are the objects data, each laid out in the
 `following syntax
@@ -52,41 +68,125 @@ be exactly:
 
     {name} {domain}:{role} {priority} {uri} {dispname}
 
+.. _{name}:
+
 ``{name}``
+
     The object name used when cross-referencing the object (falls between the
-    backticks)
+    backticks).
+
+    **Constraints**
+
+    * **MUST** have nonzero length
+
+    * **MUST NOT** start with ``#``
+
+    * **SHOULD** have no leading or trailing whitespace
+
+    * **MAY** contain internal whitespace
+
+.. _{domain}:
 
 ``{domain}``
     The Sphinx domain used when cross-referencing the object (falls between
-    the first and second colons; omitted if using the |defdom|_)
+    the first and second colons; omitted if using the |defdom|_).
+
+    **Constraints**
+
+    * **MUST** have nonzero length
+
+    * **MUST** match a built-in or installable Sphinx domain
+
+    * **MUST NOT** contain whitespace or a colon
+
+      * **RECOMMENDED** to contain only ASCII word characters (``a-z``, ``A-Z``,
+        ``0-9``, and ``_``)
+
+.. _{role}:
 
 ``{role}``
     The Sphinx role used when cross-referencing the object (falls between the
     second and third colons; or, between the first and second colons if
-    using the |defdom|_)
+    using the |defdom|_).
+
+    **Constraints**
+
+    * **MUST** have nonzero length
+
+    * **MUST** match a role defined in the domain referenced by ``{domain}``
+
+    * **MUST NOT** contain whitespace or a colon
+
+      * **RECOMMENDED** to consist of only ASCII word characters (``a-z``, ``A-Z``,
+        ``0-9``, and ``_``)
+
+.. _{priority}:
 
 ``{priority}``
     Flag for `placement in search results
     <https://github.com/sphinx-doc/sphinx/blob/f7b3292d87e9a2b7eae0b4ef72e87779beefc699/sphinx/domains/
-    __init__.py#L319-L325>`__. Most will be 1 (standard priority) or
-    -1 (omit from results)
+    __init__.py#L319-L325>`__. Most will be ``1`` (standard priority) or
+    ``-1`` (omit from results) for documentation built by Sphinx.
+
+    To note, as of Feb 2021 this value is **not** used by ``intersphinx``;
+    it is only used internally within the search function of the static webpages
+    built *by Sphinx* (|prio_py_search|_ and |prio_js_search|_). Thus, custom
+    inventories likely **MAY** use this field for arbitrary content, if desired,
+    as long as the integer constraint is observed.
+    Such use *would* run the risk of a future change to Sphinx/intersphinx
+    breaking compatibility with |objects.inv| files
+    having non-standard |{priority}|_ values.
+
+    **Constraints**
+
+    * **MUST** have nonzero length
+
+    * **MUST** be a positive or negative integer, or zero,
+      *without* a decimal point
+
+    * **MUST NOT** contain whitespace (implicit in the integer constraint)
+
+.. _{uri}:
 
 ``{uri}``
     Relative URI for the location to which cross-references will point.
     The base URI is taken from the relevant element of the |isphxmap|
-    configuration parameter of ``conf.py``.
+    configuration parameter in ``conf.py``.
+
+    **Constraints**
+
+    * **MAY** have zero length, but typically has nonzero length
+
+      * A zero-length |{uri}|_ can occur for the
+        root/index documentation page in certain instances;
+        see |sphinx_uri_issue|_
+
+    * **MUST NOT** contain whitespace
+
+.. _{dispname}:
 
 ``{dispname}``
     Default cross-reference text to be displayed in compiled documentation.
 
-.. note::
+    **Constraints**
+
+    * **MUST** have nonzero length
+
+    * **MAY** contain internal whitespace (leading/trailing whitespace
+      is ignored)
+
+Unicode characters appear to be valid for all fields except
+|{uri}|_ (where they are `specifically forbidden <https://stackoverflow.com/a/1916747/4376000>`__)
+and |{priority}|_.
+However, it is **RECOMMENDED** that they *only* be used in |{dispname}|_,
+as their use in |{name}|_, |{domain}|_ and |{role}|_ would complicate construction
+of cross-references from other documentation source.
 
-    The above fields MUST NOT contain spaces,
-    except for ``{dispname}`` which MAY contain them.
+----
 
 **For illustration**, the following is the entry for the
 :meth:`join() <str.join>` method of the :class:`str` class in the
-Python 3.5 |objects.inv|, broken out field-by-field:
+Python 3.9 |objects.inv|, broken out field-by-field:
 
 .. code-block:: none
 
@@ -105,14 +205,14 @@ Python 3.5 |objects.inv|, broken out field-by-field:
 The above illustrates two shorthand notations that were introduced to shrink the
 size of the inventory file:
 
- #. If ``{uri}`` has an anchor (technically a "`fragment identifier
+ #. If |{uri}|_ has an anchor (technically a "`fragment identifier
     <https://en.wikipedia.org/wiki/Fragment_identifier>`__," the portion
     following the ``#`` symbol) and the tail of the anchor is identical to
-    ``{name}``, that tail is `replaced
+    |{name}|_, that tail is `replaced
     <https://github.com/sphinx-doc/sphinx/blob/f7b3292d87e9a2b7eae0b4ef72e87779beefc699/sphinx/util/inventory.py#L180-L182>`__
     with ``$``. |br| |br|
 
- #. If ``{dispname}`` is identical to ``{name}``, it is `stored
+ #. If |{dispname}|_ is identical to |{name}|_, it is `stored
     <https://github.com/sphinx-doc/sphinx/blob/f7b3292d87e9a2b7eae0b4ef72e87779beefc699/sphinx/util/inventory.py#L186-L187>`__
     as ``-``.
 
@@ -129,6 +229,8 @@ The cross-reference would show as :meth:`str.join` and link to the relative URI:
 
     library/stdtypes.html#str.join
 
+----
+
 **Other intersphinx Syntax Examples**
 
 To show as only :meth:`~str.join`:
@@ -152,7 +254,32 @@ as in :obj:`This is join! <str.join>`:
 
 
 
+.. ## Definitions ##
+
 .. |defdom| replace:: default domain
 
-.. _defdom: http://www.sphinx-doc.org/en/stable/domains.html
+.. _defdom: https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html
+
+.. |prio_js_search| replace:: here
+
+.. _prio_js_search: https://github.com/sphinx-doc/sphinx/blob/241577f65eea94a08944bf096bd704b495282373/sphinx/themes/basic/static/searchtools.js#L26-L43
+
+.. |prio_py_search| replace:: here
+
+.. _prio_py_search: https://github.com/sphinx-doc/sphinx/blob/241577f65eea94a08944bf096bd704b495282373/sphinx/search/__init__.py#L332
+
+.. |sphinx_uri_issue| replace:: sphinx-doc/sphinx#7096
+
+.. _sphinx_uri_issue: https://github.com/sphinx-doc/sphinx/issues/7096
+
+.. |{name}| replace:: ``{name}``
+
+.. |{domain}| replace:: ``{domain}``
+
+.. |{role}| replace:: ``{role}``
+
+.. |{priority}| replace:: ``{priority}``
+
+.. |{uri}| replace:: ``{uri}``
 
+.. |{dispname}| replace:: ``{dispname}``
diff --git a/requirements-travis.txt b/requirements-ci.txt
similarity index 67%
rename from requirements-travis.txt
rename to requirements-ci.txt
index 77e4cd47..4060bd31 100644
--- a/requirements-travis.txt
+++ b/requirements-ci.txt
@@ -2,16 +2,20 @@ attrs>=17.4
 certifi
 codecov
 coverage
+dictdiffer
 fuzzywuzzy>=0.3
 jsonschema
+md-toc
 pytest>=4.4.0
+pytest-check>=0.4
 pytest-cov
 pytest-ordering
-pytest-subtests
 pytest-timeout
-sphinx==2.3.1
+sphinx==3.5.0
 sphinx-issues
 sphinx-rtd-theme
+sphinxcontrib-programoutput
 stdio-mgr>=1.0.1
 tox
+wheel
 -e .
diff --git a/requirements-dev.txt b/requirements-dev.txt
index e1cd7d17..fa04aef0 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,17 +1,22 @@
 attrs>=17.4
 certifi
 coverage
+dictdiffer
 fuzzywuzzy>=0.3
 jsonschema
+md-toc
+pep517
 pytest>=4.4.0
+pytest-check>=0.4
 pytest-cov
 pytest-ordering
-pytest-subtests
 pytest-timeout
 restview
-sphinx==2.3.1
+rope
+sphinx==3.5.0
 sphinx-autobuild
 sphinx-issues
+sphinxcontrib-programoutput
 sphinx-rtd-theme
 stdio-mgr>=1.0.1
 tox
diff --git a/requirements-flake8.txt b/requirements-flake8.txt
index 759d6314..1d4311d6 100644
--- a/requirements-flake8.txt
+++ b/requirements-flake8.txt
@@ -1,3 +1,4 @@
+colorama
 flake8>=3.7
 flake8-2020
 flake8-absolute-import
@@ -9,6 +10,7 @@ flake8-colors
 flake8-comprehensions
 flake8-docstrings>=1.3.1
 flake8-eradicate
+flake8-implicit-str-concat
 flake8-import-order
 flake8-pie
 flake8-rst-docstrings
diff --git a/requirements-rtd.txt b/requirements-rtd.txt
index c86a693b..a916f574 100644
--- a/requirements-rtd.txt
+++ b/requirements-rtd.txt
@@ -1,4 +1,5 @@
 attrs>=17.4
-sphinx==2.3.1
+sphinx==3.5.0
 sphinx-issues
 sphinx-rtd-theme
+sphinxcontrib-programoutput
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 00000000..5c02a880
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,57 @@
+[metadata]
+name = sphobjinv
+description = Sphinx objects.inv Inspection/Manipulation Tool
+url = https://github.com/bskinn/sphobjinv
+project_urls =
+    Changelog=https://github.com/bskinn/sphobjinv/blob/master/CHANGELOG.md
+    Docs=https://sphobjinv.readthedocs.io/en/stable/
+    Thank=https://twitter.com/btskinn
+    Donate=https://github.com/sponsors/bskinn
+license = MIT License
+license_file = LICENSE.txt
+platforms = any
+author = Brian Skinn
+author_email = bskinn@alum.mit.edu
+classifiers =
+    License :: OSI Approved
+    License :: OSI Approved :: MIT License
+    Natural Language :: English
+    Environment :: Console
+    Framework :: Sphinx
+    Intended Audience :: Developers
+    Operating System :: OS Independent
+    Programming Language :: Python
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3 :: Only
+    Programming Language :: Python :: 3.6
+    Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3.10
+    Topic :: Utilities
+    Development Status :: 5 - Production/Stable
+keywords = sphinx, sphinx-doc, inventory, manager, inspector
+
+[options]
+install_requires =
+    attrs>=19.2
+    certifi
+    fuzzywuzzy>=0.8
+    jsonschema>=3.0
+
+python_requires = >=3.6
+packages = find:
+package_dir =
+    =src
+
+[options.packages.find]
+where = src
+
+[options.extras_require]
+speedup =
+    python-Levenshtein;platform_system!="Windows"
+
+[options.entry_points]
+console_scripts =
+    sphobjinv=sphobjinv.cli.core:main
+    
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 8713c56f..af39cea0 100644
--- a/setup.py
+++ b/setup.py
@@ -1,15 +1,14 @@
 import re
 from pathlib import Path
-from setuptools import find_packages, setup
-
-
-exec(Path("src", "sphobjinv", "version.py").read_text(encoding="utf-8"))
 
+from setuptools import setup
 
 NAME = "sphobjinv"
 
+exec(Path("src", "sphobjinv", "version.py").read_text(encoding="utf-8"))
+
 
-version_override = "2.0"
+version_override = None
 
 
 def readme():
@@ -38,36 +37,6 @@ def content_update(content, pattern, sub):
 setup(
     name=NAME,
     version=__version__,
-    description="Sphinx objects.inv Inspection/Manipulation Tool",
     long_description=readme(),
     long_description_content_type="text/x-rst",
-    url="https://github.com/bskinn/sphobjinv",
-    license="MIT License",
-    author="Brian Skinn",
-    author_email="bskinn@alum.mit.edu",
-    packages=find_packages("src"),
-    package_dir={"": "src"},
-    provides=["sphobjinv"],
-    python_requires=">=3.5",
-    requires=["attrs (>=17.4)", "certifi", "fuzzywuzzy (>=0.3)", "jsonschema (>=2.0)"],
-    install_requires=["attrs>=17.4", "certifi", "fuzzywuzzy>=0.3", "jsonschema>=2.0"],
-    classifiers=[
-        "License :: OSI Approved",
-        "License :: OSI Approved :: MIT License",
-        "Natural Language :: English",
-        "Environment :: Console",
-        "Framework :: Sphinx",
-        "Intended Audience :: Developers",
-        "Operating System :: OS Independent",
-        "Programming Language :: Python",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3 :: Only",
-        "Programming Language :: Python :: 3.5",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Topic :: Utilities",
-        "Development Status :: 5 - Production/Stable",
-    ],
-    entry_points={"console_scripts": ["sphobjinv = sphobjinv.cmdline:main"]},
 )
diff --git a/src/sphobjinv/__init__.py b/src/sphobjinv/__init__.py
index f92d8683..d3a81daa 100644
--- a/src/sphobjinv/__init__.py
+++ b/src/sphobjinv/__init__.py
@@ -10,13 +10,13 @@
     17 May 2016
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
@@ -33,5 +33,5 @@
 from sphobjinv.inventory import Inventory
 from sphobjinv.re import p_data, pb_comments, pb_data, pb_project, pb_version
 from sphobjinv.schema import json_schema
-from sphobjinv.version import __version__  # noqa: F401
+from sphobjinv.version import __version__
 from sphobjinv.zlib import compress, decompress
diff --git a/src/sphobjinv/__main__.py b/src/sphobjinv/__main__.py
new file mode 100644
index 00000000..f3311c7d
--- /dev/null
+++ b/src/sphobjinv/__main__.py
@@ -0,0 +1,37 @@
+r"""``sphobjinv`` *package execution module*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    15 May 2020
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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
+
+from sphobjinv.cli.core import main
+
+if __name__ == "__main__":
+    # Spoof so 'help' usage display shows "sphobjinv" and
+    # not "__main__.py"
+    sys.argv[0] = "sphobjinv"
+
+    sys.exit(main())
diff --git a/src/sphobjinv/cli/__init__.py b/src/sphobjinv/cli/__init__.py
new file mode 100644
index 00000000..db4f8894
--- /dev/null
+++ b/src/sphobjinv/cli/__init__.py
@@ -0,0 +1,28 @@
+r"""``sphobjinv.cli`` *subpackage definition module*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    15 Nov 2020
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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.core import main
diff --git a/src/sphobjinv/cli/core.py b/src/sphobjinv/cli/core.py
new file mode 100644
index 00000000..0c1a5904
--- /dev/null
+++ b/src/sphobjinv/cli/core.py
@@ -0,0 +1,220 @@
+r"""``sphobjinv`` *CLI core execution module*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    15 Nov 2020
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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
+
+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 |cour|\ -\\-quiet\ |/cour| 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,
+    )
+
+    if len(results) == 0:
+        log_print("No results found.", params)
+        return
+
+    # 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))
+
+
+def main():
+    r"""Handle command line invocation.
+
+    Parses command line arguments,
+    handling the no-arguments and
+    |cli:VERSION| cases.
+
+    Creates the |Inventory| from the indicated source
+    and method.
+
+    Invokes :func:`do_convert` or :func:`do_suggest`
+    per the subparser name stored in |cli:SUBPARSER_NAME|.
+
+    """
+    # If no args passed, stick in '-h'
+    if len(sys.argv) == 1:
+        sys.argv.append("-h")
+
+    # Parse commandline arguments
+    prs = getparser()
+    ns, args_left = 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
+    log_print(" ", 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")
+        inv, in_path = inv_url(params)
+    elif params[PrsConst.INFILE] == "-":
+        inv = inv_stdin(params)
+        in_path = None
+    else:
+        inv, in_path = inv_local(params)
+
+    # Perform action based upon mode
+    if params[PrsConst.SUBPARSER_NAME][:2] == PrsConst.CONVERT[:2]:
+        do_convert(inv, in_path, params)
+    elif params[PrsConst.SUBPARSER_NAME][:2] == PrsConst.SUGGEST[:2]:
+        do_suggest(inv, params)
+
+    # Cosmetic final blank line
+    log_print(" ", params)
+
+    # Clean exit
+    sys.exit(0)
diff --git a/src/sphobjinv/cli/load.py b/src/sphobjinv/cli/load.py
new file mode 100644
index 00000000..671af7b0
--- /dev/null
+++ b/src/sphobjinv/cli/load.py
@@ -0,0 +1,241 @@
+r"""Module for ``sphobjinv`` *CLI |Inventory| loading*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    17 Nov 2020
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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 json
+import sys
+from json import JSONDecodeError
+from urllib.error import HTTPError, URLError
+
+from jsonschema.exceptions import ValidationError
+
+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
+
+
+def import_infile(in_path):
+    """Attempt import of indicated file.
+
+    Convenience function wrapping attempts to load an
+    |Inventory| from a local path.
+
+    Parameters
+    ----------
+    in_path
+
+        |str| -- Path to input file
+
+    Returns
+    -------
+    inv
+
+        |Inventory| or |None| -- If instantiation with the file at
+        `in_path` succeeds, the resulting |Inventory| instance;
+        otherwise, |None|
+
+    """
+    # Try general import, for zlib or plaintext files
+    try:
+        inv = Inventory(in_path)
+    except AttributeError:
+        pass  # Punt to JSON attempt
+    else:
+        return inv
+
+    # Maybe it's JSON
+    try:
+        inv = Inventory(readjson(in_path))
+    except JSONDecodeError:
+        return None
+    else:
+        return inv
+
+
+def inv_local(params):
+    """Create |Inventory| from local source.
+
+    Uses |resolve_inpath| to sanity-check and/or convert
+    |cli:INFILE|.
+
+    Calls :func:`sys.exit` internally in error-exit situations.
+
+    Parameters
+    ----------
+    params
+
+        |dict| -- Parameters/values mapping from the active subparser
+
+    Returns
+    -------
+    inv
+
+        |Inventory| -- Object representation of the inventory
+        at |cli:INFILE|
+
+    in_path
+
+        |str| -- Input file path as resolved/checked by
+        |resolve_inpath|
+
+    """
+    # Resolve input file path
+    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)
+        sys.exit(1)
+
+    # Attempt import
+    inv = import_infile(in_path)
+    if inv is None:
+        log_print("\nError: Unrecognized file format", params)
+        sys.exit(1)
+
+    return inv, in_path
+
+
+def inv_url(params):
+    """Create |Inventory| from file downloaded from URL.
+
+    Initially, treats |cli:INFILE| as a download URL to be passed to
+    the `url` initialization argument
+    of :class:`~sphobjinv.inventory.Inventory`.
+
+    If an inventory is not found at that exact URL, progressively
+    searches the directory tree of the URL for |objects.inv|.
+
+    Injects the URL at which an inventory was found into `params`
+    under the |cli:FOUND_URL| key.
+
+    Calls :func:`sys.exit` internally in error-exit situations.
+
+    Parameters
+    ----------
+    params
+
+        |dict| -- Parameters/values mapping from the active subparser
+
+    Returns
+    -------
+    inv
+
+        |Inventory| -- Object representation of the inventory
+        at |cli:INFILE|
+
+    ret_path
+
+        |str| -- URL from |cli:INFILE| used to construct `inv`.
+        If URL is longer than 45 characters, the central portion is elided.
+
+    """
+    in_file = params[PrsConst.INFILE]
+
+    # Disallow --url mode on local files
+    if in_file.startswith("file:/"):
+        log_print("\nError: URL mode on local file is invalid", params)
+        sys.exit(1)
+
+    # Need to initialize the inventory variable
+    inv = None
+
+    # Try URL as provided
+    try:
+        inv = Inventory(url=in_file)
+    except (HTTPError, ValueError, VersionError, URLError):
+        log_print("No inventory at provided URL.", params)
+    else:
+        log_print("Remote inventory found.", params)
+        url = in_file
+
+    # Keep searching if inv not found yet
+    if not inv:
+        for url in urlwalk(in_file):
+            log_print(f'Attempting "{url}" ...', params)
+            try:
+                inv = Inventory(url=url)
+            except (ValueError, HTTPError):
+                pass
+            else:
+                log_print("Remote inventory found.", params)
+                break
+
+    # Cosmetic line break
+    log_print(" ", params)
+
+    # Success or no?
+    if not inv:
+        log_print("No inventory found!", params)
+        sys.exit(1)
+
+    params.update({PrsConst.FOUND_URL: url})
+    if len(url) > 45:
+        ret_path = url[:20] + "[...]" + url[-20:]
+    else:  # pragma: no cover
+        ret_path = url
+
+    return inv, ret_path
+
+
+def inv_stdin(params):
+    """Create |Inventory| from contents of stdin.
+
+    Due to stdin's encoding and formatting assumptions, only
+    text-based inventory formats can be sanely parsed.
+
+    Thus, only plaintext and JSON inventory formats can be
+    used as inputs here.
+
+    Parameters
+    ----------
+    params
+
+        |dict| -- Parameters/values mapping from the active subparser
+
+    Returns
+    -------
+    inv
+
+        |Inventory| -- Object representation of the inventory
+        provided at stdin
+
+    """
+    data = sys.stdin.read()
+
+    try:
+        return Inventory(dict_json=json.loads(data))
+    except (JSONDecodeError, ValidationError):
+        pass
+
+    try:
+        return Inventory(plaintext=data)
+    except (AttributeError, UnicodeEncodeError, TypeError):
+        pass
+
+    log_print("Invalid plaintext or JSON inventory format.", params)
+    sys.exit(1)
diff --git a/src/sphobjinv/cli/parser.py b/src/sphobjinv/cli/parser.py
new file mode 100644
index 00000000..5d1fe4d3
--- /dev/null
+++ b/src/sphobjinv/cli/parser.py
@@ -0,0 +1,369 @@
+r"""``sphobjinv`` *CLI parser definition module*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    15 Nov 2020
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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 argparse as ap
+
+from sphobjinv.version import __version__
+
+
+class PrsConst:
+    """Container for CLI parser constants."""
+
+    # ### Version arg and helpers
+    #: Optional argument name for use with the base
+    #: argument parser, to show version &c. info, and exit
+    VERSION = "version"
+
+    #: Version &c. output blurb
+    VER_TXT = (
+        f"\nsphobjinv v{__version__}\n\nCopyright (c) Brian Skinn 2016-2021\n"
+        "License: The MIT License\n\n"
+        "Bug reports & feature requests:"
+        " https://github.com/bskinn/sphobjinv\n"
+        "Documentation:"
+        " https://sphobjinv.readthedocs.io\n"
+    )
+
+    # ### Subparser selectors and argparse param for storing subparser name
+    #: Subparser name for inventory file conversions; stored in
+    #: :data:`SUBPARSER_NAME` when selected
+    CONVERT = "convert"
+
+    #: Subparser name for inventory object suggestions; stored in
+    #: :data:`SUBPARSER_NAME` when selected
+    SUGGEST = "suggest"
+
+    #: Param for storing subparser name
+    #: (:data:`CONVERT` or :data:`SUGGEST`)
+    SUBPARSER_NAME = "sprs_name"
+
+    # ### Common URL argument for both subparsers
+    #: Optional argument name for use with both :data:`CONVERT` and
+    #: :data:`SUGGEST` subparsers, indicating that
+    #: :data:`INFILE` is to be treated as a URL
+    #: rather than a local file path
+    URL = "url"
+
+    # ### Conversion subparser: 'mode' param and choices
+    #: Positional argument name for use with :data:`CONVERT` subparser,
+    #: indicating output file format
+    #: (:data:`ZLIB`, :data:`PLAIN` or :data:`JSON`)
+    MODE = "mode"
+
+    #: Argument value for :data:`CONVERT` :data:`MODE`,
+    #: to output a :mod:`zlib`-compressed inventory
+    ZLIB = "zlib"
+
+    #: Argument value for :data:`CONVERT` :data:`MODE`,
+    #: to output a plaintext inventory
+    PLAIN = "plain"
+
+    #: Argument value for :data:`CONVERT` :data:`MODE`,
+    #: to output an inventory as JSON
+    JSON = "json"
+
+    # ### Source/destination params
+    #: Required positional argument name for use with both :data:`CONVERT` and
+    #: :data:`SUGGEST` subparsers, holding the path
+    #: (or URL, if :data:`URL` is specified)
+    #: to the input file
+    INFILE = "infile"
+
+    #: Optional positional argument name
+    #: for use with the :data:`CONVERT` subparser,
+    #: holding the path to the output file
+    #: (:data:`DEF_BASENAME` and the appropriate item from :data:`DEF_OUT_EXT`
+    #: are used if this argument is not provided)
+    OUTFILE = "outfile"
+
+    # ### Convert subparser optional params
+    #: Optional argument name for use with the :data:`CONVERT` subparser,
+    #: indicating to suppress console output
+    QUIET = "quiet"
+
+    #: Optional argument name for use with the :data:`CONVERT` subparser,
+    #: indicating to expand URI and display name
+    #: abbreviations in the generated output file
+    EXPAND = "expand"
+
+    #: Optional argument name for use with the :data:`CONVERT` subparser,
+    #: indicating to contract URIs and display names
+    #: to abbreviated forms in the generated output file
+    CONTRACT = "contract"
+
+    #: Optional argument name for use with the :data:`CONVERT` subparser,
+    #: indicating to overwrite any existing output
+    #: file without prompting
+    OVERWRITE = "overwrite"
+
+    # ### Suggest subparser params
+    #: Positional argument name for use with the :data:`SUGGEST` subparser,
+    #: holding the search term for |fuzzywuzzy|_ text matching
+    SEARCH = "search"
+
+    #: Optional argument name for use with the :data:`SUGGEST` subparser,
+    #: taking the minimum desired |fuzzywuzzy|_ match quality
+    #: as one required argument
+    THRESH = "thresh"
+
+    #: Optional argument name for use with the :data:`SUGGEST` subparser,
+    #: indicating to print the location index of each returned object
+    #: within :data:`INFILE` along with the object domain/role/name
+    #: (may be specified with :data:`SCORE`)
+    INDEX = "index"
+
+    #: Optional argument name for use with the :data:`SUGGEST` subparser,
+    #: indicating to print the |fuzzywuzzy|_ score of each returned object
+    #: within :data:`INFILE` along with the object domain/role/name
+    #: (may be specified with :data:`INDEX`)
+    SCORE = "score"
+
+    #: Optional argument name for use with the :data:`SUGGEST` subparser,
+    #: indicating to print all returned objects, regardless of the
+    #: number returned, without asking for confirmation
+    ALL = "all"
+
+    # ### Helper strings
+    #: Help text for the :data:`CONVERT` subparser
+    HELP_CO_PARSER = (
+        "Convert intersphinx inventory to zlib-compressed, plaintext, or JSON formats."
+    )
+
+    #: Help text for the :data:`SUGGEST` subparser
+    HELP_SU_PARSER = "Fuzzy-search intersphinx inventory for desired object(s)."
+
+    #: Help text for default extensions for the various conversion types
+    HELP_CONV_EXTS = "'.inv/.txt/.json'"
+
+    # ### Defaults for an unspecified OUTFILE
+    #: Default base name for an unspecified :data:`OUTFILE`
+    DEF_BASENAME = "objects"
+
+    #: Default extensions for an unspecified :data:`OUTFILE`
+    DEF_OUT_EXT = {ZLIB: ".inv", PLAIN: ".txt", JSON: ".json"}
+
+    # ### Useful constants
+    #: Number of returned objects from a :data:`SUGGEST` subparser invocation
+    #: above which user will be prompted for confirmation to print the results
+    #: (unless :data:`ALL` is specified)
+    SUGGEST_CONFIRM_LENGTH = 30
+
+    #: Default match threshold for :option:`sphobjinv suggest --thresh`
+    DEF_THRESH = 75
+
+    #: Dict key for URL at which an inventory was actually found
+    FOUND_URL = "found_url"
+
+
+def getparser():
+    """Generate argument parser.
+
+    Returns
+    -------
+    prs
+
+        :class:`~argparse.ArgumentParser` -- Parser for commandline usage
+        of ``sphobjinv``
+
+    """
+    prs = ap.ArgumentParser(
+        description="Format conversion for "
+        "and introspection of "
+        "intersphinx "
+        "'objects.inv' files."
+    )
+    prs.add_argument(
+        "-" + PrsConst.VERSION[0],
+        "--" + PrsConst.VERSION,
+        help="Print package version & other info",
+        action="store_true",
+    )
+
+    sprs = prs.add_subparsers(
+        title="Subcommands",
+        dest=PrsConst.SUBPARSER_NAME,
+        metavar=f"{{{PrsConst.CONVERT},{PrsConst.SUGGEST}}}",
+        help="Execution mode. Type "
+        "'sphobjinv [mode] -h' "
+        "for more information "
+        "on available options. "
+        "Mode names can be abbreviated "
+        "to their first two letters.",
+    )
+
+    # Enforce subparser as optional. No effect for 3.4 to 3.7;
+    # briefly required a/o 3.7.0b4 due to change in default behavior, per:
+    # https://bugs.python.org/issue33109. 3.6 behavior restored for
+    # 3.7 release.
+    sprs.required = False
+
+    spr_convert = sprs.add_parser(
+        PrsConst.CONVERT,
+        aliases=[PrsConst.CONVERT[:2]],
+        help=PrsConst.HELP_CO_PARSER,
+        description=PrsConst.HELP_CO_PARSER,
+    )
+    spr_suggest = sprs.add_parser(
+        PrsConst.SUGGEST,
+        aliases=[PrsConst.SUGGEST[:2]],
+        help=PrsConst.HELP_SU_PARSER,
+        description=PrsConst.HELP_SU_PARSER,
+    )
+
+    # ### Args for conversion subparser
+    spr_convert.add_argument(
+        PrsConst.MODE,
+        help="Conversion output format",
+        choices=(PrsConst.ZLIB, PrsConst.PLAIN, PrsConst.JSON),
+    )
+
+    spr_convert.add_argument(
+        PrsConst.INFILE,
+        help=(
+            "Path to file to be converted. Passing '-' indicates to read from stdin "
+            "(plaintext/JSON only)."
+        ),
+    )
+
+    spr_convert.add_argument(
+        PrsConst.OUTFILE,
+        help=(
+            "Path to desired output file. "
+            "Defaults to same directory and main "
+            "file name as input file but with extension "
+            + PrsConst.HELP_CONV_EXTS
+            + ", as appropriate for the output format. "
+            "A path to a directory is accepted here, "
+            "in which case the default output file name will be used. "
+            "Passing '-' indicates to write to stdout. If "
+            + PrsConst.INFILE
+            + " is passed as '-', "
+            + PrsConst.OUTFILE
+            + " can be omitted and both stdin and stdout will be used."
+        ),
+        nargs="?",
+        default=None,
+    )
+
+    # Mutually exclusive group for --expand/--contract
+    gp_expcont = spr_convert.add_argument_group(title="URI/display name conversions")
+    meg_expcont = gp_expcont.add_mutually_exclusive_group()
+    meg_expcont.add_argument(
+        "-" + PrsConst.EXPAND[0],
+        "--" + PrsConst.EXPAND,
+        help="Expand all URI and display name abbreviations",
+        action="store_true",
+    )
+
+    meg_expcont.add_argument(
+        "-" + PrsConst.CONTRACT[0],
+        "--" + PrsConst.CONTRACT,
+        help="Contract all URI and display name abbreviations",
+        action="store_true",
+    )
+
+    # Clobber argument
+    spr_convert.add_argument(
+        "-" + PrsConst.OVERWRITE[0],
+        "--" + PrsConst.OVERWRITE,
+        help="Overwrite output files without prompting",
+        action="store_true",
+    )
+
+    # stdout suppressor option (e.g., for scripting)
+    spr_convert.add_argument(
+        "-" + PrsConst.QUIET[0],
+        "--" + PrsConst.QUIET,
+        help="Suppress printing of status messages and "
+        "overwrite output files without prompting",
+        action="store_true",
+    )
+
+    # Flag to treat infile as a URL
+    spr_convert.add_argument(
+        "-" + PrsConst.URL[0],
+        "--" + PrsConst.URL,
+        help=(
+            "Treat 'infile' as a URL for download. "
+            f"Cannot be used with --{PrsConst.URL}."
+        ),
+        action="store_true",
+    )
+
+    # ### Args for suggest subparser
+    spr_suggest.add_argument(
+        PrsConst.INFILE,
+        help=(
+            "Path to inventory file to be searched. "
+            "Passing '-' indicates to read from stdin (plaintext/JSON only)."
+        ),
+    )
+    spr_suggest.add_argument(PrsConst.SEARCH, help="Search term for object suggestions")
+    spr_suggest.add_argument(
+        "-" + PrsConst.ALL[0],
+        "--" + PrsConst.ALL,
+        help="Display all results "
+        "regardless of the number returned "
+        "without prompting for confirmation.",
+        action="store_true",
+    )
+    spr_suggest.add_argument(
+        "-" + PrsConst.INDEX[0],
+        "--" + PrsConst.INDEX,
+        help="Include Inventory.objects list indices with the search results",
+        action="store_true",
+    )
+    spr_suggest.add_argument(
+        "-" + PrsConst.SCORE[0],
+        "--" + PrsConst.SCORE,
+        help="Include fuzzywuzzy scores with the search results",
+        action="store_true",
+    )
+    spr_suggest.add_argument(
+        "-" + PrsConst.THRESH[0],
+        "--" + PrsConst.THRESH,
+        help="Match quality threshold, integer 0-100, "
+        "default 75. Default is suitable when "
+        "'search' is exactly a known object name. "
+        "A value of 30-50 gives better results "
+        "for approximate matches.",
+        default=PrsConst.DEF_THRESH,
+        type=int,
+        choices=range(101),
+        metavar="{0-100}",
+    )
+    spr_suggest.add_argument(
+        "-" + PrsConst.URL[0],
+        "--" + PrsConst.URL,
+        help=(
+            "Treat 'infile' as a URL for download. "
+            f"Cannot be used with --{PrsConst.URL}."
+        ),
+        action="store_true",
+    )
+
+    return prs
diff --git a/src/sphobjinv/cli/paths.py b/src/sphobjinv/cli/paths.py
new file mode 100644
index 00000000..4846887c
--- /dev/null
+++ b/src/sphobjinv/cli/paths.py
@@ -0,0 +1,133 @@
+r"""``sphobjinv`` *CLI path resolution module*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    19 Nov 2020
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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 os
+
+from sphobjinv.cli.parser import PrsConst
+
+
+def resolve_inpath(in_path):
+    """Resolve the input file, handling invalid values.
+
+    Currently, only checks for existence and not-directory.
+
+    Parameters
+    ----------
+    in_path
+
+        |str| -- Path to desired input file
+
+    Returns
+    -------
+    abs_path
+
+        |str| -- Absolute path to indicated file
+
+    Raises
+    ------
+    :exc:`FileNotFoundError`
+
+        If a file is not found at the given path
+
+    """
+    # Path MUST be to a file, that exists
+    if not os.path.isfile(in_path):
+        raise FileNotFoundError("Indicated path is not a valid file")
+
+    # Return the path as absolute
+    return os.path.abspath(in_path)
+
+
+def resolve_outpath(out_path, in_path, params):
+    r"""Resolve the output location, handling mode-specific defaults.
+
+    If the output path or basename are not specified, they are
+    taken as the same as the input file. If the extension is
+    unspecified, it is taken as the appropriate mode-specific value
+    from |cli:DEF_OUT_EXT|.
+
+    If |cli:URL| is passed, the input directory
+    is taken to be :func:`os.getcwd` and the input basename
+    is taken as |cli:DEF_BASENAME|.
+
+    Parameters
+    ----------
+    out_path
+
+        |str| or |None| -- Output location provided by the user,
+        or |None| if omitted
+
+    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
+
+    Returns
+    -------
+    out_path
+
+        |str| -- Absolute path to the target output file
+
+    """
+    mode = params[PrsConst.MODE]
+
+    if params[PrsConst.URL] or in_path is None:
+        in_fld = os.getcwd()
+        in_fname = PrsConst.DEF_BASENAME
+    else:
+        in_fld, in_fname = os.path.split(in_path)
+
+    if out_path:
+        # Must check if the path entered is a folder
+        if os.path.isdir(out_path):
+            # Set just the folder and leave the name blank
+            out_fld = out_path
+            out_fname = None
+        else:
+            # Split appropriately
+            out_fld, out_fname = os.path.split(out_path)
+
+        # Output to same folder if unspecified
+        if not out_fld:
+            out_fld = in_fld
+
+        # Use same base filename if not specified
+        if not out_fname:
+            out_fname = os.path.splitext(in_fname)[0] + PrsConst.DEF_OUT_EXT[mode]
+
+        # Composite the full output path
+        out_path = os.path.join(out_fld, out_fname)
+    else:
+        # No output location specified; use defaults
+        out_fname = os.path.splitext(in_fname)[0] + PrsConst.DEF_OUT_EXT[mode]
+        out_path = os.path.join(in_fld, out_fname)
+
+    return out_path
diff --git a/src/sphobjinv/cli/ui.py b/src/sphobjinv/cli/ui.py
new file mode 100644
index 00000000..fee27b8b
--- /dev/null
+++ b/src/sphobjinv/cli/ui.py
@@ -0,0 +1,107 @@
+r"""``sphobjinv`` *CLI UI functions*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    19 Nov 2020
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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
+
+from sphobjinv.cli.parser import PrsConst
+
+
+def log_print(thing, params):
+    """Print `thing` to stderr if not in quiet mode.
+
+    Quiet mode is indicated by the value at the |cli:QUIET| key
+    within `params`.
+
+    Quiet mode is not implemented for the ":doc:`suggest </cli/suggest>`"
+    CLI mode.
+
+    Parameters
+    ----------
+    thing
+
+        *any* -- Object to be printed
+
+    params
+
+        |dict| -- Parameters/values mapping from the active subparser
+
+    """
+    if not params[PrsConst.SUBPARSER_NAME][:2] == "co" or not params[PrsConst.QUIET]:
+        print(thing, file=sys.stderr)
+
+
+def err_format(exc):
+    r"""Pretty-format an exception.
+
+    Parameters
+    ----------
+    exc
+
+        :class:`Exception` -- Exception instance to pretty-format
+
+    Returns
+    -------
+    pretty_exc
+
+        |str| -- Exception type and message formatted as
+        |cour|\ '{type}: {message}'\ |/cour|
+
+    """
+    return f"{type(exc).__name__}: {str(exc)}"
+
+
+def yesno_prompt(prompt):
+    r"""Query user at `stdin` for yes/no confirmation.
+
+    Uses :func:`input`, so will hang if used programmatically
+    unless `stdin` is suitably mocked.
+
+    The value returned from :func:`input` must satisfy either
+    |cour|\ resp.lower() == 'n'\ |/cour| or
+    |cour|\ resp.lower() == 'y'\ |/cour|,
+    or else the query will be repeated *ad infinitum*.
+    This function does **NOT** augment `prompt`
+    to indicate the constraints on the accepted values.
+
+    Parameters
+    ----------
+    prompt
+
+        |str| -- Prompt to display to user that
+        requests a 'Y' or 'N' response
+
+    Returns
+    -------
+    resp
+
+        |str| -- User response
+
+    """
+    resp = ""
+    while not (resp.lower() == "n" or resp.lower() == "y"):
+        resp = input(prompt)
+    return resp
diff --git a/src/sphobjinv/cli/write.py b/src/sphobjinv/cli/write.py
new file mode 100644
index 00000000..ccc3773d
--- /dev/null
+++ b/src/sphobjinv/cli/write.py
@@ -0,0 +1,279 @@
+r"""Module for ``sphobjinv`` *CLI |Inventory| writing*.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    19 Nov 2020
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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 json
+import os
+import sys
+
+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.fileops import writebytes, writejson
+from sphobjinv.zlib import compress
+
+
+def write_plaintext(inv, path, *, expand=False, contract=False):
+    """Write an |Inventory| to plaintext.
+
+    Newlines are inserted in an OS-aware manner,
+    based on the value of :data:`os.linesep`.
+
+    Calling with both `expand` and `contract` as |True| is invalid.
+
+    Parameters
+    ----------
+    inv
+
+        |Inventory| -- Objects inventory to be written as plaintext
+
+    path
+
+        |str| -- Path to output file
+
+    expand
+
+        |bool| *(optional)* -- Generate output with any
+        :data:`~sphobjinv.data.SuperDataObj.uri` or
+        :data:`~sphobjinv.data.SuperDataObj.dispname`
+        abbreviations expanded
+
+    contract
+
+        |bool| *(optional)* -- Generate output with abbreviated
+        :data:`~sphobjinv.data.SuperDataObj.uri` and
+        :data:`~sphobjinv.data.SuperDataObj.dispname` values
+
+    Raises
+    ------
+    ValueError
+
+        If both `expand` and `contract` are |True|
+
+    """
+    b_str = inv.data_file(expand=expand, contract=contract)
+    writebytes(path, b_str.replace(b"\n", os.linesep.encode("utf-8")))
+
+
+def write_zlib(inv, path, *, expand=False, contract=False):
+    """Write an |Inventory| to zlib-compressed format.
+
+       Calling with both `expand` and `contract` as |True| is invalid.
+
+    Parameters
+    ----------
+    inv
+
+        |Inventory| -- Objects inventory to be written zlib-compressed
+
+    path
+
+        |str| -- Path to output file
+
+    expand
+
+        |bool| *(optional)* -- Generate output with any
+        :data:`~sphobjinv.data.SuperDataObj.uri` or
+        :data:`~sphobjinv.data.SuperDataObj.dispname`
+        abbreviations expanded
+
+    contract
+
+        |bool| *(optional)* -- Generate output with abbreviated
+        :data:`~sphobjinv.data.SuperDataObj.uri` and
+        :data:`~sphobjinv.data.SuperDataObj.dispname` values
+
+    Raises
+    ------
+    ValueError
+
+        If both `expand` and `contract` are |True|
+
+    """
+    b_str = inv.data_file(expand=expand, contract=contract)
+    bz_str = compress(b_str)
+    writebytes(path, bz_str)
+
+
+def write_json(inv, path, params):
+    """Write an |Inventory| to JSON.
+
+    Writes output via
+    :func:`fileops.writejson() <sphobjinv.fileops.writejson>`.
+
+    Calling with both `expand` and `contract` as |True| is invalid.
+
+    Parameters
+    ----------
+    inv
+
+        |Inventory| -- Objects inventory to be written zlib-compressed
+
+    path
+
+        |str| -- Path to output file
+
+    params
+
+        dict -- `argparse` parameters
+
+    Raises
+    ------
+    ValueError
+
+        If both `params["expand"]` and `params["contract"]` are |True|
+
+    """
+    json_dict = inv.json_dict(
+        expand=params[PrsConst.EXPAND], contract=params[PrsConst.CONTRACT]
+    )
+
+    if params.get(PrsConst.FOUND_URL, False):
+        json_dict.update({"metadata": {PrsConst.URL: params[PrsConst.FOUND_URL]}})
+
+    writejson(path, json_dict)
+
+
+def write_stdout(inv, params):
+    r"""Write the inventory contents to stdout.
+
+    Parameters
+    ----------
+    inv
+
+        |Inventory| -- Objects inventory to be written to stdout
+
+    params
+
+        dict -- `argparse` parameters
+
+    Raises
+    ------
+    ValueError
+
+        If both `params["expand"]` and `params["contract"]` are |True|
+
+    """
+    if params[PrsConst.MODE] == PrsConst.PLAIN:
+        print(
+            inv.data_file(
+                expand=params[PrsConst.EXPAND], contract=params[PrsConst.CONTRACT]
+            ).decode()
+        )
+    elif params[PrsConst.MODE] == PrsConst.JSON:
+        json_dict = inv.json_dict(
+            expand=params[PrsConst.EXPAND], contract=params[PrsConst.CONTRACT]
+        )
+
+        if params.get(PrsConst.FOUND_URL, False):
+            json_dict.update({"metadata": {PrsConst.URL: params[PrsConst.FOUND_URL]}})
+
+        print(json.dumps(json_dict))
+    else:
+        log_print("Error: Only plaintext and JSON can be emitted to stdout.", params)
+        sys.exit(1)
+
+
+def write_file(inv, in_path, params):
+    r"""Write the inventory contents to a file on disk.
+
+    Parameters
+    ----------
+    inv
+
+        |Inventory| -- Objects inventory to be written to stdout
+
+    in_path
+
+        |str| -- For a local input file, its absolute path.
+        For a URL, the (possibly truncated) URL text.
+
+    params
+
+        dict -- `argparse` parameters
+
+    Raises
+    ------
+    ValueError
+
+        If both `params["expand"]` and `params["contract"]` are |True|
+
+    """
+    mode = params[PrsConst.MODE]
+
+    # Work up the output location
+    try:
+        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)
+        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)
+            sys.exit(0)
+        # This could be written w/o nesting via elif, but would be harder to read.
+        else:
+            if not params[PrsConst.QUIET]:
+                # 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)
+                    sys.exit(0)
+
+    # Write the output file
+    try:
+        if mode == PrsConst.ZLIB:
+            write_zlib(
+                inv,
+                out_path,
+                expand=params[PrsConst.EXPAND],
+                contract=params[PrsConst.CONTRACT],
+            )
+        if mode == PrsConst.PLAIN:
+            write_plaintext(
+                inv,
+                out_path,
+                expand=params[PrsConst.EXPAND],
+                contract=params[PrsConst.CONTRACT],
+            )
+        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)
+        sys.exit(1)
+
+    # Report success, if not QUIET
+    log_print(
+        "Conversion completed.\n"
+        f"'{in_path if in_path else 'stdin'}' converted to '{out_path}' ({mode}).",
+        params,
+    )
diff --git a/src/sphobjinv/cmdline.py b/src/sphobjinv/cmdline.py
deleted file mode 100644
index 743d1aa3..00000000
--- a/src/sphobjinv/cmdline.py
+++ /dev/null
@@ -1,1026 +0,0 @@
-r"""*CLI module for* ``sphobjinv``.
-
-``sphobjinv`` is a toolkit for manipulation and inspection of
-Sphinx |objects.inv| files.
-
-.. note::
-
-    This module is NOT part of the public API for ``sphobjinv``.
-    Its entire contents should be considered implementation detail.
-
-**Author**
-    Brian Skinn (bskinn@alum.mit.edu)
-
-**File Created**
-    17 May 2016
-
-**Copyright**
-    \(c) Brian Skinn 2016-2020
-
-**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 argparse as ap
-import os
-import sys
-from json.decoder import JSONDecodeError
-
-from sphobjinv import __version__
-from sphobjinv.fileops import readjson, writebytes, writejson
-from sphobjinv.inventory import Inventory as Inv
-from sphobjinv.zlib import compress
-
-
-# ### Version arg and helpers
-#: Optional argument name for use with the base
-#: argument parser, to show version &c. info, and exit
-VERSION = "version"
-
-#: Version &c. output blurb
-VER_TXT = (
-    "\nsphobjinv v{0}\n\n".format(__version__) + "Copyright (c) Brian Skinn 2016-2020\n"
-    "License: The MIT License\n\n"
-    "Bug reports & feature requests:"
-    " https://github.com/bskinn/sphobjinv\n"
-    "Documentation:"
-    " http://sphobjinv.readthedocs.io\n"
-)
-
-# ### Subparser selectors and argparse param for storing subparser name
-#: Subparser name for inventory file conversions; stored in
-#: :data:`SUBPARSER_NAME` when selected
-CONVERT = "convert"
-
-#: Subparser name for inventory object suggestions; stored in
-#: :data:`SUBPARSER_NAME` when selected
-SUGGEST = "suggest"
-
-#: Param for storing subparser name
-#: (:data:`CONVERT` or :data:`SUGGEST`)
-SUBPARSER_NAME = "sprs_name"
-
-# ### Common URL argument for both subparsers
-#: Optional argument name for use with both :data:`CONVERT` and
-#: :data:`SUGGEST` subparsers, indicating that
-#: :data:`INFILE` is to be treated as a URL
-#: rather than a local file path
-URL = "url"
-
-# ### Conversion subparser: 'mode' param and choices
-#: Positional argument name for use with :data:`CONVERT` subparser,
-#: indicating output file format
-#: (:data:`ZLIB`, :data:`PLAIN` or :data:`JSON`)
-MODE = "mode"
-
-#: Argument value for :data:`CONVERT` :data:`MODE`,
-#: to output a :mod:`zlib`-compressed inventory
-ZLIB = "zlib"
-
-#: Argument value for :data:`CONVERT` :data:`MODE`,
-#: to output a plaintext inventory
-PLAIN = "plain"
-
-#: Argument value for :data:`CONVERT` :data:`MODE`,
-#: to output an inventory as JSON
-JSON = "json"
-
-# ### Source/destination params
-#: Required positional argument name for use with both :data:`CONVERT` and
-#: :data:`SUGGEST` subparsers, holding the path
-#: (or URL, if :data:`URL` is specified)
-#: to the input file
-INFILE = "infile"
-
-#: Optional positional argument name
-#: for use with the :data:`CONVERT` subparser,
-#: holding the path to the output file
-#: (:data:`DEF_BASENAME` and the appropriate item from :data:`DEF_OUT_EXT`
-#: are used if this argument is not provided)
-OUTFILE = "outfile"
-
-# ### Convert subparser optional params
-#: Optional argument name for use with the :data:`CONVERT` subparser,
-#: indicating to suppress console output
-QUIET = "quiet"
-
-#: Optional argument name for use with the :data:`CONVERT` subparser,
-#: indicating to expand URI and display name
-#: abbreviations in the generated output file
-EXPAND = "expand"
-
-#: Optional argument name for use with the :data:`CONVERT` subparser,
-#: indicating to contract URIs and display names
-#: to abbreviated forms in the generated output file
-CONTRACT = "contract"
-
-#: Optional argument name for use with the :data:`CONVERT` subparser,
-#: indicating to overwrite any existing output
-#: file without prompting
-OVERWRITE = "overwrite"
-
-# ### Suggest subparser params
-#: Positional argument name for use with the :data:`SUGGEST` subparser,
-#: holding the search term for |fuzzywuzzy|_ text matching
-SEARCH = "search"
-
-#: Optional argument name for use with the :data:`SUGGEST` subparser,
-#: taking the minimum desired |fuzzywuzzy|_ match quality
-#: as one required argument
-THRESH = "thresh"
-
-#: Optional argument name for use with the :data:`SUGGEST` subparser,
-#: indicating to print the location index of each returned object
-#: within :data:`INFILE` along with the object domain/role/name
-#: (may be specified with :data:`SCORE`)
-INDEX = "index"
-
-#: Optional argument name for use with the :data:`SUGGEST` subparser,
-#: indicating to print the |fuzzywuzzy|_ score of each returned object
-#: within :data:`INFILE` along with the object domain/role/name
-#: (may be specified with :data:`INDEX`)
-SCORE = "score"
-
-#: Optional argument name for use with the :data:`SUGGEST` subparser,
-#: indicating to print all returned objects, regardless of the
-#: number returned, without asking for confirmation
-ALL = "all"
-
-# ### Helper strings
-#: Help text for the :data:`CONVERT` subparser
-HELP_CO_PARSER = (
-    "Convert intersphinx inventory to zlib-compressed, " "plaintext, or JSON formats."
-)
-
-#: Help text for the :data:`SUGGEST` subparser
-HELP_SU_PARSER = "Fuzzy-search intersphinx inventory " "for desired object(s)."
-
-#: Help text for default extensions for the various conversion types
-HELP_CONV_EXTS = "'.inv/.txt/.json'"
-
-# ### Defaults for an unspecified OUTFILE
-#: Default base name for an unspecified :data:`OUTFILE`
-DEF_BASENAME = "objects"
-
-#: Default extensions for an unspecified :data:`OUTFILE`
-DEF_OUT_EXT = {ZLIB: ".inv", PLAIN: ".txt", JSON: ".json"}
-
-# ### Useful constants
-#: Number of returned objects from a :data:`SUGGEST` subparser invocation
-#: above which user will be prompted for confirmation to print the results
-#: (unless :data:`ALL` is specified)
-SUGGEST_CONFIRM_LENGTH = 30
-
-#: Default match threshold for :option:`sphobjinv suggest --thresh`
-DEF_THRESH = 75
-
-
-def selective_print(thing, params):
-    """Print `thing` if not in quiet mode.
-
-    Quiet mode is indicated by the value at the :data:`QUIET` key
-    within `params`.
-
-    Quiet mode is not implemented for the ":doc:`suggest </cli/suggest>`"
-    CLI mode.
-
-    Parameters
-    ----------
-    thing
-
-        *any* -- Object to be printed
-
-    params
-
-        |dict| -- Parameters/values mapping from the active subparser
-
-    """
-    if not params[SUBPARSER_NAME][:2] == "co" or not params[QUIET]:
-        print(thing)
-
-
-def err_format(exc):
-    r"""Pretty-format an exception.
-
-    Parameters
-    ----------
-    exc
-
-        :class:`Exception` -- Exception instance to pretty-format
-
-    Returns
-    -------
-    pretty_exc
-
-        |str| -- Exception type and message formatted as
-        |cour|\ '{type}: {message}'\ |/cour|
-
-    """
-    return "{0}: {1}".format(type(exc).__name__, str(exc))
-
-
-def yesno_prompt(prompt):
-    r"""Query user at `stdin` for yes/no confirmation.
-
-    Uses :func:`input`, so will hang if used programmatically
-    unless `stdin` is suitably mocked.
-
-    The value returned from :func:`input` must satisfy either
-    |cour|\ resp.lower() == 'n'\ |/cour| or
-    |cour|\ resp.lower() == 'y'\ |/cour|,
-    or else the query will be repeated *ad infinitum*.
-    This function does **NOT** augment `prompt`
-    to indicate the constraints on the accepted values.
-
-    Parameters
-    ----------
-    prompt
-
-        |str| -- Prompt to display to user that
-        requests a 'Y' or 'N' response
-
-    Returns
-    -------
-    resp
-
-        |str| -- User response
-
-    """
-    resp = ""
-    while not (resp.lower() == "n" or resp.lower() == "y"):
-        resp = input(prompt)  # noqa: S322
-    return resp
-
-
-def getparser():
-    """Generate argument parser.
-
-    Returns
-    -------
-    prs
-
-        :class:`~argparse.ArgumentParser` -- Parser for commandline usage
-        of ``sphobjinv``
-
-    """
-    prs = ap.ArgumentParser(
-        description="Format conversion for "
-        "and introspection of "
-        "intersphinx "
-        "'objects.inv' files."
-    )
-    prs.add_argument(
-        "-" + VERSION[0],
-        "--" + VERSION,
-        help="Print package version & other info",
-        action="store_true",
-    )
-
-    sprs = prs.add_subparsers(
-        title="Subcommands",
-        dest=SUBPARSER_NAME,
-        metavar="{{{0},{1}}}".format(CONVERT, SUGGEST),
-        help="Execution mode. Type "
-        "'sphobjinv [mode] -h' "
-        "for more information "
-        "on available options. "
-        "Mode names can be abbreviated "
-        "to their first two letters.",
-    )
-
-    # Enforce subparser as optional. No effect for 3.4 to 3.7;
-    # briefly required a/o 3.7.0b4 due to change in default behavior, per:
-    # https://bugs.python.org/issue33109. 3.6 behavior restored for
-    # 3.7 release.
-    sprs.required = False
-
-    spr_convert = sprs.add_parser(
-        CONVERT, aliases=[CONVERT[:2]], help=HELP_CO_PARSER, description=HELP_CO_PARSER
-    )
-    spr_suggest = sprs.add_parser(
-        SUGGEST, aliases=[SUGGEST[:2]], help=HELP_SU_PARSER, description=HELP_SU_PARSER
-    )
-
-    # ### Args for conversion subparser
-    spr_convert.add_argument(
-        MODE, help="Conversion output format", choices=(ZLIB, PLAIN, JSON)
-    )
-
-    spr_convert.add_argument(INFILE, help="Path to file to be converted")
-
-    spr_convert.add_argument(
-        OUTFILE,
-        help="Path to desired output file. "
-        "Defaults to same directory and main "
-        "file name as input file but with extension "
-        + HELP_CONV_EXTS
-        + ", as appropriate for the output format. "
-        "A bare path is accepted here, "
-        "using the default output file names.",
-        nargs="?",
-        default=None,
-    )
-
-    # Mutually exclusive group for --expand/--contract
-    gp_expcont = spr_convert.add_argument_group(title="URI/display name " "conversions")
-    meg_expcont = gp_expcont.add_mutually_exclusive_group()
-    meg_expcont.add_argument(
-        "-e",
-        "--" + EXPAND,
-        help="Expand all URI and display name " "abbreviations",
-        action="store_true",
-    )
-
-    meg_expcont.add_argument(
-        "-c",
-        "--" + CONTRACT,
-        help="Contract all URI and display name " "abbreviations",
-        action="store_true",
-    )
-
-    # Clobber argument
-    spr_convert.add_argument(
-        "-" + OVERWRITE[0],
-        "--" + OVERWRITE,
-        help="Overwrite output files without prompting",
-        action="store_true",
-    )
-
-    # stdout suppressor option (e.g., for scripting)
-    spr_convert.add_argument(
-        "-" + QUIET[0],
-        "--" + QUIET,
-        help="Suppress printing of status messages "
-        "and overwrite output files "
-        "without prompting",
-        action="store_true",
-    )
-
-    # Flag to treat infile as a URL
-    spr_convert.add_argument(
-        "-" + URL[0],
-        "--" + URL,
-        help="Treat 'infile' as a URL for download",
-        action="store_true",
-    )
-
-    # ### Args for suggest subparser
-    spr_suggest.add_argument(INFILE, help="Path to inventory file to be searched")
-    spr_suggest.add_argument(SEARCH, help="Search term for object suggestions")
-    spr_suggest.add_argument(
-        "-" + ALL[0],
-        "--" + ALL,
-        help="Display all results "
-        "regardless of the number returned "
-        "without prompting for confirmation.",
-        action="store_true",
-    )
-    spr_suggest.add_argument(
-        "-" + INDEX[0],
-        "--" + INDEX,
-        help="Include Inventory.objects list indices " "with the search results",
-        action="store_true",
-    )
-    spr_suggest.add_argument(
-        "-" + SCORE[0],
-        "--" + SCORE,
-        help="Include fuzzywuzzy scores " "with the search results",
-        action="store_true",
-    )
-    spr_suggest.add_argument(
-        "-" + THRESH[0],
-        "--" + THRESH,
-        help="Match quality threshold, integer 0-100, "
-        "default 75. Default is suitable when "
-        "'search' is exactly a known object name. "
-        "A value of 30-50 gives better results "
-        "for approximate matches.",
-        default=DEF_THRESH,
-        type=int,
-        choices=range(101),
-        metavar="{0-100}",
-    )
-    spr_suggest.add_argument(
-        "-" + URL[0],
-        "--" + URL,
-        help="Treat 'infile' as a URL for download",
-        action="store_true",
-    )
-
-    return prs
-
-
-def resolve_inpath(in_path):
-    """Resolve the input file, handling invalid values.
-
-    Currently, only checks for existence and not-directory.
-
-    Parameters
-    ----------
-    in_path
-
-        |str| -- Path to desired input file
-
-    Returns
-    -------
-    abs_path
-
-        |str| -- Absolute path to indicated file
-
-    Raises
-    ------
-    :exc:`FileNotFoundError`
-
-        If a file is not found at the given path
-
-    """
-    # Path MUST be to a file, that exists
-    if not os.path.isfile(in_path):
-        raise FileNotFoundError("Indicated path is not a valid file")
-
-    # Return the path as absolute
-    return os.path.abspath(in_path)
-
-
-def resolve_outpath(out_path, in_path, params):
-    r"""Resolve the output location, handling mode-specific defaults.
-
-    If the output path or basename are not specified, they are
-    taken as the same as the input file. If the extension is
-    unspecified, it is taken as the appropriate mode-specific value
-    from :data:`DEF_OUT_EXT`.
-
-    If :data:`URL` is passed, the input directory
-    is taken to be :func:`os.getcwd` and the input basename
-    is taken as :data:`DEF_BASENAME`.
-
-    Parameters
-    ----------
-    out_path
-
-        |str| or |None| -- Output location provided by the user,
-        or |None| if omitted
-
-    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
-
-    Returns
-    -------
-    out_path
-
-        |str| -- Absolute path to the target output file
-
-    """
-    mode = params[MODE]
-
-    if params[URL]:
-        in_fld = os.getcwd()
-        in_fname = DEF_BASENAME
-    else:
-        in_fld, in_fname = os.path.split(in_path)
-
-    if out_path:
-        # Must check if the path entered is a folder
-        if os.path.isdir(out_path):
-            # Set just the folder and leave the name blank
-            out_fld = out_path
-            out_fname = None
-        else:
-            # Split appropriately
-            out_fld, out_fname = os.path.split(out_path)
-
-        # Output to same folder if unspecified
-        if not out_fld:
-            out_fld = in_fld
-
-        # Use same base filename if not specified
-        if not out_fname:
-            out_fname = os.path.splitext(in_fname)[0] + DEF_OUT_EXT[mode]
-
-        # Composite the full output path
-        out_path = os.path.join(out_fld, out_fname)
-    else:
-        # No output location specified; use defaults
-        out_fname = os.path.splitext(in_fname)[0] + DEF_OUT_EXT[mode]
-        out_path = os.path.join(in_fld, out_fname)
-
-    return out_path
-
-
-def import_infile(in_path):
-    """Attempt import of indicated file.
-
-    Convenience function wrapping attempts to load an
-    |Inventory| from a local path.
-
-    Parameters
-    ----------
-    in_path
-
-        |str| -- Path to input file
-
-    Returns
-    -------
-    inv
-
-        |Inventory| or |None| -- If instantiation with the file at
-        `in_path` succeeds, the resulting |Inventory| instance;
-        otherwise, |None|
-
-    """
-    # Try general import, for zlib or plaintext files
-    try:
-        inv = Inv(in_path)
-    except AttributeError:
-        pass  # Punt to JSON attempt
-    else:
-        return inv
-
-    # Maybe it's JSON
-    try:
-        inv = Inv(readjson(in_path))
-    except JSONDecodeError:
-        return None
-    else:
-        return inv
-
-
-def write_plaintext(inv, path, *, expand=False, contract=False):
-    """Write an |Inventory| to plaintext.
-
-    Newlines are inserted in an OS-aware manner,
-    based on the value of :data:`os.linesep`.
-
-    Calling with both `expand` and `contract` as |True| is invalid.
-
-    Parameters
-    ----------
-    inv
-
-        |Inventory| -- Objects inventory to be written as plaintext
-
-    path
-
-        |str| -- Path to output file
-
-    expand
-
-        |bool| *(optional)* -- Generate output with any
-        :data:`~sphobjinv.data.SuperDataObj.uri` or
-        :data:`~sphobjinv.data.SuperDataObj.dispname`
-        abbreviations expanded
-
-    contract
-
-        |bool| *(optional)* -- Generate output with abbreviated
-        :data:`~sphobjinv.data.SuperDataObj.uri` and
-        :data:`~sphobjinv.data.SuperDataObj.dispname` values
-
-    Raises
-    ------
-    ValueError
-
-        If both `expand` and `contract` are |True|
-
-    """
-    b_str = inv.data_file(expand=expand, contract=contract)
-    writebytes(path, b_str.replace(b"\n", os.linesep.encode("utf-8")))
-
-
-def write_zlib(inv, path, *, expand=False, contract=False):
-    """Write an |Inventory| to zlib-compressed format.
-
-       Calling with both `expand` and `contract` as |True| is invalid.
-
-    Parameters
-    ----------
-    inv
-
-        |Inventory| -- Objects inventory to be written zlib-compressed
-
-    path
-
-        |str| -- Path to output file
-
-    expand
-
-        |bool| *(optional)* -- Generate output with any
-        :data:`~sphobjinv.data.SuperDataObj.uri` or
-        :data:`~sphobjinv.data.SuperDataObj.dispname`
-        abbreviations expanded
-
-    contract
-
-        |bool| *(optional)* -- Generate output with abbreviated
-        :data:`~sphobjinv.data.SuperDataObj.uri` and
-        :data:`~sphobjinv.data.SuperDataObj.dispname` values
-
-    Raises
-    ------
-    ValueError
-
-        If both `expand` and `contract` are |True|
-
-    """
-    b_str = inv.data_file(expand=expand, contract=contract)
-    bz_str = compress(b_str)
-    writebytes(path, bz_str)
-
-
-def write_json(inv, path, *, expand=False, contract=False):
-    """Write an |Inventory| to JSON.
-
-    Writes output via
-    :func:`fileops.writejson() <sphobjinv.fileops.writejson>`.
-
-    Calling with both `expand` and `contract` as |True| is invalid.
-
-    Parameters
-    ----------
-    inv
-
-        |Inventory| -- Objects inventory to be written zlib-compressed
-
-    path
-
-        |str| -- Path to output file
-
-    expand
-
-        |bool| *(optional)* -- Generate output with any
-        :data:`~sphobjinv.data.SuperDataObj.uri` or
-        :data:`~sphobjinv.data.SuperDataObj.dispname`
-        abbreviations expanded
-
-    contract
-
-        |bool| *(optional)* -- Generate output with abbreviated
-        :data:`~sphobjinv.data.SuperDataObj.uri` and
-        :data:`~sphobjinv.data.SuperDataObj.dispname` values
-
-    Raises
-    ------
-    ValueError
-
-        If both `expand` and `contract` are |True|
-
-    """
-    json_dict = inv.json_dict(expand=expand, contract=contract)
-    writejson(path, json_dict)
-
-
-def do_convert(inv, in_path, params):
-    r"""Carry out the conversion operation, including writing output.
-
-    If :data:`OVERWRITE` is passed and the output file
-    (the default location, or as passed to :data:`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 :data:`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 :data:`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
-
-    """
-    mode = params[MODE]
-
-    # Work up the output location
-    try:
-        out_path = resolve_outpath(params[OUTFILE], in_path, params)
-    except Exception as e:  # pragma: no cover
-        # This may not actually be reachable except in exceptional situations
-        selective_print("\nError while constructing output file path:", params)
-        selective_print(err_format(e), params)
-        sys.exit(1)
-
-    # If exists, confirm overwrite; clobber if QUIET
-    if os.path.isfile(out_path) and not params[QUIET] and not params[OVERWRITE]:
-        resp = yesno_prompt("File exists. Overwrite (Y/N)? ")
-        if resp.lower() == "n":
-            print("\nExiting...")
-            sys.exit(0)
-
-    # Write the output file
-    try:
-        if mode == ZLIB:
-            write_zlib(inv, out_path, expand=params[EXPAND], contract=params[CONTRACT])
-        if mode == PLAIN:
-            write_plaintext(
-                inv, out_path, expand=params[EXPAND], contract=params[CONTRACT]
-            )
-        if mode == JSON:
-            write_json(inv, out_path, expand=params[EXPAND], contract=params[CONTRACT])
-    except Exception as e:
-        selective_print("\nError during write of output file:", params)
-        selective_print(err_format(e), params)
-        sys.exit(1)
-
-    # Report success, if not QUIET
-    selective_print(
-        "Conversion completed.\n"
-        "'{0}' converted to '{1}' ({2}).".format(in_path, out_path, mode),
-        params,
-    )
-
-
-def do_suggest(inv, params):
-    r"""Perform the suggest call and output the results.
-
-    Results are printed one per line.
-
-    If neither :data:`INDEX` nor :data:`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
-    :data:`SUGGEST_CONFIRM_LENGTH`,
-    the user will be queried whether to display
-    all of the returned results
-    unless :data:`ALL` is specified.
-
-    No |cour|\ -\\-quiet\ |/cour| 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 :data:`MODE`.
-
-    params
-
-        |dict| -- Parameters/values mapping from the active subparser
-
-    """
-    with_index = params[INDEX]
-    with_score = params[SCORE]
-    results = inv.suggest(
-        params[SEARCH],
-        thresh=params[THRESH],
-        with_index=with_index,
-        with_score=with_score,
-    )
-
-    if len(results) == 0:
-        print("No results found.")
-        return
-
-    if len(results) > SUGGEST_CONFIRM_LENGTH and not params[ALL]:
-        resp = yesno_prompt("Display all {0} results ".format(len(results)) + "(Y/N)? ")
-        if resp.lower() == "n":
-            print("\nExiting...")
-            sys.exit(0)
-
-    # Field widths in output
-    score_width = 7
-    index_width = 7
-
-    if with_index or with_score:
-        rst_width = max(len(_[0]) for _ in results)
-    else:
-        rst_width = max(len(_) for _ in results)
-
-    rst_width += 2
-
-    if with_index:
-        if with_score:
-            fmt = "{{0: <{0}}}  {{1: ^{1}}}  {{2: ^{2}}}".format(
-                rst_width, score_width, index_width
-            )
-            print("")
-            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 = "{{0: <{0}}}  {{1: ^{1}}}".format(rst_width, index_width)
-            print("")
-            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 = "{{0: <{0}}}  {{1: ^{1}}}".format(rst_width, score_width)
-            print("")
-            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))
-
-
-def inv_local(params):
-    """Create |Inventory| from local source.
-
-    Uses :func:`resolve_inpath` to sanity-check and/or convert
-    :data:`INFILE`.
-
-    Calls :func:`sys.exit` internally in error-exit situations.
-
-    Parameters
-    ----------
-    params
-
-        |dict| -- Parameters/values mapping from the active subparser
-
-    Returns
-    -------
-    inv
-
-        |Inventory| -- Object representation of the inventory
-        at :data:`INFILE`
-
-    in_path
-
-        |str| -- Input file path as resolved/checked by
-        :func:`resolve_inpath`
-
-    """
-    # Resolve input file path
-    try:
-        in_path = resolve_inpath(params[INFILE])
-    except Exception as e:
-        selective_print("\nError while parsing input file path:", params)
-        selective_print(err_format(e), params)
-        sys.exit(1)
-
-    # Attempt import
-    inv = import_infile(in_path)
-    if inv is None:
-        selective_print("\nError: Unrecognized file format", params)
-        sys.exit(1)
-
-    return inv, in_path
-
-
-def inv_url(params):
-    """Create |Inventory| from file downloaded from URL.
-
-    Initially, treats :data:`INFILE` as a download URL to be passed to
-    the `url` initialization argument
-    of :class:`~sphobjinv.inventory.Inventory`.
-
-    If an inventory is not found at that exact URL, progressively
-    searches the directory tree of the URL for |objects.inv|.
-
-    Calls :func:`sys.exit` internally in error-exit situations.
-
-    Parameters
-    ----------
-    params
-
-        |dict| -- Parameters/values mapping from the active subparser
-
-    Returns
-    -------
-    inv
-
-        |Inventory| -- Object representation of the inventory
-        at :data:`INFILE`
-
-    ret_path
-
-        |str| -- URL from :data:`INFILE` used to construct `inv`.
-        If URL is longer than 45 characters, the central portion is elided.
-
-    """
-    from urllib.error import HTTPError, URLError
-
-    from sphobjinv.error import VersionError
-    from sphobjinv.fileops import urlwalk
-    from sphobjinv.inventory import Inventory
-
-    in_file = params[INFILE]
-
-    # Disallow --url mode on local files
-    if in_file.startswith("file:/"):
-        selective_print("\nError: URL mode on local file is invalid", params)
-        sys.exit(1)
-
-    # Need to initialize the inventory variable
-    inv = None
-
-    # Try URL as provided
-    try:
-        inv = Inventory(url=in_file)
-    except (HTTPError, ValueError, VersionError, URLError):
-        selective_print("No inventory at provided URL.", params)
-    else:
-        selective_print("Remote inventory found.", params)
-        url = in_file
-
-    # Keep searching if inv not found yet
-    if not inv:
-        for url in urlwalk(in_file):
-            selective_print('Attempting "{0}" ...'.format(url), params)
-            try:
-                inv = Inventory(url=url)
-            except (ValueError, HTTPError):
-                pass
-            else:
-                selective_print("Remote inventory found.", params)
-                break
-
-    # Cosmetic line break
-    selective_print(" ", params)
-
-    # Success or no?
-    if not inv:
-        selective_print("No inventory found!", params)
-        sys.exit(1)
-
-    if len(url) > 45:
-        ret_path = url[:20] + "[...]" + url[-20:]
-    else:  # pragma: no cover
-        ret_path = url
-
-    return inv, ret_path
-
-
-def main():
-    r"""Handle command line invocation.
-
-    Parses command line arguments,
-    handling the no-arguments and
-    :data:`VERSION` cases.
-
-    Creates the |Inventory| from the indicated source
-    and method.
-
-    Invokes :func:`do_convert` or :func:`do_suggest`
-    per the subparser name stored in :data:`SUBPARSER_NAME`.
-
-    """
-    # If no args passed, stick in '-h'
-    if len(sys.argv) == 1:
-        sys.argv.append("-h")
-
-    # Parse commandline arguments
-    prs = getparser()
-    ns, args_left = prs.parse_known_args()
-    params = vars(ns)
-
-    # Print version &c. and exit if indicated
-    if params[VERSION]:
-        print(VER_TXT)
-        sys.exit(0)
-
-    # Regardless of mode, insert extra blank line
-    # for cosmetics
-    selective_print(" ", params)
-
-    # Generate the input Inventory based on --url or not.
-    # These inventory-load functions should call
-    # sys.exit(n) internally in error-exit situations
-    if params[URL]:
-        inv, in_path = inv_url(params)
-    else:
-        inv, in_path = inv_local(params)
-
-    # Perform action based upon mode
-    if params[SUBPARSER_NAME][:2] == CONVERT[:2]:
-        do_convert(inv, in_path, params)
-    elif params[SUBPARSER_NAME][:2] == SUGGEST[:2]:
-        do_suggest(inv, params)
-
-    # Clean exit
-    sys.exit(0)
-
-
-if __name__ == "__main__":  # pragma: no cover
-    main()
diff --git a/src/sphobjinv/data.py b/src/sphobjinv/data.py
index 98ca0cf9..df6f7f0d 100644
--- a/src/sphobjinv/data.py
+++ b/src/sphobjinv/data.py
@@ -10,13 +10,13 @@
     7 Nov 2017
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
@@ -63,9 +63,9 @@ def _utf8_decode(b):
     Helper for type conversions among DataObjStr and DataObjBytes.
 
     """
-    if type(b) is bytes:
+    if isinstance(b, bytes):
         return b.decode(encoding="utf-8")
-    elif type(b) is str:
+    elif isinstance(b, str):
         return b
     else:
         raise TypeError("Argument must be 'bytes' or 'str'")
@@ -77,15 +77,15 @@ def _utf8_encode(s):
     Helper for type conversions among DataObjStr and DataObjBytes.
 
     """
-    if type(s) is str:
+    if isinstance(s, str):
         return s.encode(encoding="utf-8")
-    elif type(s) is bytes:
+    elif isinstance(s, bytes):
         return s
     else:
         raise TypeError("Argument must be 'bytes' or 'str'")
 
 
-class SuperDataObj(object, metaclass=ABCMeta):
+class SuperDataObj(metaclass=ABCMeta):
     """Abstract base superclass defining common methods &c. for data objects.
 
     Intended only to be subclassed
@@ -102,7 +102,7 @@ class SuperDataObj(object, metaclass=ABCMeta):
     #: Helper |str| for generating plaintext |objects.inv|
     #: data lines. The field names MUST match the |str| values
     #: of the :class:`~DataFields` members.
-    data_line_fmt = "{name} {domain}:{role} {priority} " "{uri} {dispname}"
+    data_line_fmt = "{name} {domain}:{role} {priority} {uri} {dispname}"
 
     #: |str.format| template for generating reST-like representations
     #: of object data for :data:`as_rst` (used with
@@ -119,25 +119,21 @@ def __str__(self):  # pragma: no cover
     @abstractmethod
     def name(self):
         r"""Object name, as recognized internally by Sphinx\ |dag|."""
-        pass
 
     @property
     @abstractmethod
     def domain(self):
         r"""Sphinx domain containing the object\ |dag|."""
-        pass
 
     @property
     @abstractmethod
     def role(self):
         r"""Sphinx role to be used when referencing the object\ |dag|."""
-        pass
 
     @property
     @abstractmethod
     def priority(self):
         r"""Object search priority\ |dag|."""
-        pass
 
     @property
     @abstractmethod
@@ -147,7 +143,6 @@ def uri(self):
         Possibly abbreviated; see :ref:`here <syntax_shorthand>`.
 
         """
-        pass
 
     @property
     @abstractmethod
@@ -157,7 +152,6 @@ def dispname(self):
         Possibly abbreviated; see :ref:`here <syntax_shorthand>`.
 
         """
-        pass
 
     @property
     @abstractmethod
@@ -168,7 +162,6 @@ def uri_abbrev(self):
         for :doc:`version 2 </syntax>` |objects.inv| files.
 
         """
-        pass
 
     @property
     @abstractmethod
@@ -179,28 +172,24 @@ def dispname_abbrev(self):
         for :doc:`version 2 </syntax>` |objects.inv| files.
 
         """
-        pass
 
     @property
     @abstractmethod
     def as_str(self, s):
         """:class:`DataObjStr` version of instance."""
-        pass
 
     @property
     @abstractmethod
     def as_bytes(self, s):
         """:class:`DataObjBytes` version of instance."""
-        pass
 
     @abstractmethod
     def _data_line_postprocess(self, s):
         """Post-process the data_line chars output."""
-        pass
 
     @property
     def uri_contracted(self):
-        """Object relative URI, contracted with `uri_abbrev`."""
+        """Object-relative URI, contracted with `uri_abbrev`."""
         if self.uri.endswith(self.name):
             return self.uri[: -len(self.name)] + self.uri_abbrev
         else:
@@ -208,7 +197,7 @@ def uri_contracted(self):
 
     @property
     def uri_expanded(self):
-        """Object relative URI, with `uri_abbrev` expanded."""
+        """Object-relative URI, with `uri_abbrev` expanded."""
         if self.uri.endswith(self.uri_abbrev):
             return self.uri[: -len(self.uri_abbrev)] + self.name
         else:
@@ -288,7 +277,7 @@ def json_dict(self, *, expand=False, contract=False):
 
         """
         if expand and contract:
-            raise ValueError("'expand' and 'contract' cannot " "both be true.")
+            raise ValueError("'expand' and 'contract' cannot both be true.")
 
         d = {a: getattr(self, a) for a in (e.value for e in DataFields)}
 
@@ -390,7 +379,37 @@ def evolve(self, **kwargs):
 
 @attr.s(slots=True)
 class DataObjStr(SuperDataObj):
-    """:class:`SuperDataObj` subclass generating |str| object data."""
+    """:class:`SuperDataObj` subclass generating |str| object data.
+
+    Two :class:`DataObjStr` instances will test equal if all of
+    :attr:`~sphobjinv.data.SuperDataObj.name`,
+    :attr:`~sphobjinv.data.SuperDataObj.domain`,
+    :attr:`~sphobjinv.data.SuperDataObj.role`,
+    :attr:`~sphobjinv.data.SuperDataObj.priority`,
+    :attr:`~sphobjinv.data.SuperDataObj.uri`,
+    and :attr:`~sphobjinv.data.SuperDataObj.dispname`
+    are equal between them.
+
+    .. doctest:: dataobjstr
+
+        >>> obj = soi.DataObjStr(
+        ...     name="foo",
+        ...     domain="py",
+        ...     role="method",
+        ...     priority="1",
+        ...     uri="$",
+        ...     dispname="-",
+        ... )
+        >>> obj == obj
+        True
+        >>> obj == obj.evolve(name="quux")
+        False
+
+    .. versionchanged:: 2.1
+        Previously, attempts to compare instances resulted in a
+        :exc:`RecursionError`.
+
+    """
 
     uri_abbrev = "$"
     dispname_abbrev = "-"
@@ -402,10 +421,11 @@ class DataObjStr(SuperDataObj):
     uri = attr.ib(converter=_utf8_decode)
     dispname = attr.ib(converter=_utf8_decode)
 
-    as_bytes = attr.ib(repr=False)
+    as_bytes = attr.ib(repr=False, eq=False)
 
     @as_bytes.default
     def _as_bytes_default(self):
+        """Create DataObjBytes equivalent."""
         return DataObjBytes(
             name=self.name,
             domain=self.domain,
@@ -416,10 +436,11 @@ def _as_bytes_default(self):
             as_str=self,
         )
 
-    as_str = attr.ib(repr=False)
+    as_str = attr.ib(repr=False, eq=False)
 
     @as_str.default
     def _as_str_default(self):
+        """Return this instance."""
         return self
 
     def _data_line_postprocess(self, s):
@@ -429,7 +450,37 @@ def _data_line_postprocess(self, s):
 
 @attr.s(slots=True)
 class DataObjBytes(SuperDataObj):
-    """:class:`SuperDataObj` subclass generating |bytes| object data."""
+    """:class:`SuperDataObj` subclass generating |bytes| object data.
+
+    Two :class:`DataObjBytes` instances will test equal if all of
+    :attr:`~sphobjinv.data.SuperDataObj.name`,
+    :attr:`~sphobjinv.data.SuperDataObj.domain`,
+    :attr:`~sphobjinv.data.SuperDataObj.role`,
+    :attr:`~sphobjinv.data.SuperDataObj.priority`,
+    :attr:`~sphobjinv.data.SuperDataObj.uri`,
+    and :attr:`~sphobjinv.data.SuperDataObj.dispname`
+    are equal between them.
+
+    .. doctest:: dataobjbytes
+
+        >>> obj = soi.DataObjBytes(
+        ...     name=b"foo",
+        ...     domain=b"py",
+        ...     role=b"method",
+        ...     priority=b"1",
+        ...     uri=b"$",
+        ...     dispname=b"-",
+        ... )
+        >>> obj == obj
+        True
+        >>> obj == obj.evolve(name=b"quux")
+        False
+
+    .. versionchanged:: 2.1
+        Previously, attempts to compare instances resulted in a
+        :exc:`RecursionError`.
+
+    """
 
     uri_abbrev = b"$"
     dispname_abbrev = b"-"
@@ -441,10 +492,11 @@ class DataObjBytes(SuperDataObj):
     uri = attr.ib(converter=_utf8_encode)
     dispname = attr.ib(converter=_utf8_encode)
 
-    as_str = attr.ib(repr=False)
+    as_str = attr.ib(repr=False, eq=False)
 
     @as_str.default
     def _as_str_default(self):
+        """Create DataObjStr equivalent."""
         return DataObjStr(
             name=self.name,
             domain=self.domain,
@@ -455,10 +507,11 @@ def _as_str_default(self):
             as_bytes=self,
         )
 
-    as_bytes = attr.ib(repr=False)
+    as_bytes = attr.ib(repr=False, eq=False)
 
     @as_bytes.default
     def _as_bytes_default(self):
+        """Return this instance."""
         return self
 
     def _data_line_postprocess(self, s):
diff --git a/src/sphobjinv/enum.py b/src/sphobjinv/enum.py
index 16ee9a73..f7e3da1b 100644
--- a/src/sphobjinv/enum.py
+++ b/src/sphobjinv/enum.py
@@ -10,13 +10,13 @@
     4 May 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
diff --git a/src/sphobjinv/error.py b/src/sphobjinv/error.py
index 257a607e..cf5f19ed 100644
--- a/src/sphobjinv/error.py
+++ b/src/sphobjinv/error.py
@@ -10,13 +10,13 @@
     5 Nov 2017
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
diff --git a/src/sphobjinv/fileops.py b/src/sphobjinv/fileops.py
index 086a361a..1dc241e1 100644
--- a/src/sphobjinv/fileops.py
+++ b/src/sphobjinv/fileops.py
@@ -10,13 +10,13 @@
     5 Nov 2017
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
@@ -25,15 +25,22 @@
 
 """
 
+import json
+from pathlib import Path
+
 
 def readbytes(path):
     """Read file contents and return as |bytes|.
 
+    .. versionchanged:: 2.1
+
+        `path` can now be |Path| or |str|. Previously, it had to be |str|.
+
     Parameters
     ----------
     path
 
-        |str| -- Path to file to be opened.
+        |str| or |Path| -- Path to file to be opened.
 
     Returns
     -------
@@ -42,8 +49,7 @@ def readbytes(path):
         |bytes| -- Contents of the indicated file.
 
     """
-    with open(path, "rb") as f:
-        return f.read()
+    return Path(path).read_bytes()
 
 
 def writebytes(path, contents):
@@ -51,19 +57,22 @@ def writebytes(path, contents):
 
     Any existing file at `path` will be overwritten.
 
+    .. versionchanged:: 2.1
+
+        `path` can now be |Path| or |str|. Previously, it had to be |str|.
+
     Parameters
     ----------
     path
 
-        |str| -- Path to file to be written.
+        |str| or |Path| -- Path to file to be written.
 
     contents
 
         |bytes| -- Content to be written to file.
 
     """
-    with open(path, "wb") as f:
-        f.write(contents)
+    Path(path).write_bytes(contents)
 
 
 def readjson(path):
@@ -71,11 +80,15 @@ def readjson(path):
 
     No data or schema validation is performed.
 
+    .. versionchanged:: 2.1
+
+        `path` can now be |Path| or |str|. Previously, it had to be |str|.
+
     Parameters
     ----------
     path
 
-        |str| -- Path to JSON file to be read.
+        |str| or |Path| -- Path to JSON file to be read.
 
     Returns
     -------
@@ -84,10 +97,7 @@ def readjson(path):
         |dict| -- Deserialized JSON.
 
     """
-    import json
-
-    with open(path, "r") as f:
-        return json.load(f)
+    return json.loads(Path(path).read_text())
 
 
 def writejson(path, d):
@@ -96,21 +106,22 @@ def writejson(path, d):
     No data or schema validation is performed.
     Any existing file at `path` will be overwritten.
 
+    .. versionchanged:: 2.1
+
+        `path` can now be |Path| or |str|. Previously, it had to be |str|.
+
     Parameters
     ----------
     path
 
-        |str| -- Path to output JSON file.
+        |str| or |Path| -- Path to output JSON file.
 
     d
 
         |dict| -- Data structure to serialize.
 
     """
-    import json
-
-    with open(path, "w") as f:
-        json.dump(d, f)
+    Path(path).write_text(json.dumps(d))
 
 
 def urlwalk(url):
diff --git a/src/sphobjinv/inventory.py b/src/sphobjinv/inventory.py
index f3dd6e3c..4492a96f 100644
--- a/src/sphobjinv/inventory.py
+++ b/src/sphobjinv/inventory.py
@@ -10,13 +10,13 @@
     7 Dec 2017
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
@@ -41,18 +41,12 @@
 from sphobjinv.fileops import readbytes
 from sphobjinv.re import pb_data, pb_project, pb_version
 from sphobjinv.schema import json_schema
+from sphobjinv.version import __version__ as soi_version
 from sphobjinv.zlib import decompress
 
 
-# Cope with 'cmp' argument deprecation in attrs 19.2
-try:
-    _attr_wrapper = attr.s(slots=True, eq=False)
-except TypeError:  # pragma: no cover
-    _attr_wrapper = attr.s(slots=True, cmp=False)
-
-
-@_attr_wrapper
-class Inventory(object):
+@attr.s(slots=True, eq=True, order=False)
+class Inventory:
     r"""Entire contents of an |objects.inv| inventory.
 
     All information is stored internally as |str|,
@@ -64,6 +58,29 @@ class Inventory(object):
 
     The `count_error` argument is only relevant to the `dict_json` source type.
 
+    Equality comparisons between |Inventory| instances
+    will return |True| if
+    :attr:`~sphobjinv.inventory.Inventory.project`,
+    :attr:`~sphobjinv.inventory.Inventory.version`, and
+    **all** contents of :attr:`~sphobjinv.inventory.Inventory.objects`
+    are identical, even if the instances were created from different
+    sources:
+
+    .. doctest:: inventory-equality
+
+        >>> inv1 = soi.Inventory(
+        ...     url="https://sphobjinv.readthedocs.io/en/latest/objects.inv"
+        ... )
+        >>> inv2 = soi.Inventory(inv1.data_file())
+        >>> inv1 is inv2
+        False
+        >>> inv1 == inv2
+        True
+
+    .. versionchanged:: 2.1
+        Previously, an |Inventory| instance would compare equal only
+        to itself.
+
     `source`
 
         The |Inventory| will attempt to parse the indicated
@@ -83,26 +100,34 @@ class Inventory(object):
 
     `plaintext`
 
-        Object is to be parsed as the |bytes|
+        Object is to be parsed as the UTF-8 |bytes|
         plaintext contents of an |objects.inv| inventory.
 
     `zlib`
 
-        Object is to be parsed as the |bytes|
+        Object is to be parsed as the UTF-8 |bytes|
         zlib-compressed contents of an
         |objects.inv| inventory.
 
     `fname_plain`
 
-        Object is the |str| path to a file containing
+        Object is the |str| or |Path| path to a file containing
         the plaintext contents of an |objects.inv| inventory.
 
+        .. versionchanged:: 2.1
+
+            Previously, this argument could only be a |str|.
+
     `fname_zlib`
 
-        Object is the |str| path to a file containing
+        Object is the |str| or |Path| path to a file containing
         the zlib-compressed contents of an
         |objects.inv| inventory.
 
+        .. versionchanged:: 2.1
+
+            Previously, this argument could only be a |str|.
+
     `dict_json`
 
         Object is a |dict| containing the contents
@@ -136,25 +161,25 @@ class Inventory(object):
 
     # General source for try-most-types import
     # Needs to be first so it absorbs a positional arg
-    _source = attr.ib(repr=False, default=None)
+    _source = attr.ib(repr=False, default=None, eq=False)
 
     # Stringlike types (both accept str & bytes)
-    _plaintext = attr.ib(repr=False, default=None)
-    _zlib = attr.ib(repr=False, default=None)
+    _plaintext = attr.ib(repr=False, default=None, eq=False)
+    _zlib = attr.ib(repr=False, default=None, eq=False)
 
-    # Filename types (must be str)
-    _fname_plain = attr.ib(repr=False, default=None)
-    _fname_zlib = attr.ib(repr=False, default=None)
+    # Filename types (must be str or Path)
+    _fname_plain = attr.ib(repr=False, default=None, eq=False)
+    _fname_zlib = attr.ib(repr=False, default=None, eq=False)
 
     # dict types
-    _dict_json = attr.ib(repr=False, default=None)
+    _dict_json = attr.ib(repr=False, default=None, eq=False)
 
     # URL for remote retrieval of objects.inv/.txt
-    _url = attr.ib(repr=False, default=None)
+    _url = attr.ib(repr=False, default=None, eq=False)
 
     # Flag for whether to raise error on object count mismatch
     _count_error = attr.ib(
-        repr=False, default=True, validator=attr.validators.instance_of(bool)
+        repr=False, default=True, validator=attr.validators.instance_of(bool), eq=False
     )
 
     # Actual regular attributes
@@ -175,7 +200,7 @@ class Inventory(object):
 
     #: :class:`~sphobjinv.enum.SourceTypes` |Enum| value indicating the type of
     #: source from which the instance was generated.
-    source_type = attr.ib(init=False, default=None)
+    source_type = attr.ib(init=False, default=None, eq=False)
 
     # Helper strings for inventory datafile output
     #: Preamble line for v2 |objects.inv| header
@@ -313,7 +338,7 @@ def __attrs_post_init__(self):
 
         # Complain if multiple sources provided
         if src_count > 1:
-            raise RuntimeError("At most one data source can " "be specified.")
+            raise RuntimeError("At most one data source can be specified.")
 
         # Leave uninitialized ("manual" init) if no source provided
         if src_count == 0:
@@ -367,7 +392,7 @@ def __attrs_post_init__(self):
                 return
 
     def data_file(self, *, expand=False, contract=False):
-        """Generate a plaintext |objects.inv| as |bytes|.
+        """Generate a plaintext |objects.inv| as UTF-8 |bytes|.
 
         |bytes| is used here as the output type
         since the most common use cases are anticipated to be
@@ -494,26 +519,31 @@ def suggest(self, name, *, thresh=50, with_index=False, with_score=False):
         """
         # Suppress any UserWarning about the speed issue
         with warnings.catch_warnings():
-            warnings.simplefilter("ignore")
+            warnings.filterwarnings(
+                action="ignore",
+                message="Using slow.+levenshtein",
+                category=UserWarning,
+                module="fuzz",
+            )
             from fuzzywuzzy import process as fwp
 
         # Must propagate list index to include in output
         # Search vals are rst prepended with list index
-        srch_list = ["{0} {1}".format(i, o) for i, o in enumerate(self.objects_rst)]
+        srch_list = [f"{i} {o}" for i, o in enumerate(self.objects_rst)]
 
         # Composite each string result extracted by fuzzywuzzy
         # and its match score into a single string. The match
         # and score are returned together in a tuple.
         results = [
-            "{0} {1}".format(*_)
-            for _ in fwp.extract(name, srch_list, limit=None)
-            if _[1] >= thresh
+            f"{match} {score}"
+            for match, score in fwp.extract(name, srch_list, limit=None)
+            if score >= thresh
         ]
 
         # Define regex for splitting the three components, and
         # use it to convert composite result string to tuple:
         # result --> (rst, score, index)
-        p_idx = re.compile("^(\\d+)\\s+(.+?)\\s+(\\d+)$")
+        p_idx = re.compile(r"^(\d+)\s+(.+?)\s+(\d+)$")
         results = [
             (m.group(2), int(m.group(3)), int(m.group(1)))
             for m in map(p_idx.match, results)
@@ -544,7 +574,7 @@ def _general_import(self):
         import_errors = {
             SourceTypes.BytesPlaintext: TypeError,
             SourceTypes.BytesZlib: (zlib_error, TypeError),
-            SourceTypes.FnamePlaintext: (OSError, TypeError),
+            SourceTypes.FnamePlaintext: (OSError, TypeError, UnicodeDecodeError),
             SourceTypes.FnameZlib: (OSError, TypeError, zlib_error),
             SourceTypes.DictJSON: (ValidationError),
         }
@@ -581,7 +611,7 @@ def _try_import(self, import_fxn, src, exc):
         return True
 
     def _import_plaintext_bytes(self, b_str):
-        """Import an inventory from plaintext bytes."""
+        """Import an inventory from plaintext UTF-8 bytes."""
         b_res = pb_project.search(b_str).group(HeaderFields.Project.value)
         project = b_res.decode("utf-8")
 
@@ -589,6 +619,7 @@ def _import_plaintext_bytes(self, b_str):
         version = b_res.decode("utf-8")
 
         def gen_dataobjs():
+            """Generate a data object for each line in the inventory."""
             for mch in pb_data.finditer(b_str):
                 yield DataObjStr(**mch.groupdict())
 
@@ -623,7 +654,7 @@ def _import_url(self, url):
         """Import a file from a remote URL."""
         # Caller's responsibility to ensure URL points
         # someplace safe/sane!
-        req = urlrq.Request(url, headers={"User-Agent": "Magic Browser"})
+        req = urlrq.Request(url, headers={"User-Agent": "sphobjinv URL/" + soi_version})
         resp = urlrq.urlopen(req, context=self._sslcontext)  # noqa: S310
         b_str = resp.read()
 
@@ -657,8 +688,7 @@ def _import_json_dict(self, d):
             except KeyError as e:
                 if self._count_error:
                     err_str = (
-                        "Too few objects found in dict "
-                        "(halt at {0}, expect {1})".format(i, count)
+                        f"Too few objects found in dict (halt at {i}, expect {count})"
                     )
                     raise ValueError(err_str) from e
 
@@ -669,7 +699,7 @@ def _import_json_dict(self, d):
         if check_value:
             # A truthy value here will be the contents
             # of the above set difference
-            err_str = "Too many objects in dict ({0})".format(check_value)
+            err_str = f"Too many objects in dict ({check_value})"
             raise ValueError(err_str)
 
         # Should be good to return
diff --git a/src/sphobjinv/re.py b/src/sphobjinv/re.py
index 5251db80..39b49824 100644
--- a/src/sphobjinv/re.py
+++ b/src/sphobjinv/re.py
@@ -10,13 +10,13 @@
     5 Nov 2017
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
@@ -27,8 +27,8 @@
 
 import re
 
-from sphobjinv.data import DataFields
-from sphobjinv.enum import HeaderFields
+from sphobjinv.data import DataFields as DF  # noqa: N817
+from sphobjinv.enum import HeaderFields as HF  # noqa: N817
 
 
 #: Compiled |re| |bytes|  pattern for comment lines in decompressed
@@ -37,14 +37,12 @@
 
 #: Compiled |re| |bytes| pattern for project line
 pb_project = re.compile(
-    """
-    ^                        # Start of line
-    [#][ ]Project:[ ]        # Preamble
-    (?P<{}>.*?)              # Lazy rest of line is the project name
-    \\r?$                    # Ignore possible CR at EOL
-    """.format(
-        HeaderFields.Project.value
-    ).encode(
+    rf"""
+    ^                            # Start of line
+    [#][ ]Project:[ ]            # Preamble
+    (?P<{HF.Project.value}>.*?)  # Lazy rest of line is project name
+    \r?$                         # Ignore possible CR at EOL
+    """.encode(
         encoding="utf-8"
     ),
     re.M | re.X,
@@ -52,14 +50,12 @@
 
 #: Compiled |re| |bytes| pattern for version line
 pb_version = re.compile(
-    """
-    ^                        # Start of line
-    [#][ ]Version:[ ]        # Preamble
-    (?P<{}>.*?)              # Lazy rest of line is the version
-    \\r?$                    # Ignore possible CR at EOL
-    """.format(
-        HeaderFields.Version.value
-    ).encode(
+    rf"""
+    ^                            # Start of line
+    [#][ ]Version:[ ]            # Preamble
+    (?P<{HF.Version.value}>.*?)  # Lazy rest of line is version
+    \r?$                         # Ignore possible CR at EOL
+    """.encode(
         encoding="utf-8"
     ),
     re.M | re.X,
@@ -68,28 +64,21 @@
 #: Regex pattern string used to compile
 #: :data:`~sphobjinv.re.p_data` and
 #: :data:`~sphobjinv.re.pb_data`
-ptn_data = """\
-    ^                        # Start of line
-    (?P<{0}>[^#]\\S+)        # --> Name
-    \\s+                     # Dividing space
-    (?P<{1}>\\w+)            # --> Domain
-    :                        # Dividing colon
-    (?P<{2}>\\w+)            # --> Role
-    \\s+                     # Dividing space
-    (?P<{3}>-?\\d+)          # --> Priority
-    \\s+                     # Dividing space
-    (?P<{4}>\\S+)            # --> URI
-    \\s+                     # Dividing space
-    (?P<{5}>.+?)             # --> Display name, lazy b/c possible CR
-    \\r?$                    # Ignore possible CR at EOL
-    """.format(
-    DataFields.Name.value,
-    DataFields.Domain.value,
-    DataFields.Role.value,
-    DataFields.Priority.value,
-    DataFields.URI.value,
-    DataFields.DispName.value,
-)
+ptn_data = rf"""
+    ^                               # Start of line
+    (?P<{DF.Name.value}>.+?)        # --> Name
+    \s+                             # Dividing space
+    (?P<{DF.Domain.value}>[^\s:]+)  # --> Domain
+    :                               # Dividing colon
+    (?P<{DF.Role.value}>[^\s:]+)    # --> Role
+    \s+                             # Dividing space
+    (?P<{DF.Priority.value}>-?\d+)  # --> Priority
+    \s+?                            # Dividing space
+    (?P<{DF.URI.value}>\S*)         # --> URI
+    \s+                             # Dividing space
+    (?P<{DF.DispName.value}>.+?)    # --> Display name, lazy b/c possible CR
+    \r?$                            # Ignore possible CR at EOL
+    """
 
 #: Compiled |re| |bytes| regex pattern for data lines in |bytes| decompressed
 #: inventory files
diff --git a/src/sphobjinv/schema.py b/src/sphobjinv/schema.py
index c9533bb9..0812110e 100644
--- a/src/sphobjinv/schema.py
+++ b/src/sphobjinv/schema.py
@@ -11,13 +11,13 @@
     7 Dec 2017
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
@@ -47,7 +47,7 @@
 #: as generated from or expected by
 #: :class:`~sphobjinv.inventory.Inventory` classes.
 json_schema = {
-    "$schema": "http://json-schema.org/schema#",
+    "$schema": "https://json-schema.org/draft-04/schema#",
     "type": "object",
     "properties": {
         "project": {"type": "string"},
@@ -56,7 +56,7 @@
         "metadata": {},
     },
     "patternProperties": {
-        "^\\d+": {
+        r"^\d+": {
             "type": "object",
             "properties": subschema_json,
             "additionalProperties": False,
diff --git a/src/sphobjinv/version.py b/src/sphobjinv/version.py
index 6a0b7583..57b516d5 100644
--- a/src/sphobjinv/version.py
+++ b/src/sphobjinv/version.py
@@ -10,13 +10,13 @@
     18 Mar 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
@@ -25,4 +25,4 @@
 
 """
 
-__version__ = "2.0.1"
+__version__ = "2.1"
diff --git a/src/sphobjinv/zlib.py b/src/sphobjinv/zlib.py
index 864c2999..be329fd8 100644
--- a/src/sphobjinv/zlib.py
+++ b/src/sphobjinv/zlib.py
@@ -10,13 +10,13 @@
     5 Nov 2017
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
-    http://www.github.com/bskinn/sphobjinv
+    https://github.com/bskinn/sphobjinv
 
 **Documentation**
-    http://sphobjinv.readthedocs.io
+    https://sphobjinv.readthedocs.io/en/latest
 
 **License**
     The MIT License; see |license_txt|_ for full license terms
diff --git a/tox.ini b/tox.ini
index 66e6ba8e..ef1627fe 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,77 +2,108 @@
 minversion=2.0
 isolated_build=True
 envlist=
-    py3{5,6,7,8,9}-sphx_1_7_6-attrs_18_2-jsch_2_6
-    py37-sphx_{1_6,1_6_5,1_7_6,1_8,2_0,2_1,2_2,latest}-attrs_18_2-jsch_2_6
-    py37-sphx_1_7_6-attrs_{17_4,18_2,19_1,19_2,19_3,latest}-jsch_2_6
-    py37-sphx_1_7_6-attrs_18_2-jsch_{2_0,2_6,3_0,3_1,latest}
-    py35-sphx_1_6_1-attrs17_4-jsch_2_0
+    py3_{06,07,08,09,10}-sphx_latest-attrs_latest-jsch_latest-fzwz_latest
+    py3_09-sphx_dev-attrs_dev-jsch_dev-fzwz_dev
+    py3_09-sphx_{1_6_x,1_x,2_x,3_x,dev}-attrs_latest-jsch_latest-fzwz_latest
+    py3_09-sphx_latest-attrs_{19_2,19_3,20_1,20_2,20_3,dev}-jsch_latest-fzwz_latest
+    py3_09-sphx_latest-attrs_latest-jsch_{3_0,3_1,dev}-fzwz_latest
+    py3_09-sphx_latest-attrs_latest-jsch_latest-fzwz_0_{08,10,12,14,16,17,dev}
+    py3_06-sphx_1_6_x-attrs_19_2-jsch_3_0-fzwz_0_08
+    py3_0{7,8}-sphx_{1,2}_x-attrs_{19,20}_2-jsch_latest-fzwz_0_{10,16}
+    py3_09-sphx_{2_3_1,2_4_0,3_2_1,3_3_0}-attrs_latest-jsch_latest-fzwz_latest
     sdist_install
     flake8
 
 [testenv]
-whitelist_externals=/bin/sh
 commands=
     python --version
-    #sh -c 'cd doc; make html'
-    pytest --nonloc
-    py36-sphx_176: sh -c 'cd doc; make doctest'
+    pip list
+    pytest {posargs:--nonloc}
 deps=
-    sphx_1_6:     sphinx<1.7
-    sphx_1_6_1:   sphinx==1.6.1
-    sphx_1_6_5:   sphinx==1.6.5
-    sphx_1_7_6:   sphinx==1.7.6
-    sphx_1_8:     sphinx~=1.8
-    sphx_2_0:     sphinx~=2.0
-    sphx_2_1:     sphinx~=2.1
-    sphx_2_2:     sphinx~=2.2
+    sphx_1_6_x:   sphinx<1.7
+    sphx_1_x:     sphinx<2
+    sphx_2_x:     sphinx<3
+    sphx_3_x:     sphinx<4
+    sphx_2_3_1:   sphinx==2.3.1
+    sphx_2_4_0:   sphinx==2.4.0
+    sphx_3_2_1:   sphinx==3.2.1
+    sphx_3_3_0:   sphinx==3.3.0
     sphx_latest:  sphinx
+    sphx_dev:     git+https://github.com/sphinx-doc/sphinx
 
-    attrs_17_4:   attrs==17.4
-    attrs_18_1:   attrs==18.1
-    attrs_18_2:   attrs==18.2
-    attrs_19_1:   attrs==19.1
     attrs_19_2:   attrs==19.2
     attrs_19_3:   attrs==19.3
+    attrs_20_1:   attrs==20.1
+    attrs_20_2:   attrs==20.2
+    attrs_20_3:   attrs==20.3
     attrs_latest: attrs
+    attrs_dev:    git+https://github.com/python-attrs/attrs
 
-    jsch_1_0:     jsonschema==1.0
-    jsch_1_3:     jsonschema~=1.3
-    jsch_2_0:     jsonschema~=2.0
-    jsch_2_3:     jsonschema~=2.3
-    jsch_2_6:     jsonschema~=2.6
-    jsch_3_0:     jsonschema~=3.0
-    jsch_3_1:     jsonschema~=3.1
+    jsch_3_0:     jsonschema<3.1
+    jsch_3_1:     jsonschema<3.2
     jsch_latest:  jsonschema
+    jsch_dev:     git+https://github.com/Julian/jsonschema
 
+    fzwz_0_08:    fuzzywuzzy==0.8
+    fzwz_0_09:    fuzzywuzzy==0.9
+    fzwz_0_10:    fuzzywuzzy==0.10
+    fzwz_0_11:    fuzzywuzzy==0.11
+    fzwz_0_12:    fuzzywuzzy==0.12
+    fzwz_0_13:    fuzzywuzzy==0.13
+    fzwz_0_14:    fuzzywuzzy==0.14
+    fzwz_0_15:    fuzzywuzzy==0.15
+    fzwz_0_16:    fuzzywuzzy==0.16
+    fzwz_0_17:    fuzzywuzzy==0.17
+    fzwz_latest:  fuzzywuzzy
+    fzwz_dev:     git+https://github.com/seatgeek/fuzzywuzzy
+
+    dictdiffer
     pytest>=4.4.0
+    pytest-check>=0.4
     pytest-ordering
-    pytest-subtests
     pytest-timeout
     stdio-mgr>=1.0.1
     sphinx-issues==0.4.0
     sphinx-rtd-theme
+    sphinxcontrib-programoutput
 
 [testenv:win]
 platform=win
 basepython=
-    py36: C:\python36\python.exe
-    py35: C:\python35\python.exe
+    py3_09: python3.9
+    py3_08: python3.8
+    py3_07: python3.7
+    py3_06: python3.6
+    py3_05: python3.5
 
 [testenv:linux]
 platform=linux
 basepython=
-    py39: python3.9
-    py38: python3.8
-    py37: python3.7
-    py36: python3.6
-    py35: python3.5
+    py3_10: python3.10
+    py3_09: python3.9
+    py3_08: python3.8
+    py3_07: python3.7
+    py3_06: python3.6
+    py3_05: python3.5
 
 [testenv:flake8]
 skip_install=True
 deps=-rrequirements-flake8.txt
 commands=
-    flake8 conftest.py tests src
+    python -c 'import sys, colorama; from flake8.main.cli import main; colorama.init(); sys.argv = ["flake8", "conftest.py", "tests", "src"]; main()'
+
+[testenv:flake8-noqa]
+skip_install=True
+deps=-rrequirements-flake8.txt
+commands=
+    pip install flake8-noqa
+    flake8 --color=never --exit-zero conftest.py tests src
+
+[testenv:interrogate]
+skip_install=True
+deps=interrogate
+commands=
+    interrogate conftest.py tests src
 
 [testenv:sdist_install]
 commands=
@@ -88,10 +119,11 @@ markers =
   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
+  flake8_ext: Test checking that all desired plugins are active
   first: Inherited marker from `pytest-ordering`
   timeout: Inherited marker from `pytest-timeout`
 
-addopts = --strict --doctest-glob="README.rst" -rsxX -Werror
+addopts = --strict-markers --doctest-glob="README.rst" -rsxX -Werror
 
 norecursedirs = .* env* src *.egg dist build
 
@@ -104,15 +136,32 @@ 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
+rst-roles =
+    attr,
+    class,
+    data,
+    doc,
+    exc,
+    func,
+    meth,
+    mod,
+    ref,
+rst-directives =
+    doctest,
+    versionadded,
+    versionchanged,
 per_file_ignores =
 # D202: No-space-after-docstring is ugly when the first command is a class/def
 # S101: pytest uses asserts liberally
-# RST30x: sphinx &c. have lots of custom roles; use make html -n to find typos
-  src/*:                   RST304,RST305,RST306
-  tests/*:           S101, RST304,RST305,RST306
-  conftest.py: D202, S101, RST304,RST305,RST306
-# F401: MANY things imported but unused in __init__,py
-  src/sphobjinv/__init__.py:  F401 RST304,RST305,RST306
+# RST30x: linter can't know about substitutions/references in rst_epilog
+  src/*:                    RST305,RST306
+  tests/*:           S101,  RST305,RST306
+  conftest.py: D202, S101,  RST305,RST306
+# F401: MANY things imported but unused in __init__.py files
+  src/sphobjinv/__init__.py:      F401, RST305,RST306
+  src/sphobjinv/cli/__init__.py:  F401, RST305,RST306
+# PIE786: CLI uses 'except Exception:' as a catchall... to be changed, eventually
+  src/sphobjinv/cli/*:            PIE786, RST305,RST306
 
 #flake8-import-order
 import-order-style = smarkets


Test patch:
diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml
new file mode 100644
index 00000000..b7f65000
--- /dev/null
+++ b/.github/workflows/ci_tests.yml
@@ -0,0 +1,93 @@
+name: ci-tests
+
+on: push
+
+jobs:
+  all_checks:
+    name: Run all tests, lints, etc. (Python 3.9)
+    runs-on: ubuntu-latest
+    if: "!contains(github.event.head_commit.message, '[skip ci]')"
+
+    steps:
+    - name: Check out repo
+      uses: actions/checkout@v2
+
+    - name: Install Python
+      uses: actions/setup-python@v2
+      with:
+        python-version: 3.9
+
+    - name: Update pip & setuptools
+      run: python -m pip install -U pip setuptools
+
+    - name: Install & report CI dependencies
+      run: |
+        python -m pip install -U --force-reinstall -r requirements-ci.txt -r requirements-flake8.txt
+        python --version
+        pip list
+
+    - name: Build docs
+      run: |
+        cd doc
+        make html
+        mkdir scratch
+
+    - name: Run tests
+      run: |
+        pytest --cov=src --testall --nonloc
+        tox -e sdist_install
+
+    - name: Run doctests
+      run: |
+        cd doc
+        make doctest
+
+    - name: Run docs link check
+      run: |
+        cd doc
+        make linkcheck
+
+    - name: Lint code
+      run: tox -e flake8
+
+    - name: Transmit to Codecov
+      run: codecov
+
+
+  just_tests:
+    name: Run only the test suite
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python: ['3.6', '3.7', '3.8']
+    if: "!contains(github.event.head_commit.message, '[skip ci]')"
+
+    steps:
+    - name: Check out repo
+      uses: actions/checkout@v2
+
+    - name: Install Python
+      uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python }}
+
+    - name: Update pip & setuptools
+      run: python -m pip install -U pip setuptools
+
+    - name: Install & report CI dependencies
+      run: |
+        python -m pip install -U --force-reinstall -r requirements-ci.txt
+        python --version
+        pip list
+
+    - name: Build docs
+      run: |
+        cd doc
+        make html
+        mkdir scratch
+
+    - name: Run tests
+      run: |
+        pytest --cov=src --testall --nonloc
+        tox -e sdist_install
+      
\ No newline at end of file
diff --git a/azure-coretest.yml b/azure-coretest.yml
new file mode 100644
index 00000000..eb1a3b73
--- /dev/null
+++ b/azure-coretest.yml
@@ -0,0 +1,44 @@
+parameters:
+  pythons: []
+  platforms: []
+
+jobs:
+- ${{ each python in parameters.pythons }}:
+  - ${{ each platform in parameters.platforms }}:
+    - job: ${{ python.key }}_${{ coalesce(platform, 'linux') }}
+      variables:
+        ${{ if eq(platform, 'linux') }}:
+          image: 'Ubuntu-latest'
+        ${{ if eq(platform, 'windows') }}:
+          image: 'windows-latest'
+        ${{ if eq(platform, 'macOs') }}:
+          image: 'macOS-latest'
+        ${{ if notIn(platform, 'macOs', 'linux', 'windows') }}:
+          image: 'Ubuntu-latest'
+
+      pool:
+        vmImage: $[ variables.image ]
+
+      steps:
+      - task: UsePythonVersion@0
+        inputs:
+          versionSpec: '${{ python.value.spec }}'
+          architecture: 'x64'
+        displayName: Use cached ${{ coalesce(python.value.name, python.key) }} for tests.
+
+      - script: pip install -U --force-reinstall -r requirements-ci.txt
+        displayName: Install CI requirements
+
+      - script: pip list
+        displayName: Show full environment contents
+
+      - script: cd doc; make html; mkdir scratch
+        displayName: Build  the documentation for tests to work against (Linux/macOs)
+        condition: ne('${{ platform }}', 'windows')
+
+      - script: cd doc & make html & md scratch
+        displayName: Build  the documentation for tests to work against (Windows)
+        condition: eq('${{ platform }}', 'windows')
+
+      - script: pytest --testall --nonloc
+        displayName: Run pytest (${{ coalesce(python.value.name, python.key) }})
diff --git a/conftest.py b/conftest.py
index 9f1b96ab..028d33c0 100644
--- a/conftest.py
+++ b/conftest.py
@@ -10,7 +10,7 @@
     20 Mar 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -27,14 +27,20 @@
 
 
 import os.path as osp
+import platform
 import re
 import shutil
 import sys
 from enum import Enum
 from filecmp import cmp
+from functools import partial
+from io import BytesIO
 from pathlib import Path
 
+import jsonschema
 import pytest
+from sphinx import __version__ as sphinx_version_str
+from sphinx.util.inventory import InventoryFile as IFile
 
 import sphobjinv as soi
 
@@ -49,25 +55,28 @@ def pytest_addoption(parser):
             "testing resource folder, not just objects_attrs.inv"
         ),
     )
-    parser.addoption("--nonloc", action="store_true", help=("Include nonlocal tests"))
+    parser.addoption("--nonloc", action="store_true", help="Include nonlocal tests")
+    parser.addoption(
+        "--flake8_ext", action="store_true", help="Include flake8 extensions test"
+    )
 
 
 @pytest.fixture(scope="session")
 def res_path():
     """Provide Path object to the test resource directory."""
-    return Path("tests") / "resource"
+    return Path("tests", "resource")
 
 
 @pytest.fixture(scope="session")
 def res_cmp(res_path, misc_info):
-    """Provide string path to the compressed attrs inventory in resource."""
-    return str(res_path / (misc_info.FNames.RES.value + misc_info.Extensions.CMP.value))
+    """Provide Path object to the compressed attrs inventory in resource."""
+    return res_path / (misc_info.FNames.RES.value + misc_info.Extensions.CMP.value)
 
 
 @pytest.fixture(scope="session")
 def res_dec(res_path, misc_info):
-    """Provide string path to the decompressed attrs inventory in resource."""
-    return str(res_path / (misc_info.FNames.RES.value + misc_info.Extensions.DEC.value))
+    """Provide Path object to the decompressed attrs inventory in resource."""
+    return res_path / (misc_info.FNames.RES.value + misc_info.Extensions.DEC.value)
 
 
 @pytest.fixture(scope="session")
@@ -75,18 +84,26 @@ def misc_info(res_path):
     """Supply Info object with various test-relevant content."""
 
     class Info:
-        class FNames(Enum):
+        """Monolithic test-information class."""
+
+        class FNames(str, Enum):
+            """Enum of test-relevant file names."""
+
             RES = "objects_attrs"
             INIT = "objects"
             MOD = "objects_mod"
 
-        class Extensions(Enum):
+        class Extensions(str, Enum):
+            """Enum of test-relevant file extensions."""
+
             CMP = ".inv"
             DEC = ".txt"
             JSON = ".json"
 
         invalid_filename = "*?*?.txt" if sys.platform == "win32" else "/"
 
+        IN_PYPY = "pypy" in sys.implementation.name
+
         # Sample object lines lines from an inventory, as bytes
         # False --> contracted abbreviations
         # True  --> expanded abbreviations
@@ -123,9 +140,10 @@ def scratch_path(tmp_path, res_path, misc_info):
     scr_base = misc_info.FNames.INIT.value
 
     for ext in [_.value for _ in misc_info.Extensions]:
+        # The str() calls here are for Python 3.5 compat
         shutil.copy(
-            str(res_path / "{}{}".format(res_base, ext)),
-            str(tmp_path / "{}{}".format(scr_base, ext)),
+            str(res_path / f"{res_base}{ext}"),
+            str(tmp_path / f"{scr_base}{ext}"),
         )
 
     yield tmp_path
@@ -134,45 +152,86 @@ def scratch_path(tmp_path, res_path, misc_info):
 @pytest.fixture(scope="session")
 def ensure_doc_scratch():
     """Ensure doc/scratch dir exists, for README shell examples."""
-    (Path(".") / "doc" / "scratch").mkdir(parents=True, exist_ok=True)
+    Path("doc", "scratch").mkdir(parents=True, exist_ok=True)
 
 
 @pytest.fixture(scope="session")
 def bytes_txt(misc_info, res_path):
     """Load and return the contents of the example objects_attrs.txt as bytes."""
     return soi.fileops.readbytes(
-        str(res_path / (misc_info.FNames.RES.value + misc_info.Extensions.DEC.value))
+        res_path / (misc_info.FNames.RES.value + misc_info.Extensions.DEC.value)
     )
 
 
+def sphinx_ifile_load(path):
+    """Carry out inventory load via Sphinx InventoryFile.
+
+    Defined as a standalone function to allow importing
+    during debugging.
+
+    """
+    return IFile.load(BytesIO(path.read_bytes()), "", osp.join)
+
+
+@pytest.fixture(scope="session", name="sphinx_ifile_load")
+def fixture_sphinx_ifile_load():
+    """Return helper function to load inventory via Sphinx InventoryFile."""
+    return sphinx_ifile_load
+
+
+def sphinx_ifile_data_count(ifile_data):
+    """Report the total number of items in the InventoryFile data.
+
+    Defined standalone to allow import during debugging.
+
+    """
+    return sum(len(ifile_data[k]) for k in ifile_data)
+
+
+@pytest.fixture(scope="session", name="sphinx_ifile_data_count")
+def fixture_sphinx_ifile_data_count():
+    """Return helper function to report total number of objects."""
+    return sphinx_ifile_data_count
+
+
 @pytest.fixture(scope="session")
-def sphinx_load_test():
+def sphinx_load_test(sphinx_ifile_load):
     """Return function to perform 'live' Sphinx inventory load test."""
-    from sphinx.util.inventory import InventoryFile as IFile
 
     def func(path):
-        # Easier to have the file open the whole time
-        with path.open("rb") as f:
-
-            # Attempt the load operation
-            try:
-                IFile.load(f, "", osp.join)
-            except Exception as e:
-                pytest.fail(e)
+        """Perform the 'live' inventory load test."""
+        try:
+            sphinx_ifile_load(path)
+        except Exception as e:  # noqa: PIE786
+            # An exception here is a failing test, not a test error.
+            pytest.fail(e)
 
     return func
 
 
+@pytest.fixture(scope="session")
+def sphinx_version():
+    """Provide the installed Sphinx version as a tuple.
+
+    Returns (major, minor, patch).
+
+    """
+    p_version = re.compile(r"(\d+)[.]?(\d+)?[.]?(\d+)?")
+    mch = p_version.match(sphinx_version_str)
+    return tuple(map((lambda x: int(x) if x else 0), mch.groups()))
+
+
 @pytest.fixture()  # Must be function scope since uses monkeypatch
 def run_cmdline_test(monkeypatch):
     """Return function to perform command line exit code test."""
-    from sphobjinv.cmdline import main
+    from sphobjinv.cli.core import main
 
     def func(arglist, *, expect=0):  # , suffix=None):
+        """Perform the CLI exit-code test."""
 
         # Assemble execution arguments
         runargs = ["sphobjinv"]
-        runargs.extend(arglist)
+        runargs.extend(str(a) for a in arglist)
 
         # Mock sys.argv, run main, and restore sys.argv
         with monkeypatch.context() as m:
@@ -200,6 +259,8 @@ def decomp_cmp_test(misc_info):
     """Return function to confirm a decompressed file is identical to resource."""
 
     def func(path):
+        """Perform the round-trip compress/decompress comparison test."""
+        # The str() calls here are for Python 3.5 compat
         assert cmp(str(misc_info.res_decomp_path), str(path), shallow=False)
 
     return func
@@ -224,14 +285,33 @@ def func(inv, source_type):
     return func
 
 
-testall_inv_paths = (
+testall_inv_paths = [
     p
     for p in (Path(__file__).parent / "tests" / "resource").iterdir()
     if p.name.startswith("objects_") and p.name.endswith(".inv")
-)
+]
+testall_inv_ids = [p.name[8:-4] for p in testall_inv_paths]
 
 
-@pytest.fixture(params=testall_inv_paths)
+@pytest.fixture(params=testall_inv_paths, ids=testall_inv_ids)
 def testall_inv_path(request):
     """Provide parametrized --testall inventory paths."""
     return request.param
+
+
+@pytest.fixture(scope="session")
+def is_win():
+    """Report boolean of whether the current system is Windows."""
+    return platform.system().lower() == "windows"
+
+
+@pytest.fixture(scope="session")
+def unix2dos():
+    """Provide function for converting POSIX to Windows EOLs."""
+    return partial(re.sub, rb"(?<!\r)\n", b"\r\n")
+
+
+@pytest.fixture(scope="session")
+def jsonschema_validator():
+    """Provide the standard JSON schema validator."""
+    return jsonschema.Draft4Validator
diff --git a/tests/resource/objects_attrs_20_3_0.inv b/tests/resource/objects_attrs_20_3_0.inv
new file mode 100644
index 00000000..c281879f
Binary files /dev/null and b/tests/resource/objects_attrs_20_3_0.inv differ
diff --git a/tests/resource/objects_mdn.bad_inv b/tests/resource/objects_mdn.bad_inv
new file mode 100644
index 00000000..3edaf693
Binary files /dev/null and b/tests/resource/objects_mdn.bad_inv differ
diff --git a/tests/resource/objects_mkdoc_zlib0.inv b/tests/resource/objects_mkdoc_zlib0.inv
new file mode 100644
index 00000000..ede03976
--- /dev/null
+++ b/tests/resource/objects_mkdoc_zlib0.inv
@@ -0,0 +1,117 @@
+# Sphinx inventory version 2
+# Project: mkdocstrings
+# Version: 0.0.0
+# The remainder of this file is compressed using zlib.
+x;&��mkdocstrings.extension py:module 1 reference/extension/#$ -
+mkdocstrings.extension.AutoDocProcessor py:class 1 reference/extension/#$ -
+mkdocstrings.extension.AutoDocProcessor.__init__ py:method 1 reference/extension/#$ -
+mkdocstrings.extension.AutoDocProcessor.run py:method 1 reference/extension/#$ -
+mkdocstrings.extension.AutoDocProcessor.test py:method 1 reference/extension/#$ -
+mkdocstrings.extension.MkdocstringsExtension py:class 1 reference/extension/#$ -
+mkdocstrings.extension.MkdocstringsExtension.__init__ py:method 1 reference/extension/#$ -
+mkdocstrings.extension.MkdocstringsExtension.extendMarkdown py:method 1 reference/extension/#$ -
+mkdocstrings.extension.get_item_configs py:function 1 reference/extension/#$ -
+mkdocstrings.inventory py:module 1 reference/inventory/#$ -
+mkdocstrings.inventory.Inventory py:class 1 reference/inventory/#$ -
+mkdocstrings.inventory.Inventory.__init__ py:method 1 reference/inventory/#$ -
+mkdocstrings.inventory.Inventory.format_sphinx py:method 1 reference/inventory/#$ -
+mkdocstrings.inventory.Inventory.register py:method 1 reference/inventory/#$ -
+mkdocstrings.inventory.InventoryItem py:class 1 reference/inventory/#$ -
+mkdocstrings.inventory.InventoryItem.__init__ py:method 1 reference/inventory/#$ -
+mkdocstrings.inventory.InventoryItem.format_sphinx py:method 1 reference/inventory/#$ -
+mkdocstrings.loggers py:module 1 reference/loggers/#$ -
+mkdocstrings.loggers.LoggerAdapter py:class 1 reference/loggers/#$ -
+mkdocstrings.loggers.LoggerAdapter.__init__ py:method 1 reference/loggers/#$ -
+mkdocstrings.loggers.LoggerAdapter.process py:method 1 reference/loggers/#$ -
+mkdocstrings.loggers.TemplateLogger py:class 1 reference/loggers/#$ -
+mkdocstrings.loggers.TemplateLogger.__init__ py:method 1 reference/loggers/#$ -
+mkdocstrings.loggers.get_logger py:function 1 reference/loggers/#$ -
+mkdocstrings.loggers.get_template_logger py:function 1 reference/loggers/#$ -
+mkdocstrings.loggers.get_template_logger_function py:function 1 reference/loggers/#$ -
+mkdocstrings.loggers.get_template_path py:function 1 reference/loggers/#$ -
+mkdocstrings.plugin py:module 1 reference/plugin/#$ -
+mkdocstrings.plugin.RENDERING_OPTS_KEY py:data 1 reference/plugin/#$ -
+mkdocstrings.plugin.SELECTION_OPTS_KEY py:data 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin py:class 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin.config_scheme py:attr 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin.handlers py:attr 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin.inventory_enabled py:attr 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin.__init__ py:method 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin.get_handler py:method 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin.on_config py:method 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin.on_post_build py:method 1 reference/plugin/#$ -
+mkdocstrings.plugin.MkdocstringsPlugin.on_serve py:method 1 reference/plugin/#$ -
+mkdocs_autorefs.plugin py:module 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin py:class 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin.__init__ py:method 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url py:method 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin.map_urls py:method 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin.on_config py:method 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin.on_page_content py:method 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin.on_page_markdown py:method 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin.on_post_page py:method 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.plugin.AutorefsPlugin.register_anchor py:method 1 reference/autorefs/plugin/#$ -
+mkdocs_autorefs.references py:module 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.AUTO_REF_RE py:data 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.AutoRefInlineProcessor py:class 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.AutoRefInlineProcessor.evalId py:method 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.AutoRefInlineProcessor.handleMatch py:method 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.AutoRefInlineProcessor.makeTag py:method 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.AutorefsExtension py:class 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.AutorefsExtension.extendMarkdown py:method 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.fix_ref py:function 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.fix_refs py:function 1 reference/autorefs/references/#$ -
+mkdocs_autorefs.references.relative_url py:function 1 reference/autorefs/references/#$ -
+mkdocstrings.handlers.base py:module 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseCollector py:class 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseCollector.collect py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseCollector.teardown py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseHandler py:class 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseHandler.__init__ py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseRenderer py:class 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseRenderer.__init__ py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseRenderer.do_convert_markdown py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseRenderer.do_heading py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseRenderer.get_anchor py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseRenderer.get_headings py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseRenderer.render py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.BaseRenderer.update_env py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.CollectionError py:class 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.Handlers py:class 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.Handlers.seen_handlers py:attr 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.Handlers.__init__ py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.Handlers.get_anchor py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.Handlers.get_handler py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.Handlers.get_handler_config py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.Handlers.get_handler_name py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.Handlers.teardown py:method 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.ThemeNotSupported py:class 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.base.do_any py:function 1 reference/handlers/base/#$ -
+mkdocstrings.handlers.python py:module 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonCollector py:class 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonCollector.default_config py:attr 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonCollector.__init__ py:method 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonCollector.collect py:method 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonCollector.teardown py:method 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonHandler py:class 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonRenderer py:class 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonRenderer.default_config py:attr 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonRenderer.get_anchor py:method 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonRenderer.render py:method 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.PythonRenderer.update_env py:method 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.get_handler py:function 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.python.rebuild_category_lists py:function 1 reference/handlers/python/#$ -
+mkdocstrings.handlers.rendering py:module 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.HeadingShiftingTreeprocessor py:class 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.HeadingShiftingTreeprocessor.__init__ py:method 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.HeadingShiftingTreeprocessor.run py:method 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.Highlighter py:class 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.Highlighter.__init__ py:method 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.Highlighter.highlight py:method 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.IdPrependingTreeprocessor py:class 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.IdPrependingTreeprocessor.__init__ py:method 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.IdPrependingTreeprocessor.run py:method 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.MkdocstringsInnerExtension py:class 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.MkdocstringsInnerExtension.__init__ py:method 1 reference/handlers/rendering/#$ -
+mkdocstrings.handlers.rendering.MkdocstringsInnerExtension.extendMarkdown py:method 1 reference/handlers/rendering/#$ -
+Gn!W
\ No newline at end of file
diff --git a/tests/resource/objects_sympy.inv b/tests/resource/objects_sympy.inv
index 9e696022..ae294b84 100644
Binary files a/tests/resource/objects_sympy.inv and b/tests/resource/objects_sympy.inv differ
diff --git a/tests/test_api_fail.py b/tests/test_api_fail.py
index 6430729e..f1a82d54 100644
--- a/tests/test_api_fail.py
+++ b/tests/test_api_fail.py
@@ -10,7 +10,7 @@
     20 Mar 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -25,7 +25,11 @@
 
 """
 
+from zlib import error as zlib_error
+
 import pytest
+from attr.exceptions import FrozenInstanceError
+from jsonschema.exceptions import ValidationError
 
 import sphobjinv as soi
 
@@ -33,142 +37,205 @@
 pytestmark = [pytest.mark.api, pytest.mark.local]
 
 
-def test_apifail_readbytes_missing_input_file(scratch_path):
-    """Confirm that appropriate exception is raised w/no input file."""
-    with pytest.raises(FileNotFoundError):
-        soi.readbytes(str(scratch_path / "thisfilewillneverexist.foo"))
+def no_op(val):
+    """No-op function to leave Path objects alone in tests."""
+    return val
 
 
-def test_apifail_writebytes_badoutputfile(scratch_path, misc_info):
-    """Confirm OSError raised on bad filename (example of read error)."""
-    b_str = b"This is a binary string!"
+PATH_FXNS = (no_op, str)
+PATH_FXN_IDS = ("no_op", "str")
 
-    with pytest.raises(OSError):
-        soi.writebytes(misc_info.invalid_filename, b_str)
+DISALLOWED_INV_INIT_ARGS = ("project", "objects", "source_type", "data_file")
 
 
-def test_apifail_error_decompressing_plaintext(res_dec):
-    """Confirm error raised on attempt to decompress plaintext."""
-    from zlib import error as zlib_error
+class TestCore:
+    """Tests for core sphobjinv functionality."""
 
-    # OS-dependent. VersionError on Windows b/c bytes import of the
-    # text-file objects_attrs.txt has the first line ending with b'2\r\n',
-    # whereas *nix will pass the version check but choke in the zlib
-    # decompression process
-    with pytest.raises((zlib_error, soi.VersionError)):
-        soi.Inventory(fname_zlib=res_dec)
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_readbytes_missing_input_file(self, path_fxn, scratch_path):
+        """Confirm that appropriate exception is raised w/no input file."""
+        with pytest.raises(FileNotFoundError):
+            soi.readbytes(path_fxn(scratch_path / "thisfilewillneverexist.foo"))
 
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_writebytes_badoutputfile(self, path_fxn, scratch_path, misc_info):
+        """Confirm OSError raised on bad filename (example of read error)."""
+        b_str = b"This is a binary string!"
 
-@pytest.mark.parametrize("dobj", [soi.DataObjBytes, soi.DataObjStr])
-def test_apifail_bad_dataobj_init_types(dobj):
-    """Confirm error raised when init-ed w/wrong types."""
-    with pytest.raises(TypeError):
-        dobj(*range(6))
+        with pytest.raises(OSError):
+            soi.writebytes(path_fxn(scratch_path / misc_info.invalid_filename), b_str)
 
 
-@pytest.mark.xfail(reason="Made mutable to simplify Inventory revision by users")
-@pytest.mark.parametrize("use_bytes", [True, False])
-def test_apifail_changing_immutable_dataobj(use_bytes, res_cmp):
-    """Confirm DataObj's are immutable."""
-    from attr.exceptions import FrozenInstanceError as FIError
+class TestDataObj:
+    """Tests for the DataObj classes."""
 
-    inv = soi.Inventory(res_cmp)
+    @pytest.mark.parametrize("dobj", [soi.DataObjBytes, soi.DataObjStr])
+    def test_apifail_bad_dataobj_init_types(self, dobj):
+        """Confirm error raised when init-ed w/wrong types."""
+        with pytest.raises(TypeError):
+            dobj(*range(6))
 
-    with pytest.raises(FIError):
-        if use_bytes:
-            inv.objects[0].as_bytes.name = b"newname"
-        else:
-            inv.objects[0].name = "newname"
+    def test_apifail_dataline_bothargstrue(self, misc_info):
+        """Confirm error raised when both expand and contract are True."""
+        dos = soi.DataObjStr(**soi.p_data.search(misc_info.str_lines[True]).groupdict())
+        with pytest.raises(ValueError):
+            dos.data_line(expand=True, contract=True)
 
 
-def test_apifail_dataline_bothargstrue(misc_info):
-    """Confirm error raised when both expand and contract are True."""
-    dos = soi.DataObjStr(**soi.p_data.search(misc_info.str_lines[True]).groupdict())
-    with pytest.raises(ValueError):
-        dos.data_line(expand=True, contract=True)
+class TestInventory:
+    """Tests for the Inventory class."""
 
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_error_decompressing_plaintext(self, path_fxn, res_dec):
+        """Confirm error raised on attempt to decompress plaintext."""
+        # OS-dependent. VersionError on Windows b/c bytes import of the
+        # text-file objects_attrs.txt has the first line ending with b'2\r\n',
+        # whereas *nix will pass the version check but choke in the zlib
+        # decompression process
+        with pytest.raises((zlib_error, soi.VersionError)):
+            soi.Inventory(fname_zlib=path_fxn(res_dec))
 
-def test_apifail_inventory_invalidsource():
-    """Confirm error raised when invalid source provided."""
-    with pytest.raises(TypeError):
-        soi.Inventory("abcdefg")
+    def test_apifail_inventory_invalidsource(self):
+        """Confirm error raised when invalid source provided."""
+        with pytest.raises(TypeError):
+            soi.Inventory("abcdefg")
 
+    def test_apifail_inventory_dictimport_noitems(self):
+        """Confirm ValueError with no-items dict passed to json_dict."""
+        d = {
+            soi.HeaderFields.Project.value: "proj",
+            soi.HeaderFields.Version.value: "v3.3",
+            soi.HeaderFields.Count.value: 5,
+        }
 
-def test_apifail_inventory_dictimport_noitems():
-    """Confirm ValueError with no-items dict passed to json_dict."""
-    d = {
-        soi.HeaderFields.Project.value: "proj",
-        soi.HeaderFields.Version.value: "v3.3",
-        soi.HeaderFields.Count.value: 5,
-    }
+        with pytest.raises(ValueError):
+            soi.Inventory()._import_json_dict(d)
 
-    with pytest.raises(ValueError):
-        soi.Inventory()._import_json_dict(d)
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_inventory_dictimport_toosmall(self, path_fxn, res_dec):
+        """Confirm error raised when JSON dict passed w/too few objects."""
+        inv = soi.Inventory(path_fxn(res_dec))
+        d = inv.json_dict()
+        d.pop("12")
 
+        with pytest.raises(ValueError):
+            soi.Inventory(d)
 
-def test_apifail_inventory_dictimport_toosmall(res_dec):
-    """Confirm error raised when JSON dict passed w/too few objects."""
-    inv = soi.Inventory(res_dec)
-    d = inv.json_dict()
-    d.pop("12")
+    @pytest.mark.parametrize(
+        "bad_key", ("1112", "quux"), ids=("numeric", "non_numeric")
+    )
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_inventory_dictimport_badobj(
+        self, bad_key, path_fxn, res_dec, jsonschema_validator
+    ):
+        """Confirm error raised when JSON dict passed w/an invalid object."""
+        inv = soi.Inventory(path_fxn(res_dec))
+        d = inv.json_dict()
+        d.update({bad_key: "foobarbazquux"})
+
+        with pytest.raises(ValidationError):
+            soi.Inventory(dict_json=d)
+
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_inventory_dictimport_toobig(self, path_fxn, res_dec):
+        """Confirm error raised when JSON dict passed w/too many objects."""
+        inv = soi.Inventory(path_fxn(res_dec))
+        d = inv.json_dict()
+        d.update({"57": d["23"]})
 
-    with pytest.raises(ValueError):
-        soi.Inventory(d)
+        with pytest.raises(ValueError):
+            soi.Inventory(dict_json=d)
 
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_inventory_dictimport_badrootobject(self, path_fxn, res_dec):
+        """Confirm error raised when spurious extra root object present."""
+        inv = soi.Inventory(path_fxn(res_dec))
+        d = inv.json_dict()
+        d.update({"bad_foo": "angry_bar"})
 
-def test_apifail_inventory_dictimport_badobj(res_dec):
-    """Confirm error raised when JSON dict passed w/an invalid object."""
-    from jsonschema.exceptions import ValidationError
+        with pytest.raises(ValidationError):
+            soi.Inventory(dict_json=d)
 
-    inv = soi.Inventory(res_dec)
-    d = inv.json_dict()
-    d.update({"112": "foobarbazquux"})
+    def test_apifail_inventory_dictimport_toomanysrcargs(
+        self,
+    ):
+        """Confirm error if >1 sources passed."""
+        with pytest.raises(RuntimeError):
+            soi.Inventory(source="foo", plaintext="bar")
 
-    with pytest.raises(ValidationError):
-        soi.Inventory(dict_json=d)
+    def test_apifail_inventory_no_object_invs(self, check):
+        """Confirm no-objects inventories don't import."""
+        inv = soi.Inventory()
 
+        with pytest.raises(TypeError):
+            soi.Inventory(inv.data_file())
 
-def test_apifail_inventory_dictimport_toobig(res_dec):
-    """Confirm error raised when JSON dict passed w/too many objects."""
-    inv = soi.Inventory(res_dec)
-    d = inv.json_dict()
-    d.update({"57": d["23"]})
+        with pytest.raises((TypeError, ValueError)):
+            soi.Inventory(soi.compress(inv.data_file()))
 
-    with pytest.raises(ValueError):
-        soi.Inventory(dict_json=d)
+        d = {"project": "test", "version": "0.0", "count": 0}
+        with pytest.raises(ValueError):
+            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.
 
-def test_apifail_inventory_dictimport_badrootobject(res_dec):
-    """Confirm error raised when spurious extra root object present."""
-    from jsonschema.exceptions import ValidationError
+        This should *never* happen, except in a pathological circumstance where
+        unix2dos was specifically run on a compressed inventory.
 
-    inv = soi.Inventory(res_dec)
-    d = inv.json_dict()
-    d.update({"bad_foo": "angry_bar"})
+        """
+        b_cmp = soi.readbytes(res_cmp)
 
-    with pytest.raises(ValidationError):
-        soi.Inventory(dict_json=d)
+        with pytest.raises(soi.VersionError):
+            soi.decompress(unix2dos(b_cmp))
 
+    @pytest.mark.parametrize("bad_arg", DISALLOWED_INV_INIT_ARGS)
+    def test_apifail_invalid_inventory_init_arg(self, bad_arg):
+        """Confirm non-__init__ Inventory members raise exceptions when passed."""
+        with pytest.raises(TypeError):
+            soi.Inventory(**{bad_arg: "foo"})
 
-def test_apifail_inventory_dictimport_toomanysrcargs():
-    """Confirm error if >1 sources passed."""
-    with pytest.raises(RuntimeError):
-        soi.Inventory(source="foo", plaintext="bar")
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_inventory_dictimport_baddataobjmember(
+        self, res_cmp, jsonschema_validator, path_fxn
+    ):
+        """Confirm inventory load failure on spurious key in a data object."""
+        inv = soi.Inventory(path_fxn(res_cmp))
+        d = inv.json_dict()
+        d["0"].update({"foo": "bar"})
 
+        with pytest.raises(ValidationError):
+            soi.Inventory(dict_json=d)
 
-def test_apifail_inventory_no_object_invs(subtests):
-    """Confirm no-objects inventories don't import."""
-    inv = soi.Inventory()
+        assert not jsonschema_validator(soi.json_schema).is_valid(d)
 
-    with subtests.test(msg="plain"):
-        with pytest.raises(TypeError):
-            soi.Inventory(inv.data_file())
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_apifail_inventory_dictimport_missingdataobjmember(
+        self, res_cmp, jsonschema_validator, path_fxn
+    ):
+        """Confirm inventory load failure on missing key in data object."""
+        inv = soi.Inventory(path_fxn(res_cmp))
+        d = inv.json_dict()
+        d["0"].pop("domain")
 
-    with subtests.test(msg="zlib"):
-        with pytest.raises((TypeError, ValueError)):
-            soi.Inventory(soi.compress(inv.data_file()))
+        with pytest.raises(ValidationError):
+            soi.Inventory(dict_json=d)
 
-    d = {"project": "test", "version": "0.0", "count": 0}
-    with subtests.test(msg="json"):
-        with pytest.raises(ValueError):
-            soi.Inventory(d)
+        assert not jsonschema_validator(soi.json_schema).is_valid(d)
+
+
+@pytest.mark.xfail(reason="Made mutable to simplify Inventory revision by users")
+class TestImmutable:
+    """Tests for (now obsolete) DataObj immutability."""
+
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    @pytest.mark.parametrize("use_bytes", [True, False])
+    def test_apifail_changing_immutable_dataobj(self, path_fxn, use_bytes, res_cmp):
+        """Confirm DataObj's are immutable."""
+        inv = soi.Inventory(path_fxn(res_cmp))
+
+        with pytest.raises(FrozenInstanceError):  # pragma: no cover (XFAIL)
+            if use_bytes:
+                inv.objects[0].as_bytes.name = b"newname"
+            else:
+                inv.objects[0].name = "newname"
diff --git a/tests/test_api_good.py b/tests/test_api_good.py
index 80efefbb..0c97ecdb 100644
--- a/tests/test_api_good.py
+++ b/tests/test_api_good.py
@@ -10,7 +10,7 @@
     20 Mar 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -25,9 +25,13 @@
 
 """
 
+import copy
 import itertools as itt
 import re
+import warnings
+from numbers import Number
 
+import dictdiffer
 import pytest
 
 import sphobjinv as soi
@@ -36,459 +40,582 @@
 pytestmark = [pytest.mark.api, pytest.mark.local]
 
 
-@pytest.mark.parametrize(
-    ["actual", "expect"],
-    tuple(
-        itt.zip_longest(
-            soi.SourceTypes,  # actual
-            [  # expect
-                soi.SourceTypes.Manual,
-                soi.SourceTypes.BytesPlaintext,
-                soi.SourceTypes.BytesZlib,
-                soi.SourceTypes.FnamePlaintext,
-                soi.SourceTypes.FnameZlib,
-                soi.SourceTypes.DictJSON,
-                soi.SourceTypes.URL,
-            ],
-            fillvalue=None,
-        )
-    ),
-    ids=(lambda a: a.value if a else a),
-)
-def test_source_types_iteration(actual, expect):
-    """Confirm that SourceTypes iterates in the expected order."""
-    assert actual.value == expect.value
-
-
-def test_api_compress(scratch_path, misc_info, sphinx_load_test):
-    """Check that a compress attempt via API throws no errors."""
-    src_path = scratch_path / (
-        misc_info.FNames.INIT.value + misc_info.Extensions.DEC.value
-    )
-    dest_path = scratch_path / (
-        misc_info.FNames.MOD.value + misc_info.Extensions.CMP.value
+def no_op(val):
+    """No-op function to leave Path objects alone in tests."""
+    return val
+
+
+PATH_FXNS = (no_op, str)
+PATH_FXN_IDS = ("no_op", "str")
+
+
+class TestCore:
+    """Tests of core sphobjinv functionality."""
+
+    @pytest.mark.parametrize(
+        ["actual", "expect"],
+        tuple(
+            itt.zip_longest(
+                soi.SourceTypes,  # actual
+                [  # expect
+                    soi.SourceTypes.Manual,
+                    soi.SourceTypes.BytesPlaintext,
+                    soi.SourceTypes.BytesZlib,
+                    soi.SourceTypes.FnamePlaintext,
+                    soi.SourceTypes.FnameZlib,
+                    soi.SourceTypes.DictJSON,
+                    soi.SourceTypes.URL,
+                ],
+                fillvalue=None,
+            )
+        ),
+        ids=(lambda a: a.value if a else a),
     )
+    def test_source_types_iteration(self, actual, expect):
+        """Confirm that SourceTypes iterates in the expected order."""
+        assert actual == expect
 
-    try:
-        b_dec = soi.readbytes(str(src_path))
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_api_compress(self, path_fxn, scratch_path, misc_info, sphinx_load_test):
+        """Check that a compress attempt via API throws no errors."""
+        src_path = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.DEC)
+        dest_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.CMP)
+
+        b_dec = soi.readbytes(path_fxn(src_path))
         b_cmp = soi.compress(b_dec)
-        soi.writebytes(str(dest_path), b_cmp)
-    except Exception:
-        pytest.fail("objects.txt compression failed.")
+        soi.writebytes(path_fxn(dest_path), b_cmp)
 
-    assert dest_path.is_file()
+        assert dest_path.is_file()
 
-    sphinx_load_test(dest_path)
+        sphinx_load_test(dest_path)
 
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_api_decompress(self, path_fxn, scratch_path, misc_info, decomp_cmp_test):
+        """Check that a decompress attempt via API throws no errors."""
+        src_path = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.CMP)
+        dest_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.DEC)
 
-def test_api_decompress(scratch_path, misc_info, decomp_cmp_test):
-    """Check that a decompress attempt via API throws no errors."""
-    src_path = scratch_path / (
-        misc_info.FNames.INIT.value + misc_info.Extensions.CMP.value
-    )
-    dest_path = scratch_path / (
-        misc_info.FNames.MOD.value + misc_info.Extensions.DEC.value
+        b_cmp = soi.readbytes(path_fxn(src_path))
+        b_dec = soi.decompress(b_cmp)
+        soi.writebytes(path_fxn(dest_path), b_dec)
+
+        assert dest_path.is_file()
+
+        decomp_cmp_test(dest_path)
+
+    @pytest.mark.parametrize(
+        ["element", "datadict"],
+        (
+            [
+                0,
+                {  # attr.Attribute py:class 1 api.html#$ -
+                    soi.DataFields.Name: b"attr.Attribute",
+                    soi.DataFields.Domain: b"py",
+                    soi.DataFields.Role: b"class",
+                    soi.DataFields.Priority: b"1",
+                    soi.DataFields.URI: b"api.html#$",
+                    soi.DataFields.DispName: b"-",
+                },
+            ],
+            [
+                -3,
+                {  # slots std:label -1 examples.html#$ Slots
+                    soi.DataFields.Name: b"slots",
+                    soi.DataFields.Domain: b"std",
+                    soi.DataFields.Role: b"label",
+                    soi.DataFields.Priority: b"-1",
+                    soi.DataFields.URI: b"examples.html#$",
+                    soi.DataFields.DispName: b"Slots",
+                },
+            ],
+        ),
     )
+    def test_api_data_regex(self, element, datadict, bytes_txt, misc_info):
+        """Confirm the regex for loading data lines is working properly."""
+        # Prelim approximate check to be sure we're working with the
+        # correct file/data.
+        assert len(soi.re.pb_data.findall(bytes_txt)) == 56
 
-    try:
-        b_cmp = soi.readbytes(str(src_path))
-        b_dec = soi.decompress(b_cmp)
-        soi.writebytes(str(dest_path), b_dec)
-    except Exception:
-        pytest.fail("objects.inv decompression failed.")
+        mchs = list(soi.re.pb_data.finditer(bytes_txt))
 
-    assert dest_path.is_file()
+        assert mchs[element].groupdict() == {_.value: datadict[_] for _ in datadict}
 
-    decomp_cmp_test(dest_path)
+    def test_api_compress_win_eols(self, unix2dos, res_dec):
+        """Confirm the scrub for Windows EOLs is working.
 
+        Only a relevant passing test when starting with a decompressed inventory.
 
-@pytest.mark.parametrize(
-    ["element", "datadict"],
-    (
-        [
-            0,
-            {  # attr.Attribute py:class 1 api.html#$ -
-                soi.DataFields.Name: b"attr.Attribute",
-                soi.DataFields.Domain: b"py",
-                soi.DataFields.Role: b"class",
-                soi.DataFields.Priority: b"1",
-                soi.DataFields.URI: b"api.html#$",
-                soi.DataFields.DispName: b"-",
-            },
-        ],
-        [
-            -3,
-            {  # slots std:label -1 examples.html#$ Slots
-                soi.DataFields.Name: b"slots",
-                soi.DataFields.Domain: b"std",
-                soi.DataFields.Role: b"label",
-                soi.DataFields.Priority: b"-1",
-                soi.DataFields.URI: b"examples.html#$",
-                soi.DataFields.DispName: b"Slots",
-            },
-        ],
-    ),
-)
-def test_api_data_regex(element, datadict, bytes_txt, misc_info):
-    """Confirm the regex for loading data lines is working properly."""
-    import sphobjinv as soi
+        Written based on a surviving mutmut mutant munging the Windows EOL scrub in
+        zlib.compress.
+
+        The repeated application of unix2dos tests that the substitution ignores
+        existing Windows EOLs in the string.
+
+        """
+        b_dec = unix2dos(unix2dos(soi.readbytes(res_dec)))
+        b_cmp = soi.compress(b_dec)
+
+        b_dec_new = soi.decompress(b_cmp)
 
-    # Prelim approximate check to be sure we're working with the
-    # correct file/data.
-    assert len(soi.re.pb_data.findall(bytes_txt)) == 56
+        assert rb"\r\n" not in b_dec_new
 
-    mchs = list(soi.re.pb_data.finditer(bytes_txt))
+    def test_flatdict_schema_valid(self, jsonschema_validator):
+        """Confirm that the Inventory JSON schema is itself a valid schema."""
+        meta_schema = copy.deepcopy(jsonschema_validator({}).META_SCHEMA)
 
-    assert mchs[element].groupdict() == {_.value: datadict[_] for _ in datadict}
+        # Forbid unrecognized keys
+        meta_schema.update({"additionalProperties": False})
 
+        assert jsonschema_validator(meta_schema).is_valid(soi.json_schema)
 
-@pytest.mark.xfail(
-    reason="Will fail until .as_xxx properties are removed from attrs cmp"
-)
-def test_api_dataobjbytes_init(bytes_txt):
-    """Confirm the DataObjBytes type functions correctly."""
-    mch = soi.pb_data.search(bytes_txt)
-    b_mchdict = {_: mch.group(_) for _ in mch.groupdict()}
-    s_mchdict = {_: b_mchdict[_].decode(encoding="utf-8") for _ in b_mchdict}
 
-    try:
+class TestDataObj:
+    """Tests of the DataObj classes."""
+
+    def test_api_dataobjbytes_init(self, bytes_txt):
+        """Confirm the DataObjBytes type functions correctly."""
+        mch = soi.pb_data.search(bytes_txt)
+        b_mchdict = {_: mch.group(_) for _ in mch.groupdict()}
+        s_mchdict = {_: b_mchdict[_].decode(encoding="utf-8") for _ in b_mchdict}
+
         b_dob = soi.DataObjBytes(**b_mchdict)
-    except Exception:
-        pytest.fail("bytes instantiation failed")
 
-    try:
         s_dob = soi.DataObjBytes(**s_mchdict)
-    except Exception:
-        pytest.fail("str instantiation failed")
 
-    assert b_dob == s_dob
-
-    assert all(
-        getattr(b_dob, _) == getattr(b_dob.as_str, _).encode("utf-8") for _ in b_mchdict
-    )
+        assert b_dob == s_dob
 
+        assert all(
+            getattr(b_dob, _) == getattr(b_dob.as_str, _).encode("utf-8")
+            for _ in b_mchdict
+        )
 
-@pytest.mark.xfail(
-    reason="Will fail until .as_xxx properties are removed from attrs cmp"
-)
-def test_api_dataobjstr_init(bytes_txt):
-    """Confirm the DataObjStr type functions correctly."""
-    mch = soi.pb_data.search(bytes_txt)
-    b_mchdict = {_: mch.group(_) for _ in mch.groupdict()}
-    s_mchdict = {_: b_mchdict[_].decode(encoding="utf-8") for _ in b_mchdict}
+    def test_api_dataobjstr_init(self, bytes_txt):
+        """Confirm the DataObjStr type functions correctly."""
+        mch = soi.pb_data.search(bytes_txt)
+        b_mchdict = {_: mch.group(_) for _ in mch.groupdict()}
+        s_mchdict = {_: b_mchdict[_].decode(encoding="utf-8") for _ in b_mchdict}
 
-    try:
         b_dos = soi.DataObjStr(**b_mchdict)
-    except Exception:
-        pytest.fail("bytes instantiation failed")
 
-    try:
         s_dos = soi.DataObjStr(**s_mchdict)
-    except Exception:
-        pytest.fail("str instantiation failed")
 
-    assert b_dos == s_dos
+        assert b_dos == s_dos
 
-    assert all(
-        getattr(s_dos, _) == getattr(b_dos.as_bytes, _).decode("utf-8")
-        for _ in s_mchdict
-    )
+        assert all(
+            getattr(s_dos, _) == getattr(b_dos.as_bytes, _).decode("utf-8")
+            for _ in s_mchdict
+        )
 
+    def test_api_dataobjbytes_flatdictfxn(self, bytes_txt):
+        """Confirm that flat dict generating function works."""
+        mch = soi.pb_data.search(bytes_txt)
 
-def test_api_dataobjbytes_flatdictfxn(bytes_txt):
-    """Confirm that flat dict generating function works."""
-    mch = soi.pb_data.search(bytes_txt)
+        b_mchdict = {_: mch.group(_) for _ in mch.groupdict()}
+        b_jsondict = soi.DataObjBytes(**b_mchdict).json_dict()
 
-    b_mchdict = {_: mch.group(_) for _ in mch.groupdict()}
-    b_jsondict = soi.DataObjBytes(**b_mchdict).json_dict()
+        assert b_mchdict == b_jsondict
 
-    assert b_mchdict == b_jsondict
+    def test_api_dataobjstr_flatdictfxn(self, bytes_txt):
+        """Confirm that flat dict generating function works."""
+        mch = soi.pb_data.search(bytes_txt)
 
+        b_mchdict = {_: mch.group(_) for _ in mch.groupdict()}
+        s_mchdict = {_: b_mchdict[_].decode("utf-8") for _ in b_mchdict}
+        s_jsondict = soi.DataObjStr(**b_mchdict).json_dict()
 
-def test_api_dataobjstr_flatdictfxn(bytes_txt):
-    """Confirm that flat dict generating function works."""
-    mch = soi.pb_data.search(bytes_txt)
+        assert s_mchdict == s_jsondict
 
-    b_mchdict = {_: mch.group(_) for _ in mch.groupdict()}
-    s_mchdict = {_: b_mchdict[_].decode("utf-8") for _ in b_mchdict}
-    s_jsondict = soi.DataObjStr(**b_mchdict).json_dict()
+    @pytest.mark.parametrize(
+        ["dataobjtype", "regex", "lines"],
+        (
+            [soi.DataObjBytes, soi.pb_data, "byte_lines"],
+            [soi.DataObjStr, soi.p_data, "str_lines"],
+        ),
+        ids=(lambda i: i if isinstance(i, str) else ""),
+    )
+    @pytest.mark.parametrize("dataline_arg", (True, False))
+    @pytest.mark.parametrize("init_expanded", (True, False))
+    def test_api_dataobj_datalinefxn(
+        self,
+        dataobjtype,
+        regex,
+        lines,
+        init_expanded,
+        dataline_arg,
+        misc_info,
+        check,
+    ):
+        """Confirm that data line formatting function works.
+
+        Test both str and bytes versions of the DataObj.
+
+        Also provides further testing of flat_dict.
+
+        """
+        lines_obj = getattr(misc_info, lines)
+
+        dobj = dataobjtype(**regex.search(lines_obj[init_expanded]).groupdict())
+
+        # If dataline_arg is False, should match the value of init_expanded.
+        # If dataline_arg is True, should match the True (expanded) value.
+        # Thus, the only False (contracted) situation is with both values False.
+        dl = dobj.data_line(expand=dataline_arg)
+        check.equal(dl, lines_obj[dataline_arg or init_expanded])
 
-    assert s_mchdict == s_jsondict
+        # If dataline_arg is False, should match the value of init_expanded.
+        # If dataline_arg is True, should match the False (contracted) value.
+        # Thus, the only True (expanded) situation is when init_expanded == True
+        # and and dataline_arg == False.
+        dl = dobj.data_line(contract=dataline_arg)
+        check.equal(dl, lines_obj[init_expanded and not dataline_arg])
 
+    @pytest.mark.parametrize(
+        "use_bytes", (True, False), ids=(lambda b: "use_bytes_" + str(b))
+    )
+    def test_api_dataobj_evolvename(self, use_bytes, res_cmp):
+        """Confirm evolving new DataObj instances works properly."""
+        inv = soi.Inventory(res_cmp)
+        obj = (
+            inv.objects[5].as_bytes if use_bytes else inv.objects[5]
+        )  # Arbitrary choice
+        oldname = obj.name
 
-@pytest.mark.parametrize(
-    ["dataobjtype", "regex", "lines"],
-    (
-        [soi.DataObjBytes, soi.pb_data, "byte_lines"],
-        [soi.DataObjStr, soi.p_data, "str_lines"],
-    ),
-    ids=(lambda i: i if type(i) == str else ""),
-)
-@pytest.mark.parametrize("dataline_arg", (True, False))
-@pytest.mark.parametrize("init_expanded", (True, False))
-def test_api_dataobj_datalinefxn(
-    dataobjtype, regex, lines, init_expanded, dataline_arg, misc_info, subtests
-):
-    """Confirm that data line formatting function works.
+        newname = b"foo" if use_bytes else "foo"
+        obj2 = obj.evolve(name=newname)
+        obj3 = obj2.evolve(name=oldname)
 
-    Test both str and bytes versions of the DataObj.
+        assert obj == obj3
+        assert obj2.name == newname
 
-    Also provides further testing of flat_dict.
+    def test_api_dataobj_equality(self, res_cmp):
+        """Confirm various aspects of DataObj equality behavior."""
+        inv = soi.Inventory(res_cmp)
 
-    """
-    lines_obj = getattr(misc_info, lines)
+        obj1 = inv.objects[0]
+        obj2 = inv.objects[1]
+        obj3 = obj1.evolve()
+        obj4 = obj3.evolve(name="foobar")
 
-    dobj = dataobjtype(**regex.search(lines_obj[init_expanded]).groupdict())
+        assert obj1 is obj1
+        assert obj1 is not obj2
+        assert obj1 is not obj3
+        assert obj1 is not obj4
 
-    # If dataline_arg is False, should match the value of init_expanded.
-    # If dataline_arg is True, should match the True (expanded) value.
-    # Thus, the only False (contracted) situation is with both values False.
-    with subtests.test(msg="expand"):
-        dl = dobj.data_line(expand=dataline_arg)
-        assert dl == lines_obj[dataline_arg or init_expanded]
+        assert obj1 is not obj1.as_bytes
+        assert obj1 is obj1.as_bytes.as_str
 
-    # If dataline_arg is False, should match the value of init_expanded.
-    # If dataline_arg is True, should match the False (contracted) value.
-    # Thus, the only True (expanded) situation is when init_expanded == True
-    # and and dataline_arg == False.
-    with subtests.test(msg="contract"):
-        dl = dobj.data_line(contract=dataline_arg)
-        assert dl == lines_obj[init_expanded and not dataline_arg]
-
-
-@pytest.mark.xfail(
-    reason="Will fail until .as_xxx properties are removed from attrs cmp"
-)
-@pytest.mark.parametrize(
-    "use_bytes", (True, False), ids=(lambda b: "use_bytes_" + str(b))
-)
-def test_api_dataobj_evolvename(use_bytes, res_cmp):
-    """Confirm evolving new DataObj instances works properly."""
-    inv = soi.Inventory(res_cmp)
-    obj = inv.objects[5].as_bytes if use_bytes else inv.objects[5]  # Arbitrary choice
-    oldname = obj.name
-
-    newname = b"foo" if use_bytes else "foo"
-    obj2 = obj.evolve(name=newname)
-    obj3 = obj2.evolve(name=oldname)
-
-    assert obj == obj3
-    assert obj2.name == newname
-
-
-def test_api_inventory_default_none_instantiation(subtests):
-    """Confirm 'manual' instantiation with None."""
-    inv = soi.Inventory()
-
-    with subtests.test(msg="project"):
-        assert inv.project is None
-
-    with subtests.test(msg="version"):
-        assert inv.version is None
-
-    with subtests.test(msg="count"):
-        assert inv.count == 0
-
-    with subtests.test(msg="source_type"):
-        assert inv.source_type is soi.SourceTypes.Manual
-
-
-@pytest.mark.parametrize(
-    ["source_type", "inv_arg"],
-    [
-        (soi.SourceTypes.BytesPlaintext, "plaintext"),
-        (soi.SourceTypes.BytesZlib, "zlib"),
-        (soi.SourceTypes.FnamePlaintext, "fname_plain"),
-        (soi.SourceTypes.FnameZlib, "fname_zlib"),
-    ],
-    ids=(lambda v: v if type(v) == str else ""),
-)
-def test_api_inventory_bytes_fname_instantiation(
-    source_type, inv_arg, res_path, misc_info, attrs_inventory_test, subtests
-):
-    """Check bytes and filename modes for Inventory instantiation."""
-    source = str(res_path / misc_info.FNames.RES.value)
-
-    if source_type in (soi.SourceTypes.BytesPlaintext, soi.SourceTypes.FnamePlaintext):
-        source += misc_info.Extensions.DEC.value
-    else:
-        source += misc_info.Extensions.CMP.value
-
-    if source_type in (soi.SourceTypes.BytesPlaintext, soi.SourceTypes.BytesZlib):
-        source = soi.readbytes(source)
-
-    # General import, without a specified kwarg
-    with subtests.test(msg="general"):
-        attrs_inventory_test(soi.Inventory(source), source_type)
-
-    # Importing with the respective kwarg for each source type
-    with subtests.test(msg="specific"):
-        inv = soi.Inventory(**{inv_arg: source})
-        attrs_inventory_test(inv, source_type)
-
-    # Special case for plaintext bytes, try decoding it
-    if source_type is soi.SourceTypes.BytesPlaintext:
-        with subtests.test(msg="plaintext_bytes"):
-            inv = soi.Inventory(**{inv_arg: source.decode("utf-8")})
-            attrs_inventory_test(inv, source_type)
+        assert obj1 == obj1
+        assert obj1 != obj2
+        assert obj1 == obj3
+        assert obj1 != obj4
 
+        assert obj1 != obj1.as_bytes
 
-@pytest.mark.parametrize("prop", ("none", "expand", "contract"))
-def test_api_inventory_flatdict_jsonvalidate(prop, res_cmp):
-    """Confirm that the flat_dict properties generated valid JSON."""
-    import jsonschema
 
-    inv = soi.Inventory(res_cmp)
-    val = jsonschema.Draft4Validator(soi.json_schema)
+class TestInventory:
+    """Tests of the Inventory class."""
 
-    kwarg = {} if prop == "none" else {prop: True}
-    try:
-        val.validate(inv.json_dict(**kwarg))
-    except jsonschema.ValidationError:
-        pytest.fail("'{}' JSON invalid".format(prop))
+    def test_api_inventory_default_none_instantiation(self, check):
+        """Confirm 'manual' instantiation with None."""
+        inv = soi.Inventory()
 
+        check.is_none(inv.project)
+        check.is_none(inv.version)
+        check.equal(inv.count, 0)
+        check.is_(inv.source_type, soi.SourceTypes.Manual)
 
-def test_api_inventory_flatdict_reimport(res_dec, attrs_inventory_test):
-    """Confirm re-import of a generated flat_dict."""
-    inv = soi.Inventory(res_dec)
-    inv = soi.Inventory(inv.json_dict())
+    @pytest.mark.parametrize(
+        ["source_type", "inv_arg"],
+        [
+            (soi.SourceTypes.BytesPlaintext, "plaintext"),
+            (soi.SourceTypes.BytesZlib, "zlib"),
+            (soi.SourceTypes.FnamePlaintext, "fname_plain"),
+            (soi.SourceTypes.FnameZlib, "fname_zlib"),
+        ],
+        ids=(lambda v: v if isinstance(v, str) else ""),
+    )
+    @pytest.mark.parametrize("path_fxn", PATH_FXNS, ids=PATH_FXN_IDS)
+    def test_api_inventory_bytes_fname_instantiation(
+        self,
+        source_type,
+        inv_arg,
+        path_fxn,
+        res_path,
+        misc_info,
+        attrs_inventory_test,
+        check,
+    ):
+        """Check bytes and filename modes for Inventory instantiation."""
+        fname = misc_info.FNames.RES
+
+        if source_type in (
+            soi.SourceTypes.BytesPlaintext,
+            soi.SourceTypes.FnamePlaintext,
+        ):
+            fname += misc_info.Extensions.DEC
+        else:
+            fname += misc_info.Extensions.CMP
+
+        source = path_fxn(res_path / fname)
+
+        if source_type in (soi.SourceTypes.BytesPlaintext, soi.SourceTypes.BytesZlib):
+            # Passing in the actual inventory contents, and not just the location
+            source = soi.readbytes(source)
+
+        # General import, without a specified kwarg
+        with check.check(msg="general"):
+            attrs_inventory_test(soi.Inventory(source), source_type)
+
+        # Importing with the respective kwarg for each source type
+        with check.check(msg="specific"):
+            inv = soi.Inventory(**{inv_arg: source})
+            attrs_inventory_test(inv, source_type)
 
-    attrs_inventory_test(inv, soi.SourceTypes.DictJSON)
+        # Special case for plaintext bytes, try decoding it
+        if source_type is soi.SourceTypes.BytesPlaintext:
+            with check.check(msg="plaintext_bytes"):
+                inv = soi.Inventory(**{inv_arg: source.decode("utf-8")})
+                attrs_inventory_test(inv, source_type)
+
+    def test_api_inventory_equality(self, res_cmp):
+        """Confirm the attrs Inventory equality methods work as expected."""
+        inv1 = soi.Inventory(res_cmp)
+        inv2 = soi.Inventory(res_cmp)
+        inv3 = soi.Inventory(inv1.data_file())
+        inv4 = soi.Inventory(res_cmp)
+
+        assert inv1 is inv1
+        assert inv1 is not inv2
+        assert inv1 is not inv3
+
+        assert inv1 == inv1
+        assert inv1 == inv2
+        assert inv1 == inv3
+
+        inv2.objects[0].name = "foobar"
+        inv3.project = "quux"
+        inv4.version = "0.0"
+
+        assert inv1 != inv2
+        assert inv1 != inv3
+        assert inv1 != inv4
+
+    @pytest.mark.parametrize("prop", ("none", "expand", "contract"))
+    def test_api_inventory_flatdict_jsonvalidate(
+        self, prop, res_cmp, jsonschema_validator
+    ):
+        """Confirm that the flat_dict properties generated valid JSON."""
+        inv = soi.Inventory(res_cmp)
+        val = jsonschema_validator(soi.json_schema)
+
+        kwarg = {} if prop == "none" else {prop: True}
 
+        val.validate(inv.json_dict(**kwarg))
 
-@pytest.mark.parametrize(
-    "metadata",
-    ["test string", {"this": "foo", "that": "bar"}, 42],
-    ids=(lambda v: re.search("'([^']+)'", str(type(v))).group(1)),
-)
-def test_api_inventory_flatdict_reimportwithmetadata(
-    metadata, res_dec, attrs_inventory_test
-):
-    """Confirm re-import of a generated flat_dict with metadata."""
-    inv = soi.Inventory(res_dec)
-    d = inv.json_dict()
+    def test_api_inventory_flatdict_reimport(self, res_dec, attrs_inventory_test):
+        """Confirm re-import of a generated flat_dict."""
+        inv = soi.Inventory(res_dec)
+        inv = soi.Inventory(inv.json_dict())
 
-    d.update({"metadata": metadata})
-    try:
-        inv = soi.Inventory(d)
-    except Exception:
-        pytest.fail("Instantiation fail on metadata '{}'".format(metadata))
+        attrs_inventory_test(inv, soi.SourceTypes.DictJSON)
 
-    attrs_inventory_test(inv, soi.SourceTypes.DictJSON)
+    @pytest.mark.parametrize(
+        "metadata",
+        ["test string", {"this": "foo", "that": "bar"}, 42],
+        ids=(lambda v: re.search("'([^']+)'", str(type(v))).group(1)),
+    )
+    def test_api_inventory_flatdict_reimportwithmetadata(
+        self, metadata, res_dec, attrs_inventory_test
+    ):
+        """Confirm re-import of a generated flat_dict with metadata."""
+        inv = soi.Inventory(res_dec)
+        d = inv.json_dict()
 
+        d.update({"metadata": metadata})
 
-def test_api_inventory_toosmallflatdict_importbutignore(res_dec):
-    """Confirm no error when flat dict passed w/too few objs w/ignore."""
-    inv = soi.Inventory(res_dec)
-    d = inv.json_dict()
-    d.pop("12")
+        inv = soi.Inventory(d)
 
-    inv2 = soi.Inventory(d, count_error=False)
+        attrs_inventory_test(inv, soi.SourceTypes.DictJSON)
 
-    # 55 b/c the loop continues past missing elements
-    assert inv2.count == 55
+    def test_api_inventory_toosmallflatdict_importbutignore(self, res_dec):
+        """Confirm no error when flat dict passed w/too few objs w/ignore."""
+        inv = soi.Inventory(res_dec)
+        d = inv.json_dict()
+        d.pop("12")
 
+        inv2 = soi.Inventory(d, count_error=False)
 
-def test_api_inventory_namesuggest(res_cmp, subtests):
-    """Confirm object name suggestion is nominally working."""
-    from numbers import Number
+        # 55 b/c the loop continues past missing elements
+        assert inv2.count == 55
 
-    rst = ":py:function:`attr.evolve`"
-    idx = 6
+    def test_api_inventory_namesuggest(self, res_cmp, check):
+        """Confirm object name suggestion is nominally working."""
+        rst = ":py:function:`attr.evolve`"
+        idx = 6
 
-    inv = soi.Inventory(str(res_cmp))
+        inv = soi.Inventory(str(res_cmp))
 
-    # No test on the exact fuzzywuzzy match score in these since
-    # it could change as fw continues development
-    with subtests.test(msg="basic"):
-        assert inv.suggest("evolve")[0] == rst
+        # No test on the exact fuzzywuzzy match score in these since
+        # it could change as fw continues development
+        check.equal(inv.suggest("evolve")[0], rst)
 
-    with subtests.test(msg="index"):
-        assert inv.suggest("evolve", with_index=True)[0] == (rst, idx)
+        check.equal(inv.suggest("evolve", with_index=True)[0], (rst, idx))
 
-    with subtests.test(msg="score"):
         rec = inv.suggest("evolve", with_score=True)
-        assert rec[0][0] == rst
-        assert isinstance(rec[0][1], Number)
+        check.equal(rec[0][0], rst)
+        check.is_instance(rec[0][1], Number)
 
-    with subtests.test(msg="index_and_score"):
         rec = inv.suggest("evolve", with_index=True, with_score=True)
-        assert rec[0][0] == rst
-        assert isinstance(rec[0][1], Number)
-        assert rec[0][2] == idx
-
-
-# Must be run first, otherwise the fuzzywuzzy warning is consumed
-# inappropriately
-@pytest.mark.first
-def test_api_fuzzywuzzy_warningcheck():
-    """Confirm only the Levenshtein warning is raised, if any are."""
-    import warnings
-
-    with warnings.catch_warnings(record=True) as wc:
-        warnings.simplefilter("always")
-        from fuzzywuzzy import process  # noqa: F401
-
-    # Try to import, and adjust tests accordingly
-    try:
-        import Levenshtein  # noqa: F401
-    except ImportError:
-        lev_present = False
-    else:
-        lev_present = True
-
-    if lev_present:
-        assert len(wc) == 0, "Warning unexpectedly raised"  # pragma: no cover
-    else:
-        assert len(wc) == 1, "Warning unexpectedly not raised"
-
-        # 'message' will be a Warning instance, thus 'args[0]'
-        # to retrieve the warning message as str.
-        assert (
-            "levenshtein" in wc[0].message.args[0].lower()
-        ), "Warning raised for unexpected reason"
-
-
-@pytest.mark.testall
-def test_api_inventory_datafile_gen_and_reimport(
-    testall_inv_path,
-    res_path,
-    scratch_path,
-    misc_info,
-    sphinx_load_test,
-    pytestconfig,
-    subtests,
-):
-    """Confirm integrated data_file export/import behavior."""
-    fname = testall_inv_path.name
-    scr_fpath = scratch_path / fname
-
-    # Drop most unless testall
-    if not pytestconfig.getoption("--testall") and fname != "objects_attrs.inv":
-        pytest.skip("'--testall' not specified")
-
-    # Make Inventory
-    inv1 = soi.Inventory(str(res_path / fname))
-
-    # Generate new zlib file and reimport
-    data = inv1.data_file()
-    cmp_data = soi.compress(data)
-    soi.writebytes(str(scr_fpath), cmp_data)
-    inv2 = soi.Inventory(str(scr_fpath))
-
-    # Test the things
-    with subtests.test(msg="content"):
-        assert inv1.project == inv2.project
-        assert inv1.version == inv2.version
-        assert inv1.count == inv2.count
-        for objs in zip(inv1.objects, inv2.objects):
-            assert objs[0].name == objs[1].name
-            assert objs[0].domain == objs[1].domain
-            assert objs[0].role == objs[1].role
-            assert objs[0].uri == objs[1].uri
-            assert objs[0].priority == objs[1].priority
-            assert objs[0].dispname == objs[1].dispname
-
-    # Ensure sphinx likes the regenerated inventory
-    with subtests.test(msg="sphinx_load"):
+        check.equal(rec[0][0], rst)
+        check.is_instance(rec[0][1], Number)
+        check.equal(rec[0][2], idx)
+
+    @pytest.mark.testall
+    def test_api_inventory_datafile_gen_and_reimport(
+        self,
+        testall_inv_path,
+        res_path,
+        scratch_path,
+        misc_info,
+        sphinx_load_test,
+        pytestconfig,
+    ):
+        """Confirm integrated data_file export/import behavior."""
+        fname = testall_inv_path.name
+        scr_fpath = scratch_path / fname
+
+        # Drop most unless testall
+        if not pytestconfig.getoption("--testall") and fname != "objects_attrs.inv":
+            pytest.skip("'--testall' not specified")
+
+        # Make Inventory
+        inv1 = soi.Inventory(str(res_path / fname))
+
+        # Generate new zlib file and reimport
+        data = inv1.data_file()
+        cmp_data = soi.compress(data)
+        soi.writebytes(str(scr_fpath), cmp_data)
+        inv2 = soi.Inventory(str(scr_fpath))
+
+        # Test the things
+        assert inv1 == inv2
+
+        # Ensure sphinx likes the regenerated inventory
         sphinx_load_test(scr_fpath)
+
+    @pytest.mark.testall
+    def test_api_inventory_matches_sphinx_ifile(
+        self,
+        testall_inv_path,
+        scratch_path,
+        misc_info,
+        pytestconfig,
+        sphinx_ifile_load,
+        sphinx_ifile_data_count,
+        sphinx_version,
+    ):
+        """Confirm no-op per Sphinx on passing through sphobjinv.Inventory."""
+        fname = testall_inv_path.name
+        scr_fpath = scratch_path / fname
+
+        # Drop most unless testall
+        if not pytestconfig.getoption("--testall") and fname != "objects_attrs.inv":
+            pytest.skip("'--testall' not specified")
+
+        original_ifile_data = sphinx_ifile_load(testall_inv_path)
+
+        inv = soi.Inventory(testall_inv_path)
+        soi.writebytes(scr_fpath, soi.compress(inv.data_file()))
+        soi_ifile_data = sphinx_ifile_load(scr_fpath)
+
+        assert not list(dictdiffer.diff(soi_ifile_data, original_ifile_data)), fname
+
+        if "celery" in fname:  # pragma: no cover
+            # Celery inventory contains some exact domain:role:name duplicates
+            assert inv.count == 54 + sphinx_ifile_data_count(original_ifile_data), fname
+
+        elif "opencv" in fname:  # pragma: no cover
+            # OpenCV inventory contains some lines that
+            # parse incorrectly after sphinx/#8225, which was first
+            # incorporated into Sphinx 3.3.0
+            if sphinx_version < (3, 3, 0):
+                assert inv.count == sphinx_ifile_data_count(original_ifile_data), fname
+            else:
+                assert inv.count == 13 + sphinx_ifile_data_count(
+                    original_ifile_data
+                ), fname
+
+        elif "jsonschema" in fname:  # pragma: no cover
+            # The version of the jsonschema inventory held in tests/resource
+            # has an item with an empty uri. Sphinx<2.4 does not import this line
+            # correctly.
+            if sphinx_version < (2, 4, 0):
+                assert inv.count == 1 + sphinx_ifile_data_count(
+                    original_ifile_data
+                ), fname
+            else:
+                assert inv.count == sphinx_ifile_data_count(original_ifile_data), fname
+
+        else:
+            assert inv.count == sphinx_ifile_data_count(original_ifile_data), fname
+
+    def test_api_inventory_one_object_flatdict(self):
+        """Confirm a flat dict inventory with one object imports ok.
+
+        Addresses edge case identified via mutation testing.
+
+        """
+        inv = soi.Inventory()
+        inv.project = "Foo"
+        inv.version = "1.2"
+        inv.objects.append(
+            soi.DataObjStr(
+                name="bar",
+                domain="py",
+                role="function",
+                priority="1",
+                uri="$",
+                dispname="-",
+            )
+        )
+
+        # Should not raise an exception; assert is to emphasize this is the check
+        assert soi.Inventory(inv.json_dict())
+
+
+class TestWarnings:
+    """Tests for warnings emitted by dependencies."""
+
+    # The python-Levenshtein warning is only emitted the first time
+    # fuzzywuzzy.process is imported in a given pytest session.
+    # Thus, this test *MUST* be run first, in order for the warning
+    # to be detected in the test.
+    @pytest.mark.first
+    def test_api_fuzzywuzzy_warningcheck(self, misc_info):
+        """Confirm only the Levenshtein warning is raised, if any are."""
+        if misc_info.IN_PYPY:
+            pytest.skip("Don't test warnings in PyPy")  # pragma: no cover
+
+        with warnings.catch_warnings(record=True) as wc:
+            warnings.simplefilter("always")
+            from fuzzywuzzy import process  # noqa: F401
+
+        # Try to import, and adjust tests accordingly
+        try:
+            import Levenshtein  # noqa: F401
+        except ImportError:
+            lev_present = False
+        else:
+            # Standard testing setup is WITHOUT python-Levenshtein
+            lev_present = True  # pragma: no cover
+
+        if lev_present:
+            assert len(wc) == 0, "Warning unexpectedly raised"  # pragma: no cover
+        else:
+            assert len(wc) == 1, "Warning unexpectedly not raised"
+
+            # 'message' will be a Warning instance, thus 'args[0]'
+            # to retrieve the warning message as str.
+            assert (
+                "levenshtein" in wc[0].message.args[0].lower()
+            ), "Warning raised for unexpected reason"
diff --git a/tests/test_api_good_nonlocal.py b/tests/test_api_good_nonlocal.py
index 5c9376a1..3f6a28df 100644
--- a/tests/test_api_good_nonlocal.py
+++ b/tests/test_api_good_nonlocal.py
@@ -10,7 +10,7 @@
     21 Mar 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -41,7 +41,7 @@ def skip_if_no_nonloc(pytestconfig):
 
     """
     if not pytestconfig.getoption("--nonloc"):
-        pytest.skip("'--nonloc' not specified")
+        pytest.skip("'--nonloc' not specified")  # pragma: no cover
 
 
 @pytest.mark.parametrize(
@@ -53,7 +53,7 @@ def skip_if_no_nonloc(pytestconfig):
     ids=(lambda x: "" if "://" in x else x),
 )
 @pytest.mark.timeout(30)
-def test_api_inventory_known_header_required(name, url, subtests):
+def test_api_inventory_known_header_required(name, url):
     """Confirm URL load works on docs pages requiring HTTP header config."""
     inv = soi.Inventory(url=url)
     assert inv.count > 0
@@ -68,7 +68,6 @@ def test_api_inventory_many_url_imports(
     misc_info,
     sphinx_load_test,
     pytestconfig,
-    subtests,
 ):
     """Confirm a plethora of .inv files downloads properly via url arg.
 
@@ -91,21 +90,10 @@ def test_api_inventory_many_url_imports(
     inv2 = soi.Inventory(url=misc_info.remote_url.format(proj_name))
 
     # Test the things
-    with subtests.test(msg="properties"):
-        assert inv1.project == inv2.project
-        assert inv1.version == inv2.version
-        assert inv1.count == inv2.count
-        for objs in zip(inv1.objects, inv2.objects):
-            assert objs[0].name == objs[1].name
-            assert objs[0].domain == objs[1].domain
-            assert objs[0].role == objs[1].role
-            assert objs[0].uri == objs[1].uri
-            assert objs[0].priority == objs[1].priority
-            assert objs[0].dispname == objs[1].dispname
+    assert inv1 == inv2
 
     # Ensure sphinx likes the regenerated inventory
-    with subtests.test(msg="sphinx_load"):
-        data = inv2.data_file()
-        cmp_data = soi.compress(data)
-        soi.writebytes(str(scr_fpath), cmp_data)
-        sphinx_load_test(scr_fpath)
+    data = inv2.data_file()
+    cmp_data = soi.compress(data)
+    soi.writebytes(scr_fpath, cmp_data)
+    sphinx_load_test(scr_fpath)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index df83e110..4a3ee1cf 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -10,7 +10,7 @@
     20 Mar 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -27,380 +27,504 @@
 
 import json
 import re
+import shlex
+import subprocess as sp  # noqa: S404
 from itertools import product
 from pathlib import Path
 
 import pytest
 from stdio_mgr import stdio_mgr
 
-from sphobjinv import Inventory as Inv
+from sphobjinv import HeaderFields
+from sphobjinv import Inventory
+from sphobjinv import SourceTypes
+
 
 CLI_TEST_TIMEOUT = 2
+CLI_CMDS = ["sphobjinv", "python -m sphobjinv"]
 
 pytestmark = [pytest.mark.cli, pytest.mark.local]
 
 
-# ====  EXPECT-GOOD MISC TESTS  ====
-
+class TestMisc:
+    """Tests for miscellaneous CLI functions."""
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_version_exits_ok(run_cmdline_test):
-    """Confirm --version exits cleanly."""
-    run_cmdline_test(["-v"])
+    @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
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_noargs_shows_help(run_cmdline_test):
-    """Confirm help shown when invoked with no arguments."""
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test([])
+        assert "sphobjinv" in out
+        assert "suggest" in out
 
-        assert "usage: sphobjinv" in out_.getvalue()
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_version_exits_ok(self, run_cmdline_test):
+        """Confirm --version exits cleanly."""
+        run_cmdline_test(["-v"])
 
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_noargs_shows_help(self, run_cmdline_test):
+        """Confirm help shown when invoked with no arguments."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test([])
 
-# ====  EXPECT-GOOD CONVERT TESTS  ====
+            assert "usage: sphobjinv" in out_.getvalue()
 
 
-@pytest.mark.parametrize(
-    ["out_ext", "cli_arg"],
-    [(".txt", "plain"), (".inv", "zlib"), (".json", "json")],
-    ids=(lambda i: "" if i.startswith(".") else i),
-)
-@pytest.mark.parametrize(
-    "in_ext", [".txt", ".inv", ".json"], ids=(lambda i: i.split(".")[-1])
-)
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_convert_default_outname(
-    in_ext,
-    out_ext,
-    cli_arg,
-    scratch_path,
-    run_cmdline_test,
-    decomp_cmp_test,
-    sphinx_load_test,
-    misc_info,
-):
-    """Confirm cmdline conversions with only input file arg."""
-    if in_ext == out_ext:
-        pytest.skip("Ignore no-change conversions")
+class TestConvertGood:
+    """Tests for expected-good convert functionality."""
 
-    src_path = scratch_path / (misc_info.FNames.INIT.value + in_ext)
-    dest_path = scratch_path / (misc_info.FNames.INIT.value + out_ext)
+    @pytest.mark.parametrize(
+        ["out_ext", "cli_arg"],
+        [(".txt", "plain"), (".inv", "zlib"), (".json", "json")],
+        ids=(lambda i: "" if i.startswith(".") else i),
+    )
+    @pytest.mark.parametrize(
+        "in_ext", [".txt", ".inv", ".json"], ids=(lambda i: i.split(".")[-1])
+    )
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_convert_default_outname(
+        self,
+        in_ext,
+        out_ext,
+        cli_arg,
+        scratch_path,
+        run_cmdline_test,
+        decomp_cmp_test,
+        sphinx_load_test,
+        misc_info,
+    ):
+        """Confirm cmdline conversions with only input file arg."""
+        if in_ext == out_ext:
+            pytest.skip("Ignore no-change conversions")
 
-    assert src_path.is_file()
-    assert dest_path.is_file()
+        src_path = scratch_path / (misc_info.FNames.INIT + in_ext)
+        dest_path = scratch_path / (misc_info.FNames.INIT + out_ext)
 
-    dest_path.unlink()
+        assert src_path.is_file()
+        assert dest_path.is_file()
 
-    cli_arglist = ["convert", cli_arg, str(src_path)]
-    run_cmdline_test(cli_arglist)
-    assert dest_path.is_file()
+        dest_path.unlink()
 
-    if cli_arg == "zlib":
-        sphinx_load_test(dest_path)
-    if cli_arg == "plain":
-        decomp_cmp_test(dest_path)
+        cli_arglist = ["convert", cli_arg, str(src_path)]
+        run_cmdline_test(cli_arglist)
+        assert dest_path.is_file()
 
+        if cli_arg == "zlib":
+            sphinx_load_test(dest_path)
+        if cli_arg == "plain":
+            decomp_cmp_test(dest_path)
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 2)
-def test_cli_convert_expandcontract(scratch_path, misc_info, run_cmdline_test):
-    """Confirm cmdline contract decompress of zlib with input file arg."""
-    cmp_path = scratch_path / (
-        misc_info.FNames.INIT.value + misc_info.Extensions.CMP.value
-    )
-    dec_path = scratch_path / (
-        misc_info.FNames.MOD.value + misc_info.Extensions.DEC.value
-    )
-    recmp_path = scratch_path / (
-        misc_info.FNames.MOD.value + misc_info.Extensions.CMP.value
-    )
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT * 2)
+    def test_cli_convert_expandcontract(
+        self, scratch_path, misc_info, run_cmdline_test
+    ):
+        """Confirm cmdline contract decompress of zlib with input file arg."""
+        cmp_path = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.CMP)
+        dec_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.DEC)
+        recmp_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.CMP)
 
-    run_cmdline_test(["convert", "plain", "-e", str(cmp_path), str(dec_path)])
-    assert dec_path.is_file()
-
-    run_cmdline_test(["convert", "zlib", "-c", str(dec_path), str(recmp_path)])
-    assert recmp_path.is_file()
-
-
-@pytest.mark.parametrize(
-    "dst_name", [True, False], ids=(lambda v: "dst_name" if v else "no_dst_name")
-)
-@pytest.mark.parametrize(
-    "dst_path", [True, False], ids=(lambda v: "dst_path" if v else "no_dst_path")
-)
-@pytest.mark.parametrize(
-    "src_path", [True, False], ids=(lambda v: "src_path" if v else "no_src_path")
-)
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_convert_various_pathargs(
-    src_path,
-    dst_path,
-    dst_name,
-    scratch_path,
-    misc_info,
-    run_cmdline_test,
-    decomp_cmp_test,
-    monkeypatch,
-):
-    """Confirm the various src/dest path/file combinations work."""
-    init_dst_fname = misc_info.FNames.INIT.value + misc_info.Extensions.DEC.value
-    mod_dst_fname = misc_info.FNames.MOD.value + misc_info.Extensions.DEC.value
-
-    src_path = (scratch_path.resolve() if src_path else Path(".")) / (
-        misc_info.FNames.INIT.value + misc_info.Extensions.CMP.value
-    )
-    dst_path = (scratch_path.resolve() if dst_path else Path(".")) / (
-        mod_dst_fname if dst_name else ""
-    )
+        run_cmdline_test(["convert", "plain", "-e", str(cmp_path), str(dec_path)])
+        assert dec_path.is_file()
 
-    full_dst_path = scratch_path.resolve() / (
-        mod_dst_fname if dst_name else init_dst_fname
-    )
+        run_cmdline_test(["convert", "zlib", "-c", str(dec_path), str(recmp_path)])
+        assert recmp_path.is_file()
 
-    assert (scratch_path / init_dst_fname).is_file()
-    (scratch_path / init_dst_fname).unlink()
-
-    with monkeypatch.context() as m:
-        m.chdir(scratch_path)
-        run_cmdline_test(["convert", "plain", str(src_path), str(dst_path)])
-        assert full_dst_path.is_file()
-
-    decomp_cmp_test(full_dst_path)
-
-
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 50 * 3)  # noqa: F821
-@pytest.mark.testall
-def test_cli_convert_cycle_formats(
-    testall_inv_path,
-    res_path,
-    scratch_path,
-    run_cmdline_test,
-    misc_info,
-    pytestconfig,
-    subtests,
-):
-    """Confirm conversion in a loop, reading/writing all formats."""
-    from sphobjinv import HeaderFields as HFields
-
-    res_src_path = res_path / testall_inv_path
-    plain_path = scratch_path / (
-        misc_info.FNames.MOD.value + misc_info.Extensions.DEC.value
+    @pytest.mark.parametrize(
+        "dst_name", [True, False], ids=(lambda v: "dst_name" if v else "no_dst_name")
     )
-    json_path = scratch_path / (
-        misc_info.FNames.MOD.value + misc_info.Extensions.JSON.value
+    @pytest.mark.parametrize(
+        "dst_path", [True, False], ids=(lambda v: "dst_path" if v else "no_dst_path")
     )
-    zlib_path = scratch_path / (
-        misc_info.FNames.MOD.value + misc_info.Extensions.CMP.value
+    @pytest.mark.parametrize(
+        "src_path", [True, False], ids=(lambda v: "src_path" if v else "no_src_path")
     )
-
-    if (
-        not pytestconfig.getoption("--testall")
-        and testall_inv_path.name != "objects_attrs.inv"
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_convert_various_pathargs(
+        self,
+        src_path,
+        dst_path,
+        dst_name,
+        scratch_path,
+        misc_info,
+        run_cmdline_test,
+        decomp_cmp_test,
+        monkeypatch,
     ):
-        pytest.skip("'--testall' not specified")
-
-    run_cmdline_test(["convert", "plain", str(res_src_path), str(plain_path)])
-    run_cmdline_test(["convert", "json", str(plain_path), str(json_path)])
-    run_cmdline_test(["convert", "zlib", str(json_path), str(zlib_path)])
-
-    invs = {
-        "orig": Inv(str(res_src_path)),
-        "plain": Inv(str(plain_path)),
-        "zlib": Inv(str(zlib_path)),
-    }
-    with json_path.open() as f:
-        invs.update({"json": Inv(json.load(f))})
-
-    for fmt, attrib in product(
-        ("plain", "zlib", "json"),
-        (HFields.Project.value, HFields.Version.value, HFields.Count.value),
+        """Confirm the various src/dest path/file combinations work."""
+        init_dst_fname = misc_info.FNames.INIT + misc_info.Extensions.DEC
+        mod_dst_fname = misc_info.FNames.MOD + misc_info.Extensions.DEC
+
+        src_path = (scratch_path.resolve() if src_path else Path(".")) / (
+            misc_info.FNames.INIT + misc_info.Extensions.CMP
+        )
+        dst_path = (scratch_path.resolve() if dst_path else Path(".")) / (
+            mod_dst_fname if dst_name else ""
+        )
+
+        full_dst_path = scratch_path.resolve() / (
+            mod_dst_fname if dst_name else init_dst_fname
+        )
+
+        assert (scratch_path / init_dst_fname).is_file()
+        (scratch_path / init_dst_fname).unlink()
+
+        with monkeypatch.context() as m:
+            m.chdir(scratch_path)
+            run_cmdline_test(["convert", "plain", str(src_path), str(dst_path)])
+            assert full_dst_path.is_file()
+
+        decomp_cmp_test(full_dst_path)
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT * 50 * 3)
+    @pytest.mark.testall
+    def test_cli_convert_cycle_formats(
+        self,
+        testall_inv_path,
+        res_path,
+        scratch_path,
+        run_cmdline_test,
+        misc_info,
+        pytestconfig,
+        check,
     ):
-        with subtests.test(msg="{}_{}".format(fmt, attrib)):
-            assert getattr(invs[fmt], attrib) == getattr(invs["orig"], attrib)
-
-
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_overwrite_prompt_and_behavior(
-    res_path, scratch_path, misc_info, run_cmdline_test
-):
-    """Confirm overwrite prompt works properly."""
-    src_path_1 = res_path / "objects_attrs.inv"
-    src_path_2 = res_path / "objects_sarge.inv"
-    dst_path = scratch_path / (
-        misc_info.FNames.INIT.value + misc_info.Extensions.DEC.value
-    )
-    dst_path.unlink()
-
-    args = ["convert", "plain", None, str(dst_path)]
+        """Confirm conversion in a loop, reading/writing all formats."""
+        res_src_path = res_path / testall_inv_path
+        plain_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.DEC)
+        json_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.JSON)
+        zlib_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.CMP)
+
+        if (
+            not pytestconfig.getoption("--testall")
+            and testall_inv_path.name != "objects_attrs.inv"
+        ):
+            pytest.skip("'--testall' not specified")
+
+        run_cmdline_test(["convert", "plain", str(res_src_path), str(plain_path)])
+        run_cmdline_test(["convert", "json", str(plain_path), str(json_path)])
+        run_cmdline_test(["convert", "zlib", str(json_path), str(zlib_path)])
+
+        invs = {
+            "orig": Inventory(str(res_src_path)),
+            "plain": Inventory(str(plain_path)),
+            "zlib": Inventory(str(zlib_path)),
+            "json": Inventory(json.loads(json_path.read_text())),
+        }
+
+        for fmt, attrib in product(
+            ("plain", "zlib", "json"),
+            (
+                HeaderFields.Project.value,
+                HeaderFields.Version.value,
+                HeaderFields.Count.value,
+            ),
+        ):
+            check.equal(getattr(invs[fmt], attrib), getattr(invs["orig"], attrib))
 
-    # Initial decompress
-    args[2] = str(src_path_1)
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(args)
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_overwrite_prompt_and_behavior(
+        self, res_path, scratch_path, misc_info, run_cmdline_test
+    ):
+        """Confirm overwrite prompt works properly."""
+        src_path_1 = res_path / "objects_attrs.inv"
+        src_path_2 = res_path / "objects_sarge.inv"
+        dst_path = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.DEC)
+        dst_path.unlink()
 
-        assert "converted" in out_.getvalue()
-        assert "(plain)" in out_.getvalue()
+        args = ["convert", "plain", None, str(dst_path)]
 
-    # First overwrite, declining clobber
-    args[2] = str(src_path_2)
-    with stdio_mgr("n\n") as (in_, out_, err_):
-        run_cmdline_test(args)
+        # Initial decompress
+        args[2] = str(src_path_1)
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(args)
 
-        assert "(Y/N)? n" in out_.getvalue()
+            assert "converted" in err_.getvalue()
+            assert "(plain)" in err_.getvalue()
 
-    assert "attrs" == Inv(str(dst_path)).project
+        # First overwrite, declining clobber
+        args[2] = str(src_path_2)
+        with stdio_mgr("n\n") as (in_, out_, err_):
+            run_cmdline_test(args)
 
-    # Second overwrite, with clobber
-    with stdio_mgr("y\n") as (in_, out_, err_):
-        run_cmdline_test(args)
+            assert "(Y/N)? n" in out_.getvalue()
 
-        assert "(Y/N)? y" in out_.getvalue()
+        assert "attrs" == Inventory(str(dst_path)).project
 
-    assert "Sarge" == Inv(str(dst_path)).project
+        # Second overwrite, with clobber
+        with stdio_mgr("y\n") as (in_, out_, err_):
+            run_cmdline_test(args)
 
+            assert "(Y/N)? y" in out_.getvalue()
 
-# ====  EXPECT-GOOD SUGGEST TESTS  ====
+        assert "Sarge" == Inventory(str(dst_path)).project
 
+    def test_cli_stdin_clobber(
+        self, res_path, scratch_path, misc_info, run_cmdline_test
+    ):
+        """Confirm clobber with stdin data only with --overwrite."""
+        src_path_sarge = res_path / "objects_sarge.inv"
+        dst_path = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.CMP)
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_suggest_noresults(run_cmdline_test, res_cmp):
-    """Confirm suggest w/no found results works."""
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["suggest", res_cmp, "instance", "-t", "99"])
-        assert "No results found." in out_.getvalue()
+        assert "attrs" == Inventory(dst_path).project
 
+        data = json.dumps(Inventory(src_path_sarge).json_dict())
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_suggest_nameonly(run_cmdline_test, res_cmp):
-    """Confirm name-only suggest works."""
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["suggest", res_cmp, "instance", "-t", "50"])
-        assert re.search("^.*instance_of.*$", out_.getvalue(), re.M)
+        args = ["convert", "plain", "-", str(dst_path)]
+        with stdio_mgr(data):
+            run_cmdline_test(args)
+        assert "attrs" == Inventory(dst_path).project
 
+        args.append("-o")
+        with stdio_mgr(data):
+            run_cmdline_test(args)
+        assert "Sarge" == Inventory(dst_path).project
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_suggest_withindex(run_cmdline_test, res_cmp):
-    """Confirm with_index suggest works."""
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["suggest", res_cmp, "instance", "-it", "50"])
-        assert re.search("^.*instance_of\\S*\\s+23\\s*$", out_.getvalue(), re.M)
+    def test_cli_json_no_metadata_url(
+        self, res_cmp, scratch_path, misc_info, run_cmdline_test
+    ):
+        """Confim JSON generated from local inventory has no url in metadata."""
+        json_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.JSON)
 
+        run_cmdline_test(
+            ["convert", "json", str(res_cmp.resolve()), str(json_path.resolve())]
+        )
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_suggest_withscore(run_cmdline_test, res_cmp):
-    """Confirm with_index suggest works."""
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["suggest", res_cmp, "instance", "-st", "50"])
-        re.search("^.*instance_of\\S*\\s+\\d+\\s*$", out_.getvalue(), re.M)
+        d = json.loads(json_path.read_text())
 
+        assert "url" not in d.get("metadata", {})
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_suggest_withscoreandindex(run_cmdline_test, res_cmp):
-    """Confirm with_index suggest works."""
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["suggest", res_cmp, "instance", "-sit", "50"])
-        re.search("^.*instance_of\\S*\\s+\\d+\\s+23\\s*$", out_.getvalue(), re.M)
+    def test_cli_json_export_import(
+        self, res_cmp, scratch_path, misc_info, run_cmdline_test, sphinx_load_test
+    ):
+        """Confirm JSON sent to stdout from local source imports ok."""
+        mod_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.CMP)
+
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["convert", "json", str(res_cmp.resolve()), "-"])
+
+            data = out_.getvalue()
+
+        with stdio_mgr(data) as (in_, out_, err_):
+            run_cmdline_test(["convert", "zlib", "-", str(mod_path.resolve())])
+
+        assert Inventory(json.loads(data))
+        assert Inventory(mod_path)
+        sphinx_load_test(mod_path)
+
+
+class TestSuggestGood:
+    """Tests for expected-good suggest-mode functionality."""
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_suggest_noresults(self, run_cmdline_test, res_cmp):
+        """Confirm suggest w/no found results works."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["suggest", res_cmp, "instance", "-t", "99"])
+            assert "No results found." in err_.getvalue()
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_suggest_nameonly(self, run_cmdline_test, res_cmp):
+        """Confirm name-only suggest works."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["suggest", res_cmp, "instance", "-t", "50"])
+            assert re.search("^.*instance_of.*$", out_.getvalue(), re.M)
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_suggest_withindex(self, run_cmdline_test, res_cmp):
+        """Confirm with_index suggest works."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["suggest", res_cmp, "instance", "-it", "50"])
+            assert re.search("^.*instance_of\\S*\\s+23\\s*$", out_.getvalue(), re.M)
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_suggest_withscore(self, run_cmdline_test, res_cmp):
+        """Confirm with_index suggest works."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["suggest", res_cmp, "instance", "-st", "50"])
+            re.search("^.*instance_of\\S*\\s+\\d+\\s*$", out_.getvalue(), re.M)
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_suggest_withscoreandindex(self, run_cmdline_test, res_cmp):
+        """Confirm with_index suggest works."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["suggest", res_cmp, "instance", "-sit", "50"])
+            re.search("^.*instance_of\\S*\\s+\\d+\\s+23\\s*$", out_.getvalue(), re.M)
+
+    @pytest.mark.parametrize(
+        ["inp", "flags", "nlines"],
+        [("", "-at", 56), ("y\n", "-t", 57), ("n\n", "-t", 1)],
+    )  # Extra line for input() query in the "y\n" case
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_cli_suggest_long_list(self, inp, flags, nlines, run_cmdline_test, res_cmp):
+        """Confirm with_index suggest works."""
+        with stdio_mgr(inp) as (in_, out_, err_):
+            run_cmdline_test(["suggest", res_cmp, "instance", flags, "1"])
+            assert nlines == out_.getvalue().count("\n")
+
+    def test_cli_suggest_many_results_stdin(self, res_cmp, run_cmdline_test):
+        """Confirm suggest from stdin doesn't choke on a long list."""
+        data = json.dumps(Inventory(res_cmp).json_dict())
+
+        with stdio_mgr(data) as (in_, out_, err_):
+            run_cmdline_test(["suggest", "-", "py", "-t", "1"])
+
+
+class TestFail:
+    """Tests for expected-fail behaviors."""
+
+    @pytest.mark.parametrize("with_format", [True, False])
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT * 2)
+    def test_clifail_convert_nosrc(
+        self, with_format, scratch_path, run_cmdline_test, monkeypatch
+    ):
+        """Confirm commandline convert w/o enough args fails."""
+        monkeypatch.chdir(scratch_path)
+        if with_format:
+            run_cmdline_test(["convert", "plain"], expect=2)
+        else:
+            run_cmdline_test(["convert"], expect=2)
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_clifail_convert_wrongfiletype(
+        self, scratch_path, run_cmdline_test, monkeypatch
+    ):
+        """Confirm exit code 1 with invalid file format."""
+        monkeypatch.chdir(scratch_path)
+        fname = "testfile"
+        Path(fname).write_bytes(b"this is not objects.inv\n")
+
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["convert", "plain", fname], expect=1)
+            assert "Unrecognized" in err_.getvalue()
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_clifail_convert_missingfile(self, run_cmdline_test):
+        """Confirm exit code 1 with nonexistent file specified."""
+        run_cmdline_test(["convert", "plain", "thisfileshouldbeabsent.txt"], expect=1)
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_clifail_convert_badoutfilename(
+        self, scratch_path, run_cmdline_test, misc_info
+    ):
+        """Confirm exit code 1 with invalid output file name."""
+        run_cmdline_test(
+            [
+                "convert",
+                "plain",
+                str(scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.CMP)),
+                misc_info.invalid_filename,
+            ],
+            expect=1,
+        )
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_clifail_convert_badoutputdir(
+        self, res_cmp, scratch_path, run_cmdline_test
+    ):
+        """Confirm exit code 1 when output location can't be created."""
+        run_cmdline_test(
+            [
+                "convert",
+                "plain",
+                res_cmp,
+                str(scratch_path / "nonexistent" / "folder" / "obj.txt"),
+            ],
+            expect=1,
+        )
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_clifail_convert_pathonlysrc(self, scratch_path, run_cmdline_test):
+        """Confirm cmdline plaintext convert with input directory arg fails."""
+        run_cmdline_test(["convert", "plain", str(scratch_path)], expect=1)
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT)
+    def test_clifail_convert_localfile_as_url(
+        self, scratch_path, misc_info, run_cmdline_test, check
+    ):
+        """Confirm error when using URL mode on local file."""
+        in_path = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.CMP)
 
+        (scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.DEC)).unlink()
 
-@pytest.mark.parametrize(
-    ["inp", "flags", "nlines"], [("", "-at", 57), ("y\n", "-t", 58), ("n\n", "-t", 4)]
-)  # Extra line for input() query in the "y\n" case
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_cli_suggest_long_list(inp, flags, nlines, run_cmdline_test, res_cmp):
-    """Confirm with_index suggest works."""
-    with stdio_mgr(inp) as (in_, out_, err_):
-        run_cmdline_test(["suggest", res_cmp, "instance", flags, "1"])
-        assert nlines == out_.getvalue().count("\n")
+        with check.check(msg="path-style"):
+            run_cmdline_test(["convert", "plain", "-u", str(in_path)], expect=1)
 
+        with check.check(msg="url-style"):
+            file_url = "file:///" + str(in_path.resolve())
+            run_cmdline_test(["convert", "plain", "-u", file_url], expect=1)
 
-# ====  EXPECT-FAIL TESTS  ====
+    def test_clifail_no_url_with_stdin(self, run_cmdline_test):
+        """Confirm parser exit when -u passed with "-" infile."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["convert", "plain", "-u", "-"], expect=2)
+            assert "--url not allowed" in err_.getvalue()
 
 
-@pytest.mark.parametrize("with_format", [True, False])
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 2)
-def test_clifail_convert_nosrc(
-    with_format, scratch_path, run_cmdline_test, monkeypatch
-):
-    """Confirm commandline convert w/o enough args fails."""
-    monkeypatch.chdir(scratch_path)
-    if with_format:
-        run_cmdline_test(["convert", "plain"], expect=2)
-    else:
-        run_cmdline_test(["convert"], expect=2)
+class TestStdio:
+    """Tests for the stdin/stdout functionality."""
 
+    @pytest.mark.parametrize(
+        "data_format", [SourceTypes.DictJSON, SourceTypes.BytesPlaintext]
+    )
+    def test_cli_stdio_input(
+        self, scratch_path, res_cmp, misc_info, run_cmdline_test, data_format
+    ):
+        """Confirm that inventory data can be read on stdin."""
+        inv1 = Inventory(res_cmp)
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_clifail_convert_wrongfiletype(scratch_path, run_cmdline_test, monkeypatch):
-    """Confirm exit code 1 with invalid file format."""
-    monkeypatch.chdir(scratch_path)
-    fname = "testfile"
-    with Path(fname).open("wb") as f:
-        f.write(b"this is not objects.inv\n")
+        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")
 
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["convert", "plain", fname], expect=1)
-        assert "Unrecognized" in out_.getvalue()
+        out_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.DEC)
 
+        with stdio_mgr(input_data) as (in_, out_, err_):
+            run_cmdline_test(["convert", "plain", "-", str(out_path.resolve())])
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_clifail_convert_missingfile(run_cmdline_test):
-    """Confirm exit code 1 with nonexistent file specified."""
-    run_cmdline_test(["convert", "plain", "thisfileshouldbeabsent.txt"], expect=1)
+        inv2 = Inventory(out_path)
 
+        assert inv1 == inv2
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_clifail_convert_badoutfilename(scratch_path, run_cmdline_test, misc_info):
-    """Confirm exit code 1 with invalid output file name."""
-    run_cmdline_test(
-        [
-            "convert",
-            "plain",
-            str(
-                scratch_path
-                / (misc_info.FNames.INIT.value + misc_info.Extensions.CMP.value)
-            ),
-            misc_info.invalid_filename,
-        ],
-        expect=1,
-    )
+    def test_cli_stdio_zlib_input_fails(self, scratch_path, res_cmp, run_cmdline_test):
+        """Confirm that error response is made on attempt to pipe in zlib inventory."""
+        input_data = res_cmp.read_bytes().decode("latin-1")
 
+        with stdio_mgr(input_data) as (in_, out_, err_):
+            run_cmdline_test(
+                ["convert", "plain", "-", str(scratch_path.resolve())], expect=1
+            )
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_clifail_convert_badoutputdir(res_cmp, scratch_path, run_cmdline_test):
-    """Confirm exit code 1 when output location can't be created."""
-    run_cmdline_test(
-        [
-            "convert",
-            "plain",
-            res_cmp,
-            str(scratch_path / "nonexistent" / "folder" / "obj.txt"),
-        ],
-        expect=1,
-    )
+            assert "Invalid" in err_.getvalue()
 
+    @pytest.mark.parametrize("format_arg", ["plain", "json"])
+    def test_cli_stdio_output(
+        self, scratch_path, res_cmp, run_cmdline_test, format_arg
+    ):
+        """Confirm that inventory data can be written to stdout."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["convert", format_arg, str(res_cmp.resolve()), "-"])
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_clifail_convert_pathonlysrc(scratch_path, run_cmdline_test):
-    """Confirm cmdline plaintext convert with input directory arg fails."""
-    run_cmdline_test(["convert", "plain", str(scratch_path)], expect=1)
+            result = out_.getvalue()
 
+        inv1 = Inventory(res_cmp)
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT)
-def test_clifail_convert_localfile_as_url(
-    scratch_path, misc_info, run_cmdline_test, subtests
-):
-    """Confirm error when using URL mode on local file."""
-    in_path = scratch_path / (
-        misc_info.FNames.INIT.value + misc_info.Extensions.CMP.value
-    )
+        if format_arg == "plain":
+            inv2 = Inventory(result.encode("utf-8"))
+        elif format_arg == "json":
+            inv2 = Inventory(json.loads(result))
+        else:  # pragma: no cover
+            raise ValueError("Invalid parametrized format arg")
 
-    (
-        scratch_path / (misc_info.FNames.INIT.value + misc_info.Extensions.DEC.value)
-    ).unlink()
+        assert inv1 == inv2
 
-    with subtests.test(msg="path-style"):
-        run_cmdline_test(["convert", "plain", "-u", str(in_path)], expect=1)
+    def test_cli_stdio_zlib_output_fails(self, res_dec, run_cmdline_test):
+        """Confirm that error response is made on attempt to pipe in zlib inventory."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["convert", "zlib", str(res_dec.resolve()), "-"], expect=1)
 
-    with subtests.test(msg="url-style"):
-        file_url = "file:///" + str(in_path.resolve())
-        run_cmdline_test(["convert", "plain", "-u", file_url], expect=1)
+            assert "Error" in err_.getvalue(), err_
diff --git a/tests/test_cli_nonlocal.py b/tests/test_cli_nonlocal.py
index 14809d6c..8978a13d 100644
--- a/tests/test_cli_nonlocal.py
+++ b/tests/test_cli_nonlocal.py
@@ -10,7 +10,7 @@
     20 Mar 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -25,11 +25,13 @@
 """
 
 
+import json
 import re
 
 import pytest
 from stdio_mgr import stdio_mgr
 
+from sphobjinv import Inventory
 
 CLI_TEST_TIMEOUT = 5
 
@@ -47,122 +49,160 @@ def skip_if_no_nonloc(pytestconfig):
 
     """
     if not pytestconfig.getoption("--nonloc"):
-        pytest.skip("'--nonloc' not specified")
+        pytest.skip("'--nonloc' not specified")  # pragma: no cover
 
 
-# ====  NONLOCAL CONVERT TESTS  ====
+class TestConvert:
+    """Test nonlocal CLI convert mode functionality."""
 
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
+    def test_cli_convert_from_url_with_dest(
+        self, scratch_path, misc_info, run_cmdline_test, monkeypatch
+    ):
+        """Confirm CLI URL D/L, convert works w/outfile supplied."""
+        monkeypatch.chdir(scratch_path)
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-def test_cli_convert_from_url_with_dest(
-    scratch_path, misc_info, run_cmdline_test, monkeypatch
-):
-    """Confirm CLI URL D/L, convert works w/outfile supplied."""
-    monkeypatch.chdir(scratch_path)
-
-    dest_path = scratch_path / (
-        misc_info.FNames.MOD.value + misc_info.Extensions.DEC.value
-    )
-    run_cmdline_test(
-        ["convert", "plain", "-u", misc_info.remote_url.format("attrs"), str(dest_path)]
-    )
-
-    assert dest_path.is_file()
-
-
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-def test_cli_convert_from_url_no_dest(
-    scratch_path, misc_info, run_cmdline_test, monkeypatch
-):
-    """Confirm CLI URL D/L, convert works w/o outfile supplied."""
-    monkeypatch.chdir(scratch_path)
-    dest_path = scratch_path / (
-        misc_info.FNames.INIT.value + misc_info.Extensions.DEC.value
-    )
-    dest_path.unlink()
-    run_cmdline_test(["convert", "plain", "-u", misc_info.remote_url.format("attrs")])
-    assert dest_path.is_file()
-
-
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-def test_clifail_bad_url(run_cmdline_test, misc_info, scratch_path):
-    """Confirm proper error behavior when a bad URL is passed."""
-    with stdio_mgr() as (in_, out_, err_):
+        dest_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.DEC)
         run_cmdline_test(
             [
                 "convert",
                 "plain",
                 "-u",
-                misc_info.remote_url.format("blarghers"),
-                str(scratch_path),
-            ],
-            expect=1,
+                misc_info.remote_url.format("attrs"),
+                str(dest_path),
+            ]
         )
-        assert "No inventory at provided URL." in out_.getvalue()
 
+        assert dest_path.is_file()
 
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-def test_clifail_url_no_leading_http(run_cmdline_test, scratch_path):
-    """Confirm proper error behavior when a URL w/o leading 'http://' is passed."""
-    with stdio_mgr() as (in_, out_, err_):
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
+    def test_cli_convert_from_url_no_dest(
+        self, scratch_path, misc_info, run_cmdline_test, monkeypatch
+    ):
+        """Confirm CLI URL D/L, convert works w/o outfile supplied."""
+        monkeypatch.chdir(scratch_path)
+        dest_path = scratch_path / (misc_info.FNames.INIT + misc_info.Extensions.DEC)
+        dest_path.unlink()
         run_cmdline_test(
-            [
-                "convert",
-                "plain",
-                "-u",
-                "sphobjinv.readthedocs.io/en/latest",
-                str(scratch_path),
-            ],
-            expect=1,
+            ["convert", "plain", "-u", misc_info.remote_url.format("attrs")]
         )
-        assert "No inventory at provided URL." in out_.getvalue()
-
-
-# ====  NONLOCAL SUGGEST TESTS  ====
-
-
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-def test_cli_suggest_from_url(misc_info, run_cmdline_test):
-    """Confirm name-only suggest works from URL."""
-    with stdio_mgr() as (in_, out_, err_):
+        assert dest_path.is_file()
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
+    def test_cli_url_in_json(
+        self, scratch_path, misc_info, run_cmdline_test, monkeypatch
+    ):
+        """Confirm URL is present when using CLI URL mode."""
+        monkeypatch.chdir(scratch_path)
+        dest_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.JSON)
         run_cmdline_test(
             [
-                "suggest",
+                "convert",
+                "json",
                 "-u",
                 misc_info.remote_url.format("attrs"),
-                "instance",
-                "-t",
-                "50",
+                str(dest_path.resolve()),
             ]
         )
-        assert p_instance_of.search(out_.getvalue())
-
-
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-def test_cli_suggest_from_dir_noanchor(run_cmdline_test):
-    """Confirm name-only suggest works from docpage URL."""
-    url = "http://sphobjinv.readthedocs.io/en/v2.0/modules/"
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
-        assert p_inventory.search(out_.getvalue())
-
-
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-def test_cli_suggest_from_page_noanchor(run_cmdline_test):
-    """Confirm name-only suggest works from docpage URL."""
-    url = "http://sphobjinv.readthedocs.io/en/v2.0/modules/cmdline.html"
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
-        assert p_inventory.search(out_.getvalue())
-
-
-@pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
-def test_cli_suggest_from_page_withanchor(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"
-    )
-    with stdio_mgr() as (in_, out_, err_):
-        run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
-        assert p_inventory.search(out_.getvalue())
+
+        d = json.loads(dest_path.read_text())
+
+        assert "objects" in d.get("metadata", {}).get("url", {})
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
+    def test_clifail_bad_url(self, run_cmdline_test, misc_info, scratch_path):
+        """Confirm proper error behavior when a bad URL is passed."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(
+                [
+                    "convert",
+                    "plain",
+                    "-u",
+                    misc_info.remote_url.format("blarghers"),
+                    str(scratch_path),
+                ],
+                expect=1,
+            )
+            assert "No inventory at provided URL." in err_.getvalue()
+
+    @pytest.mark.timeout(CLI_TEST_TIMEOUT * 4)
+    def test_clifail_url_no_leading_http(self, run_cmdline_test, scratch_path):
+        """Confirm proper error behavior when a URL w/o leading 'http://' is passed."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(
+                [
+                    "convert",
+                    "plain",
+                    "-u",
+                    "sphobjinv.readthedocs.io/en/latest",
+                    str(scratch_path),
+                ],
+                expect=1,
+            )
+            assert "No inventory at provided URL." in err_.getvalue()
+
+    def test_cli_json_export_import(
+        self, res_cmp, scratch_path, misc_info, run_cmdline_test, sphinx_load_test
+    ):
+        """Confirm JSON sent to stdout from local source imports ok."""
+        inv_url = misc_info.remote_url.format("attrs")
+        mod_path = scratch_path / (misc_info.FNames.MOD + misc_info.Extensions.CMP)
+
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["convert", "json", "-u", inv_url, "-"])
+
+            data = out_.getvalue()
+
+        with stdio_mgr(data) as (in_, out_, err_):
+            run_cmdline_test(["convert", "zlib", "-", str(mod_path.resolve())])
+
+        assert Inventory(json.loads(data))
+        assert Inventory(mod_path)
+        sphinx_load_test(mod_path)
+
+
+class TestSuggest:
+    """Test nonlocal CLI suggest mode functionality."""
+
+    @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."""
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(
+                [
+                    "suggest",
+                    "-u",
+                    misc_info.remote_url.format("attrs"),
+                    "instance",
+                    "-t",
+                    "50",
+                ]
+            )
+            assert p_instance_of.search(out_.getvalue())
+
+    @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/"
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
+            assert p_inventory.search(out_.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"
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
+            assert p_inventory.search(out_.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"
+        )
+        with stdio_mgr() as (in_, out_, err_):
+            run_cmdline_test(["suggest", "-u", url, "inventory", "-at", "50"])
+            assert p_inventory.search(out_.getvalue())
diff --git a/tests/test_fixture.py b/tests/test_fixture.py
index d0a70aba..65853790 100644
--- a/tests/test_fixture.py
+++ b/tests/test_fixture.py
@@ -10,7 +10,7 @@
     20 Mar 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -36,13 +36,13 @@ def test_info_fixture(misc_info):
     assert True in misc_info.byte_lines
 
 
-def test_populate_scratch(misc_info, scratch_path, subtests):
+def test_populate_scratch(misc_info, scratch_path, check):
     """Ensure the scratch_path fixture populates the scratch dir correctly."""
-    scr_base = misc_info.FNames.INIT.value
+    scr_base = misc_info.FNames.INIT
 
     for ext in [_.value for _ in misc_info.Extensions]:
-        with subtests.test(msg=ext):
-            assert (scratch_path / "{}{}".format(scr_base, ext)).is_file(), ext
+        with check.check(msg=ext):
+            assert (scratch_path / f"{scr_base}{ext}").is_file(), ext
 
 
 def test_sphinx_load(res_path, sphinx_load_test):
diff --git a/tests/test_flake8_ext.py b/tests/test_flake8_ext.py
index 43ab6f8a..7f0593d2 100644
--- a/tests/test_flake8_ext.py
+++ b/tests/test_flake8_ext.py
@@ -10,7 +10,7 @@
     27 Apr 2019
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -32,12 +32,25 @@
 
 import pytest
 
+pytestmark = [pytest.mark.flake8_ext]
+
+
+@pytest.fixture(scope="module", autouse=True)
+def skip_if_no_flake8_ext(pytestconfig):
+    """Skip test if --flake8_ext not provided.
+
+    Auto-applied to all functions in module.
+
+    """
+    if not pytestconfig.getoption("--flake8_ext"):
+        pytest.skip("'--flake8_ext' not specified")  # pragma: no cover
+
 
 @pytest.mark.skipif(
     sys.version_info < (3, 6),
     reason="Some flake8 extensions require Python 3.6 or later",
 )
-def test_flake8_version_output(subtests):
+def test_flake8_version_output(check):
     """Confirm that all desired plugins actually report as loaded."""
     p_pkgname = re.compile("^[0-9a-z_-]+", re.I)
     plugins = Path("requirements-flake8.txt").read_text().splitlines()[1:]
@@ -51,6 +64,6 @@ def test_flake8_version_output(subtests):
         ["flake8", "--version"], universal_newlines=True
     )  # noqa: S607,S603
 
-    for i, p in enumerate(plugins):
-        with subtests.test(msg=p, i=i):
-            assert p in flake8_ver_output
+    for p in plugins:
+        with check.check(msg=p):
+            assert p in flake8_ver_output.replace("_", "-")
diff --git a/tests/test_readme.py b/tests/test_readme.py
index 1701fc9e..682c8c7a 100644
--- a/tests/test_readme.py
+++ b/tests/test_readme.py
@@ -10,7 +10,7 @@
     6 Aug 2018
 
 **Copyright**
-    \(c) Brian Skinn 2016-2020
+    \(c) Brian Skinn 2016-2021
 
 **Source Repository**
     http://www.github.com/bskinn/sphobjinv
@@ -29,13 +29,14 @@
 import re
 import shlex
 import subprocess as sp  # noqa: S404
+from pathlib import Path
 
 
 import pytest
 from sphinx import __version__ as sphinx_ver
 
-with open("requirements-dev.txt", "r") as f:
-    reqs = f.read()
+
+reqs = Path("requirements-dev.txt").read_text()
 
 m_sphinx_req = re.search("^sphinx==(.+)$", reqs, re.I | re.M)
 sphinx_req = m_sphinx_req.group(1)
@@ -55,10 +56,9 @@
     sphinx_ver != sphinx_req,
     reason="Skip if Sphinx version mismatches current dev version.",
 )
-def test_readme_shell_cmds(ensure_doc_scratch, subtests):
+def test_readme_shell_cmds(ensure_doc_scratch, check):
     """Perform testing on README shell command examples."""
-    with open("README.rst") as f:
-        text = f.read()
+    text = Path("README.rst").read_text()
 
     chk = dt.OutputChecker()
 
@@ -76,5 +76,5 @@ def test_readme_shell_cmds(ensure_doc_scratch, subtests):
 
         msg = "\n\nExpected:\n" + out + "\n\nGot:\n" + result
 
-        with subtests.test(i=i):
+        with check.check(msg=f"match_{i}"):
             assert chk.check_output(out, result, dt_flags), msg
diff --git a/tests/test_valid_objects.py b/tests/test_valid_objects.py
new file mode 100644
index 00000000..c4702dd7
--- /dev/null
+++ b/tests/test_valid_objects.py
@@ -0,0 +1,177 @@
+r"""*Valid/invalid object data tests for* ``sphobjinv``.
+
+``sphobjinv`` is a toolkit for manipulation and inspection of
+Sphinx |objects.inv| files.
+
+**Author**
+    Brian Skinn (bskinn@alum.mit.edu)
+
+**File Created**
+    13 Feb 2021
+
+**Copyright**
+    \(c) Brian Skinn 2016-2021
+
+**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 os.path as osp
+import zlib
+from io import BytesIO
+
+import pytest
+from sphinx.util.inventory import InventoryFile as IFile
+
+import sphobjinv as soi
+
+
+@pytest.fixture(autouse=True)
+def skip_on_sphinx_version(sphinx_version):
+    """Trigger test skip if Sphinx version is too low.
+
+    Changes to the Sphinx InventoryFile regex &c. cause older
+    versions of Sphinx to have different behavior.
+
+    This skip *should* only trigger during the tox matrix of
+    environments with various old dependency versions.
+
+    """
+    if sphinx_version < (3, 3, 0):  # pragma: no cover
+        pytest.skip("Sphinx version too low")
+
+
+# Once DataObjStr instance validation is in place, this will probably
+# be a good place to use hypothesis
+@pytest.mark.parametrize(
+    ("name", "domain", "role", "prio", "uri", "dispname"),
+    [
+        ("foo", "py", "data", 1, "quux.html#$", "-"),  # Priorities
+        ("foo", "py", "data", 0, "quux.html#$", "-"),
+        ("foo", "py", "data", -1, "quux.html#$", "-"),
+        ("foo", "py", "data", -1235778, "quux.html#$", "-"),
+        ("foo", "py", "data", 2214888, "quux.html#$", "-"),
+        ("foo bar", "std", "term", 1, "quux.html#$", "-"),  # Space in name
+        ("foo\tbar", "std", "term", 1, "quux.html#$", "-"),  # Valid but discouraged
+        ("Index Page", "std", "doc", 1, "index.html", "-"),
+        ("Index Page", "std", "doc", 1, "index.html", "Index Page Thing"),
+        ("Index Page", "std", "doc", 1, "index.html", "Index\tPage\tThing"),
+        ("Index Page", "std", "doc", 1, "", "-"),  # Zero-length uri
+        ("Index # Page", "std", "doc", 1, "index.html", "-"),  # Symbol in name
+        ("Thing \u33a4", "std", "ref", 1, "index.html#$", "-"),  # Unicode in name
+        ("Thing One", "std", "ref", 1, "index.html#$", "\u33a4"),  # Unicode in dispname
+        ("foo", "py$", "data", 1, "data.html#$", "-"),  # Valid but discouraged
+        ("foo", "py\u33a4", "data", 1, "data.html#$", "-"),  # Valid but discouraged
+        ("foo", "py", "data$", 1, "data.html#$", "-"),  # Valid but discouraged
+        ("foo", "py", "data\u33a4", 1, "data.html#$", "-"),  # Valid but discouraged
+        ("foo", "py", "data", 1, "data/\u33a4.html#$", "-"),  # Valid but discouraged
+        ("  foo", "py", "data", 1, "data.html#$", "-"),  # Valid but discouraged
+    ],
+)
+def test_dataobjstr_valid_objects(
+    misc_info, sphinx_ifile_data_count, name, domain, role, prio, uri, dispname
+):
+    """Run sphobjinv/sphinx comparison on specific object data lines."""
+    dos = soi.DataObjStr(
+        name=name,
+        domain=domain,
+        role=role,
+        priority=str(prio),
+        uri=uri,
+        dispname=dispname,
+    )
+
+    assert dos
+
+    inv = soi.Inventory()
+    inv.project = "Foo"
+    inv.version = "1.0"
+    inv.objects.append(
+        soi.DataObjStr(
+            name="bar", domain="py", role="data", priority="1", uri="$", dispname="-"
+        )
+    )
+    inv.objects.append(dos)
+
+    df = inv.data_file(contract=True)
+
+    ifile_data = IFile.load(BytesIO(soi.compress(df)), "", osp.join)
+
+    ifile_count = sphinx_ifile_data_count(ifile_data)
+
+    assert inv.count == ifile_count
+
+    domrole = "{dos.domain}:{dos.role}".format(dos=dos)
+
+    assert domrole in ifile_data
+    assert dos.name in ifile_data[domrole]
+
+
+@pytest.mark.parametrize(
+    ("name", "domain", "role", "prio", "uri", "dispname"),
+    [
+        ("", "std", "doc", 1, "index.html", "-"),  # Missing name
+        ("foo  ", "py", "data", 1, "data.html#$", "-"),  # Name w/trailing space
+        ("# Index Page", "std", "doc", 1, "index.html", "-"),  # '#' @ name start
+        ("X Y Z 0 foo", "std", "doc", 1, "index.html", "-"),  # Int in name
+        ("foo", "p:y", "data", 1, "data.html#$", "-"),  # Colon in domain
+        ("foo", "py thon", "data", 1, "data.html#$", "-"),  # Space in domain
+        ("foo", "", "data", 1, "data.html#$", "-"),  # Missing domain
+        ("foo", "py", "da:ta", 1, "data.html#$", "-"),  # Colon in role
+        ("foo", "py", "da ta", 1, "data.html#$", "-"),  # Space in role
+        ("foo", "py", "", 1, "data.html#$", "-"),  # Missing role
+        ("foo", "py", "data", 0.5, "data.html#$", "-"),  # Non-integer prio
+        ("foo", "py", "data", "", "data.html#$", "-"),  # Missing prio
+        ("foo", "py", "data", "quux", "data.html#$", "-"),  # Non-numeric prio
+        ("Index Page", "std", "doc", 1, "index.html", ""),  # Missing dispname
+        ("Index Page", "std", "doc", 1, "", ""),  # Missing uri & dispname
+    ],
+)
+def test_dataobjstr_invalid_objects(
+    misc_info, sphinx_ifile_data_count, name, domain, role, prio, uri, dispname
+):
+    """Run sphobjinv/sphinx comparison on specific invalid data lines."""
+    with pytest.raises((AssertionError, zlib.error)):
+        test_dataobjstr_valid_objects(
+            misc_info, sphinx_ifile_data_count, name, domain, role, prio, uri, dispname
+        )
+
+
+def int_to_latin_1(val):
+    """Provide the latin-1 string equivalent of an 8-bit int."""
+    return bytes((val,)).decode("latin-1")
+
+
+def latin_1_id(val):
+    """Provide the value-and-character string for a latin-1 int value."""
+    return str(val) + "_" + int_to_latin_1(val)
+
+
+@pytest.mark.parametrize("leadint", range(255), ids=latin_1_id)
+def test_name_lead_chars(misc_info, sphinx_ifile_data_count, leadint):
+    """Screen for valid/invalid first characters."""
+    name = int_to_latin_1(leadint) + " foo"
+
+    # Expect only two fail cases, newline and '#'
+    if leadint in (10, 35):
+        pytest.xfail("Known invalid name lead char")
+
+    test_dataobjstr_valid_objects(
+        misc_info,
+        sphinx_ifile_data_count,
+        name=name,
+        domain="py",
+        role="data",
+        prio=1,
+        uri="data.html#$",
+        dispname="-",
+    )