Repository: zimeon/ocfl-py (language: python)

Issue:
Harvard 1: Load existing, standalone OCFL object
As a part of our bulk download process, we would like to pull down individual OCFL objects from S3 to local disk, then use ocfl-py to inspect and pull out specific files.

This will involve three new functions in ocfl-py:
1. load individual object
2. ~list files in object (optional version arg, default head)~
3. ~get content (arg: logical path, optional version arg)~

This issue is to design the CLI interaction for step 1.


Discussion / hints:
This is the function I'm not sure about, where is the object loaded to/from?

Actually, I should probably rephrase this ticket (and the other two: https://github.com/zimeon/ocfl-py/issues/106 & https://github.com/zimeon/ocfl-py/issues/107) to remove the "CLI" design comment. I would like to load an object and interact with it by using ocfl-py as an imported library. For this ticket, I would like to load the object into memory as a Python object.
@awoods - I don't think loading an object into memory makes much sense, that could be really big! I certainly understand loading an inventory associated with an object on storage. Currently one can load the inventory and get a disc based on the parsed JSON:

```
>>> import ocfl
>>> object = ocfl.Object(path="fixtures/1.1/good-objects/spec-ex-full")
>>> inv = object.parse_inventory()
>>> inv
{'digestAlgorithm': 'sha512', 'fixity': {'md5': {'184f84e28cbe75e050e9c25ea7f2e939': ['v1/content/foo/bar.xml'], '2673a7b11a70bc7ff960ad8127b4adeb': ['v2/content/foo/bar.xml'], 'c289c8ccd4bab6e385f5afdd89b5bda2': ['v1/content/image.tiff'], 'd41d8cd98f00b204e9800998ecf8427e': ['v1/content/empty.txt']}, 'sha1': {'66709b068a2faead97113559db78ccd44712cbf2': ['v1/content/foo/bar.xml'], 'a6357c99ecc5752931e133227581e914968f3b9c': ['v2/content/foo/bar.xml'], 'b9c7ccc6154974288132b63c15db8d2750716b49': ['v1/content/image.tiff'], 'da39a3ee5e6b4b0d3255bfef95601890afd80709': ['v1/content/empty.txt']}}, 'head': 'v3', 'id': 'ark:/12345/bcd987', 'manifest': {'4d27c86b026ff709b02b05d126cfef7ec3aed5f83f5e98df7d7592f7a44bd1dc7f29509cff06b884158baa36a2bbeda11ab8a64b56585a70f5ce1fa96e26eb53': ['v2/content/foo/bar.xml'], '7dcc352f96c56dc5b094b2492c2866afeb12136a78f0143431ae247d02f02497bbd733e0536d34ec9703eba14c6017ea9f5738322c1d43169f8c77785947ac31': ['v1/content/foo/bar.xml'], 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e': ['v1/content/empty.txt'], 'ffccf6baa21809716f31563fafb9f333c09c336bb7400088f17e4ff307f98fc9b14a577f92f3285913b7f53a6d5cf004503cf839aada1c885ac69336cbfb862e': ['v1/content/image.tiff']}, 'type': 'https://ocfl.io/1.1/spec/#inventory', 'versions': {'v1': {'created': '2018-01-01T01:01:01Z', 'message': 'Initial import', 'state': {'7dcc352f96c56dc5b094b2492c2866afeb12136a78f0143431ae247d02f02497bbd733e0536d34ec9703eba14c6017ea9f5738322c1d43169f8c77785947ac31': ['foo/bar.xml'], 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e': ['empty.txt'], 'ffccf6baa21809716f31563fafb9f333c09c336bb7400088f17e4ff307f98fc9b14a577f92f3285913b7f53a6d5cf004503cf839aada1c885ac69336cbfb862e': ['image.tiff']}, 'user': {'address': 'mailto:alice@example.com', 'name': 'Alice'}}, 'v2': {'created': '2018-02-02T02:02:02Z', 'message': 'Fix bar.xml, remove image.tiff, add empty2.txt', 'state': {'4d27c86b026ff709b02b05d126cfef7ec3aed5f83f5e98df7d7592f7a44bd1dc7f29509cff06b884158baa36a2bbeda11ab8a64b56585a70f5ce1fa96e26eb53': ['foo/bar.xml'], 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e': ['empty.txt', 'empty2.txt']}, 'user': {'address': 'mailto:bob@example.com', 'name': 'Bob'}}, 'v3': {'created': '2018-03-03T03:03:03Z', 'message': 'Reinstate image.tiff, delete empty.txt', 'state': {'4d27c86b026ff709b02b05d126cfef7ec3aed5f83f5e98df7d7592f7a44bd1dc7f29509cff06b884158baa36a2bbeda11ab8a64b56585a70f5ce1fa96e26eb53': ['foo/bar.xml'], 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e': ['empty2.txt'], 'ffccf6baa21809716f31563fafb9f333c09c336bb7400088f17e4ff307f98fc9b14a577f92f3285913b7f53a6d5cf004503cf839aada1c885ac69336cbfb862e': ['image.tiff']}, 'user': {'address': 'mailto:cecilia@example.com', 'name': 'Cecilia'}}}}
>>> inv['digestAlgorithm']
'sha512'
>>> inv['versions']['v1']
{'created': '2018-01-01T01:01:01Z', 'message': 'Initial import', 'state': {'7dcc352f96c56dc5b094b2492c2866afeb12136a78f0143431ae247d02f02497bbd733e0536d34ec9703eba14c6017ea9f5738322c1d43169f8c77785947ac31': ['foo/bar.xml'], 'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e': ['empty.txt'], 'ffccf6baa21809716f31563fafb9f333c09c336bb7400088f17e4ff307f98fc9b14a577f92f3285913b7f53a6d5cf004503cf839aada1c885ac69336cbfb862e': ['image.tiff']}, 'user': {'address': 'mailto:alice@example.com', 'name': 'Alice'}}
```


Agreed, loading the inventory into memory versus the entire object with content files makes sense. Our use cases for this involve an OCFL repository that is created and managed by a separate application and we need a Python library to help read/inspect existing OCFL objects.

For example, we have the need to ask an OCFL object (or its inventory) for the "content path" of a specific file for which we have the "logical path". We do not know/care in which version the file was created nor do we know/care if the logical file was de-duplicated.

Ideally, the Python code importing an ocfl-py module would not need to parse/understand the underlying JSON structure of OCFL inventories.

Fix patch:
diff --git a/CHANGES.md b/CHANGES.md
index 2782a72..4af8ff2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,13 +2,15 @@
 
 ## 2024-XX-XX v2.0.0
 
-  * SIGNIFICANT REWORKING to align better with specification language and approach. Includes refactoring, command line tool changes, and API changes.
+  * SIGNIFICANTLY REWRITTEN AND REFACTORED to align better with the specification language and approach. Includes refactoring, command line tool changes, and many API changes. The InventoryValidator class is relatively unchanged, however
+  * Improved docstrings in many places
   * Change from "1.0" to "1.1" as default specification version
   * Adjust for removed `E023_missing_file` fixture (https://github.com/OCFL/fixtures/pull/99)
   * Add note of specification version number in validation output
   * Ignore `logs` directory if present in object root (extra fixture https://github.com/OCFL/fixtures/issues/108)
   * Changed Object.show() method to Object.tree() that returns string
   * Change validator to use `force_spec_version` to validate against a specific version and remove old `extract_spec_version` with that becoming the default behavior unless force is specified
+  * Code style change to use double quotes for strings unless there is a good reason to use single quotes
   * Drop Python 3.6 and 3.7 from testing, add 3.11
 
 ## 2022-04-26 v1.3.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6fda01b..80cb5c7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,5 +25,8 @@ If submitting a pull request:
        * [Checks already implemented in `pycodestyle` and `pydocstyle`](http://pylint.pycqa.org/en/latest/faq.html#i-am-using-another-popular-linter-alongside-pylint-which-messages-should-i-disable-to-avoid-duplicates)
        * A FIXME doesn't generate and error, but please avoid anyway (W0511)
        * See `.github/workflows/action.yml` for current exclusions
-   * Please don't repeat code
+  * Other style choices not enforced:
+    * Use double quotes (") and triple-double quotes (""") for strings unless
+      there is reason to avoid having to quote these inside
+  * Please don't repeat code
    * Please cover the code with tests
diff --git a/compactify_spec_examples.py b/compactify_spec_examples.py
index 896e421..eca4040 100755
--- a/compactify_spec_examples.py
+++ b/compactify_spec_examples.py
@@ -4,17 +4,27 @@
 Takes the long digests and replaces them with first 6 characters,
 an ellipsis, and then the last 6 characters. Input from stdin
 and output to stdout.
+
+Typical usage:
+> cat example.json | ./compactify_spec_examples.py > example_comp.json
 """
 import re
 import sys
 
-txt = sys.stdin.read()
 
-txt = re.sub(r'''sha512-spec-ex''', 'sha512', txt)
-txt = re.sub(r'''"([\da-f]{6})([\da-f]{119})([\da-f]{3})"''', r'''"\1...\3"''', txt)
-txt = re.sub(r'''\s+\[\s+"''', ' [ "', txt, flags=re.MULTILINE)
-txt = re.sub(r'''"\s+\](\s+)}(,)?''', r'''" ]\1}\2''', txt, flags=re.MULTILINE)
-txt = re.sub(r'''\s+],''', ' ],', txt, flags=re.MULTILINE)
-txt = re.sub(r'''(\[[^\n,\]]+,)\s+''', r'''\1 ''', txt, flags=re.MULTILINE)
+def compactify():
+    """Read from stdin and return compactified example to stdout."""
+    txt = sys.stdin.read()
+    #
+    txt = re.sub(r'''sha512-spec-ex''', 'sha512', txt)
+    txt = re.sub(r'''"([\da-f]{6})([\da-f]{119})([\da-f]{3})"''', r'''"\1...\3"''', txt)
+    txt = re.sub(r'''\s+\[\s+"''', ' [ "', txt, flags=re.MULTILINE)
+    txt = re.sub(r'''"\s+\](\s+)}(,)?''', r'''" ]\1}\2''', txt, flags=re.MULTILINE)
+    txt = re.sub(r'''\s+],''', ' ],', txt, flags=re.MULTILINE)
+    txt = re.sub(r'''(\[[^\n,\]]+,)\s+''', r'''\1 ''', txt, flags=re.MULTILINE)
+    #
+    print(txt)
+
 
-print(txt)
+if __name__ == "__main__":
+    compactify()
diff --git a/docs/demo_build_v1_0_spec_examples.md b/docs/demo_build_v1_0_spec_examples.md
index f84bf7a..4a614fd 100644
--- a/docs/demo_build_v1_0_spec_examples.md
+++ b/docs/demo_build_v1_0_spec_examples.md
@@ -9,7 +9,7 @@ _Output from `tests/test_demo_build_spec_v1_0_examples.py`._
 The digest type sha512-spec-ex is sha512 with most of the content stripped out and replaced with an ellipsis. This is inventory should match the example in <https://ocfl.io/1.0/spec/#example-minimal-object>.
 
 ```
-> python ocfl-object.py build --src fixtures/1.0/content/spec-ex-minimal --id http://example.org/minimal --spec-version 1.0 --digest sha512-spec-ex --created 2018-10-02T12:00:00Z --message One file --name Alice --address alice@example.org -v
+> python ocfl-object.py create --src fixtures/1.0/content/spec-ex-minimal --id http://example.org/minimal --spec-version 1.0 --digest sha512-spec-ex --created 2018-10-02T12:00:00Z --message One file --name Alice --address alice@example.org -v
 ### Inventory for v1
 {
   "digestAlgorithm": "sha512-spec-ex",
@@ -17,7 +17,7 @@ The digest type sha512-spec-ex is sha512 with most of the content stripped out a
   "id": "http://example.org/minimal",
   "manifest": {
     "7545b8720a60123...f67": [
-      "v1/content/file.txt"
+      "v1/content/v1/file.txt"
     ]
   },
   "type": "https://ocfl.io/1.0/spec/#inventory",
@@ -27,7 +27,7 @@ The digest type sha512-spec-ex is sha512 with most of the content stripped out a
       "message": "One file",
       "state": {
         "7545b8720a60123...f67": [
-          "file.txt"
+          "v1/file.txt"
         ]
       },
       "user": {
@@ -47,7 +47,7 @@ The digest type sha512-spec-ex is sha512 with most of the content stripped out a
 This is inventory should match the example with 3 versions in <https://ocfl.io/1.0/spec/#example-versioned-object>.
 
 ```
-> python ocfl-object.py build --src fixtures/1.0/content/spec-ex-full --spec-version 1.0 --id ark:/12345/bcd987 --fixity md5 --fixity sha1 --digest sha512-spec-ex -v
+> python ocfl-object.py build --src fixtures/1.0/content/spec-ex-full --spec-version 1.0 --id ark:/12345/bcd987 --fixity md5 --fixity sha1 --digest sha512-spec-ex --metadata extra_fixtures/1.0/content/spec-ex-full-metadata.json -v
 ### Inventory for v3
 {
   "digestAlgorithm": "sha512-spec-ex",
@@ -100,7 +100,8 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
   "type": "https://ocfl.io/1.0/spec/#inventory",
   "versions": {
     "v1": {
-      "created": "2024-10-24T18:30:33.138005Z",
+      "created": "2018-01-01T01:01:01Z",
+      "message": "Initial import",
       "state": {
         "7dcc352f96c56dc...c31": [
           "foo/bar.xml"
@@ -111,10 +112,15 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
         "ffccf6baa218097...62e": [
           "image.tiff"
         ]
+      },
+      "user": {
+        "address": "alice@example.com",
+        "name": "Alice"
       }
     },
     "v2": {
-      "created": "2024-10-24T18:30:33.138439Z",
+      "created": "2018-02-02T02:02:02Z",
+      "message": "Fix bar.xml, remove image.tiff, add empty2.txt",
       "state": {
         "4d27c86b026ff70...b53": [
           "foo/bar.xml"
@@ -123,10 +129,15 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
           "empty.txt",
           "empty2.txt"
         ]
+      },
+      "user": {
+        "address": "bob@example.com",
+        "name": "Bob"
       }
     },
     "v3": {
-      "created": "2024-10-24T18:30:33.138771Z",
+      "created": "2018-03-03T03:03:03Z",
+      "message": "Reinstate image.tiff, delete empty.txt",
       "state": {
         "4d27c86b026ff70...b53": [
           "foo/bar.xml"
@@ -137,6 +148,10 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
         "ffccf6baa218097...62e": [
           "image.tiff"
         ]
+      },
+      "user": {
+        "address": "cecilia@example.com",
+        "name": "Cecilia"
       }
     }
   }
@@ -151,7 +166,7 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
 This is inventory should match the example showing how content paths may differ from logical paths in <https://ocfl.io/1.0/spec/#example-object-diff-paths>.
 
 ```
-> python ocfl-object.py build --src fixtures/1.0/content/spec-ex-diff-paths --id http://example.org/diff-paths --spec-version 1.0 --digest sha512-spec-ex --normalization md5 --created 2019-03-14T20:31:00Z -v
+> python ocfl-object.py create --src fixtures/1.0/content/spec-ex-diff-paths/v1 --id http://example.org/diff-paths --spec-version 1.0 --digest sha512-spec-ex --normalization md5 --created 2019-03-14T20:31:00Z -v
 ### Inventory for v1
 {
   "digestAlgorithm": "sha512-spec-ex",
diff --git a/docs/demo_build_v1_1_spec_examples.md b/docs/demo_build_v1_1_spec_examples.md
index 99826f9..a9b38df 100644
--- a/docs/demo_build_v1_1_spec_examples.md
+++ b/docs/demo_build_v1_1_spec_examples.md
@@ -9,7 +9,7 @@ _Output from `tests/test_demo_build_spec_v1_1_examples.py`._
 The digest type sha512-spec-ex is sha512 with most of the content stripped out and replaced with an ellipsis. This is inventory should match the example in <https://ocfl.io/1.1/spec/#example-minimal-object>.
 
 ```
-> python ocfl-object.py build --src fixtures/1.1/content/spec-ex-minimal --id http://example.org/minimal --spec-version 1.1 --digest sha512-spec-ex --created 2018-10-02T12:00:00Z --message One file --name Alice --address alice@example.org -v
+> python ocfl-object.py create --src fixtures/1.1/content/spec-ex-minimal --id http://example.org/minimal --spec-version 1.1 --digest sha512-spec-ex --created 2018-10-02T12:00:00Z --message One file --name Alice --address alice@example.org -v
 ### Inventory for v1
 {
   "digestAlgorithm": "sha512-spec-ex",
@@ -17,7 +17,7 @@ The digest type sha512-spec-ex is sha512 with most of the content stripped out a
   "id": "http://example.org/minimal",
   "manifest": {
     "7545b8720a60123...f67": [
-      "v1/content/file.txt"
+      "v1/content/v1/file.txt"
     ]
   },
   "type": "https://ocfl.io/1.1/spec/#inventory",
@@ -27,7 +27,7 @@ The digest type sha512-spec-ex is sha512 with most of the content stripped out a
       "message": "One file",
       "state": {
         "7545b8720a60123...f67": [
-          "file.txt"
+          "v1/file.txt"
         ]
       },
       "user": {
@@ -47,7 +47,7 @@ The digest type sha512-spec-ex is sha512 with most of the content stripped out a
 This is inventory should match the example with 3 versions in <https://ocfl.io/1.1/spec/#example-versioned-object>.
 
 ```
-> python ocfl-object.py build --src fixtures/1.1/content/spec-ex-full --spec-version 1.1 --id ark:/12345/bcd987 --fixity md5 --fixity sha1 --digest sha512-spec-ex -v
+> python ocfl-object.py build --src fixtures/1.1/content/spec-ex-full --spec-version 1.1 --id ark:/12345/bcd987 --fixity md5 --fixity sha1 --digest sha512-spec-ex --metadata extra_fixtures/1.1/content/spec-ex-full-metadata.json -v
 ### Inventory for v3
 {
   "digestAlgorithm": "sha512-spec-ex",
@@ -100,7 +100,8 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
   "type": "https://ocfl.io/1.1/spec/#inventory",
   "versions": {
     "v1": {
-      "created": "2024-10-24T18:30:33.910644Z",
+      "created": "2018-01-01T01:01:01Z",
+      "message": "Initial import",
       "state": {
         "7dcc352f96c56dc...c31": [
           "foo/bar.xml"
@@ -111,10 +112,15 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
         "ffccf6baa218097...62e": [
           "image.tiff"
         ]
+      },
+      "user": {
+        "address": "alice@example.com",
+        "name": "Alice"
       }
     },
     "v2": {
-      "created": "2024-10-24T18:30:33.911054Z",
+      "created": "2018-02-02T02:02:02Z",
+      "message": "Fix bar.xml, remove image.tiff, add empty2.txt",
       "state": {
         "4d27c86b026ff70...b53": [
           "foo/bar.xml"
@@ -123,10 +129,15 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
           "empty.txt",
           "empty2.txt"
         ]
+      },
+      "user": {
+        "address": "bob@example.com",
+        "name": "Bob"
       }
     },
     "v3": {
-      "created": "2024-10-24T18:30:33.911365Z",
+      "created": "2018-03-03T03:03:03Z",
+      "message": "Reinstate image.tiff, delete empty.txt",
       "state": {
         "4d27c86b026ff70...b53": [
           "foo/bar.xml"
@@ -137,6 +148,10 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
         "ffccf6baa218097...62e": [
           "image.tiff"
         ]
+      },
+      "user": {
+        "address": "cecilia@example.com",
+        "name": "Cecilia"
       }
     }
   }
@@ -151,12 +166,12 @@ This is inventory should match the example with 3 versions in <https://ocfl.io/1
 This is inventory should match the example showing how content paths may differ from logical paths in <https://ocfl.io/1.1/spec/#example-object-diff-paths>.
 
 ```
-> python ocfl-object.py build --src fixtures/1.1/content/spec-ex-diff-paths --id http://example.org/diff-paths --spec-version 1.1 --digest sha512-spec-ex --normalization md5 --created 2019-03-14T20:31:00Z -v
+> python ocfl-object.py create --src fixtures/1.1/content/spec-ex-diff-paths/v1 --id http://example.org/diff-paths/v1 --spec-version 1.1 --digest sha512-spec-ex --normalization md5 --created 2019-03-14T20:31:00Z -v
 ### Inventory for v1
 {
   "digestAlgorithm": "sha512-spec-ex",
   "head": "v1",
-  "id": "http://example.org/diff-paths",
+  "id": "http://example.org/diff-paths/v1",
   "manifest": {
     "7545b8720a60123...f67": [
       "v1/content/3bacb119a98a15c5"
diff --git a/docs/demo_ocfl_object_script.md b/docs/demo_ocfl_object_script.md
index 810048d..9f07a01 100644
--- a/docs/demo_ocfl_object_script.md
+++ b/docs/demo_ocfl_object_script.md
@@ -21,7 +21,7 @@ ocfl-object.py is part of ocfl-py version 1.9.0
 Without an `--objdir` argument the script just writes out the inventory for the object that would have been created.
 
 ```
-> python ocfl-object.py create --id http://example.org/obj1 --src fixtures/1.0/content/cf1/v1
+> python ocfl-object.py create --id http://example.org/obj1 --src fixtures/1.0/content/cf1/v1 --created 2024-10-24T18:30:01Z
 ### Inventory for v1
 {
   "digestAlgorithm": "sha512",
@@ -35,7 +35,7 @@ Without an `--objdir` argument the script just writes out the inventory for the
   "type": "https://ocfl.io/1.1/spec/#inventory",
   "versions": {
     "v1": {
-      "created": "2024-10-24T18:30:25.012110Z",
+      "created": "2024-10-24T18:30:01Z",
       "state": {
         "43a43fe8a8a082d3b5343dfaf2fd0c8b8e370675b1f376e92e9994612c33ea255b11298269d72f797399ebb94edeefe53df243643676548f584fb8603ca53a0f": [
           "a_file.txt"
@@ -52,7 +52,7 @@ Without an `--objdir` argument the script just writes out the inventory for the
 Without an `--objdir` argument the script just writes out the inventory for each version in the object that would have been created.
 
 ```
-> python ocfl-object.py build --id http://example.org/obj2 --src fixtures/1.0/content/cf3
+> python ocfl-object.py build --id http://example.org/obj2 --src fixtures/1.0/content/cf3 --metadata extra_fixtures/1.0/content/spec-ex-full-metadata.json
 ### Inventory for v3
 {
   "digestAlgorithm": "sha512",
@@ -69,27 +69,42 @@ Without an `--objdir` argument the script just writes out the inventory for each
   "type": "https://ocfl.io/1.1/spec/#inventory",
   "versions": {
     "v1": {
-      "created": "2024-10-24T18:30:25.254643Z",
+      "created": "2018-01-01T01:01:01Z",
+      "message": "Initial import",
       "state": {
         "43a43fe8a8a082d3b5343dfaf2fd0c8b8e370675b1f376e92e9994612c33ea255b11298269d72f797399ebb94edeefe53df243643676548f584fb8603ca53a0f": [
           "a_file.txt"
         ]
+      },
+      "user": {
+        "address": "alice@example.com",
+        "name": "Alice"
       }
     },
     "v2": {
-      "created": "2024-10-24T18:30:25.254794Z",
+      "created": "2018-02-02T02:02:02Z",
+      "message": "Fix bar.xml, remove image.tiff, add empty2.txt",
       "state": {
         "296e72b8fd5f7f0ac1473993600ae34953d5dab646f17e7b182b8648aff830d7bf01b56490777cb3e72b33fcc1ae520506badea1032252d1a55fd7362e269975": [
           "a_file.txt"
         ]
+      },
+      "user": {
+        "address": "bob@example.com",
+        "name": "Bob"
       }
     },
     "v3": {
-      "created": "2024-10-24T18:30:25.254921Z",
+      "created": "2018-03-03T03:03:03Z",
+      "message": "Reinstate image.tiff, delete empty.txt",
       "state": {
         "43a43fe8a8a082d3b5343dfaf2fd0c8b8e370675b1f376e92e9994612c33ea255b11298269d72f797399ebb94edeefe53df243643676548f584fb8603ca53a0f": [
           "a_file.txt"
         ]
+      },
+      "user": {
+        "address": "cecilia@example.com",
+        "name": "Cecilia"
       }
     }
   }
@@ -102,7 +117,7 @@ Without an `--objdir` argument the script just writes out the inventory for each
 ### 3.1 New object with just v1
 
 ```
-> python ocfl-object.py create --id http://example.org/obj1 --src fixtures/1.0/content/cf1/v1 --objdir tmp/obj1 -v
+> python ocfl-object.py create --id http://example.org/obj1 --src fixtures/1.0/content/cf1/v1 --objdir tmp/obj1 --created 2024-10-24T18:30:03Z -v
 INFO:root:Created OCFL object http://example.org/obj1 in tmp/obj1
 ```
 
@@ -132,14 +147,12 @@ Extracted content for v1 in tmp/v1
 and the extracted files are:
 
 ```
-> find tmp/v1 -print
-tmp/v1
-tmp/v1/image.tiff
-tmp/v1/empty.txt
-tmp/v1/foo
-tmp/v1/foo/bar.xml
+> find -s tmp/v1 -print
+find: unknown predicate `-s'
 ```
 
+(last command exited with return code 1)
+
 
 ### 5.2 Extract v2 of content in an OCFL v1.1 object
 
@@ -152,14 +165,12 @@ Extracted content for v2 in tmp/v2
 and the extracted files are:
 
 ```
-> find tmp/v2 -print
-tmp/v2
-tmp/v2/empty2.txt
-tmp/v2/empty.txt
-tmp/v2/foo
-tmp/v2/foo/bar.xml
+> find -s tmp/v2 -print
+find: unknown predicate `-s'
 ```
 
+(last command exited with return code 1)
+
 
 ### 5.3 Extract head version (v3) of content in the same OCFL v1.1 object
 
@@ -172,8 +183,8 @@ Extracted content for v3 in tmp/head
 and the extracted files are:
 
 ```
-> find tmp/v3 -print
-find: ‘tmp/v3’: No such file or directory
+> find -s tmp/v3 -print
+find: unknown predicate `-s'
 ```
 
 (last command exited with return code 1)
@@ -189,11 +200,12 @@ Extracted foo/bar.xml in v3 to tmp/files
 and the extracted file is:
 
 ```
-> find tmp/files -print
-tmp/files
-tmp/files/bar.xml
+> find -s tmp/files -print
+find: unknown predicate `-s'
 ```
 
+(last command exited with return code 1)
+
 
 ### 5.5 Extract image.tiff of v3 (default) into the same directory
 
@@ -205,12 +217,12 @@ Extracted image.tiff in v3 to tmp/files
 and the directory now contains two extracted files:
 
 ```
-> find tmp/files -print
-tmp/files
-tmp/files/image.tiff
-tmp/files/bar.xml
+> find -s tmp/files -print
+find: unknown predicate `-s'
 ```
 
+(last command exited with return code 1)
+
 
 ## 6. Test error conditions.
 
diff --git a/docs/demo_using_bagit_bags.md b/docs/demo_using_bagit_bags.md
index 4a1c410..c85d3b2 100644
--- a/docs/demo_using_bagit_bags.md
+++ b/docs/demo_using_bagit_bags.md
@@ -161,8 +161,8 @@ Taking the newly created OCFL object `/tmp/obj` we can `--extract` the `v4` cont
 INFO:root:Extracted v4 into tmp/extracted_v4
 INFO:bagit:Creating bag for directory tmp/extracted_v4
 INFO:bagit:Creating data directory
-INFO:bagit:Moving my_content to tmp/extracted_v4/tmpoa3md5vo/my_content
-INFO:bagit:Moving tmp/extracted_v4/tmpoa3md5vo to data
+INFO:bagit:Moving my_content to tmp/extracted_v4/tmp0vu88z7y/my_content
+INFO:bagit:Moving tmp/extracted_v4/tmp0vu88z7y to data
 INFO:bagit:Using 1 processes to generate manifests: sha512
 INFO:bagit:Generating manifest lines for file data/my_content/dracula.txt
 INFO:bagit:Generating manifest lines for file data/my_content/dunwich.txt
@@ -184,12 +184,12 @@ We note that the OCFL object had only one `content` file in `v4` but the extract
 diff -r tmp/extracted_v4/bag-info.txt tests/testdata/bags/uaa_v4/bag-info.txt
 1,2c1
 < Bag-Software-Agent: bagit.py v1.8.1 <https://github.com/LibraryOfCongress/bagit-python>
-< Bagging-Date: 2024-10-24
+< Bagging-Date: 2024-11-03
 ---
 > Bagging-Date: 2020-01-04
 diff -r tmp/extracted_v4/tagmanifest-sha512.txt tests/testdata/bags/uaa_v4/tagmanifest-sha512.txt
 2c2
-< 5194634462c7a05762477f269e763f47939695275b709649cc1ecb598777b042cf735ecf319a67882c37ddc726509ec6f3f1746c2ffc0e2dc4e4fc9775c746a1 bag-info.txt
+< 3379553ab3cdfee56b1ebe6dba9118e340fc3b7fcc5ff9aa76ee7123ad7cad89842cb47a0861afe43a1c0cbcd208bd42b4b9790d7b4525cb0b6299cc76fdf817 bag-info.txt
 ---
 > 10624e6d45462def7af66d1a0d977606c7b073b01809c1d42258cfab5c34a275480943cbe78044416aee1f23822cc3762f92247b8f39b5c6ddc5ae32a8f94ce5 bag-info.txt
 ```
diff --git a/docs/source/conf.py b/docs/source/conf.py
deleted file mode 100644
index 8f28826..0000000
--- a/docs/source/conf.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# Configuration file for the Sphinx documentation builder.
-
-# -- Project information
-
-project = 'ocfl-py'
-
-# -- General configuration
-
-extensions = [
-    "myst_parser",
-    'sphinx.ext.duration',
-    'sphinx.ext.doctest',
-    'sphinx.ext.autodoc',
-    'sphinx.ext.autosummary',
-    'sphinx.ext.intersphinx',
-]
-
-autodoc_default_options = {
-    'members': 'ocfl',
-    'member-order': 'bysource',
-    'special-members': '__init__',
-    'undoc-members': True,
-    'exclude-members': '__weakref__'
-}
-
-intersphinx_mapping = {
-    'python': ('https://docs.python.org/3/', None),
-    'sphinx': ('https://www.sphinx-doc.org/en/master/', None),
-}
-intersphinx_disabled_domains = ['std']
-
-templates_path = ['_templates']
-
-# -- Options for HTML output
-
-html_theme = 'sphinx_rtd_theme'
diff --git a/docs/validation_status.md b/docs/validation_status.md
index 85b53e2..a439745 100644
--- a/docs/validation_status.md
+++ b/docs/validation_status.md
@@ -7,129 +7,129 @@ The following tables show the implementation status of all errors and warnings i
 | Code | Specification text (or suffixed code) | Implementation status and message/links |
 | --- | --- | --- |
 | [E001](https://ocfl.io/1.1/spec#E001) | 'The OCFL Object Root must not contain files or directories other than those specified in the following sections.' | **Missing description** \[[ocfl/validation_logger.py#L5](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validation_logger.py#L5)\] |
-| | E001a | OCFL Object root contains unexpected file: %s \[[ocfl/validation_logger.py#L10](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validation_logger.py#L10) [ocfl/validator.py#L244](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L244)\] |
-| | E001b | OCFL Object root contains unexpected directory: %s \[[ocfl/validation_logger.py#L16](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validation_logger.py#L16) [ocfl/validator.py#L261](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L261)\] |
-| | E001c | OCFL Object root contains unexpected entry that isn't a file or directory: %s \[[ocfl/validator.py#L263](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L263)\] |
+| | E001a | OCFL Object root contains unexpected file: %s \[[ocfl/validation_logger.py#L10](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validation_logger.py#L10) [ocfl/validator.py#L247](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L247)\] |
+| | E001b | OCFL Object root contains unexpected directory: %s \[[ocfl/validation_logger.py#L16](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validation_logger.py#L16) [ocfl/validator.py#L264](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L264)\] |
+| | E001c | OCFL Object root contains unexpected entry that isn't a file or directory: %s \[[ocfl/validator.py#L266](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L266)\] |
 | [E002](https://ocfl.io/1.1/spec#E002) | 'The version declaration must be formatted according to the NAMASTE specification.' | NOTE - E002 is redundant to more specific errors E003, E004, E005, E006. \[_Not implemented_\] |
 | [E003](https://ocfl.io/1.1/spec#E003) | 'There must be exactly one version declaration file in the base directory of the OCFL Object Root giving the OCFL version in the filename.' | _See multiple cases identified with suffixes below_ |
-| | E003a | OCFL Object version declaration file is missing (assuming version %s) \[[ocfl/validator.py#L127](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L127)\] |
-| | E003b | OCFL Object includes more that one file that looks like an object declaration (got %s), using version %s \[[ocfl/validator.py#L149](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L149)\] |
-| | E003c | OCFL Object includes one or move object declaration files (starting 0=) but no valid version number could be extracted from any of them (assuming version %s) \[[ocfl/validator.py#L144](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L144)\] |
+| | E003a | OCFL Object version declaration file is missing (assuming version %s) \[[ocfl/validator.py#L130](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L130)\] |
+| | E003b | OCFL Object includes more that one file that looks like an object declaration (got %s), using version %s \[[ocfl/validator.py#L152](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L152)\] |
+| | E003c | OCFL Object includes one or move object declaration files (starting 0=) but no valid version number could be extracted from any of them (assuming version %s) \[[ocfl/validator.py#L147](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L147)\] |
 | | E003d | OCFL Storage Root hierarchy includes directory %s with more that one file that looks like an object declaration, ignoring \[[ocfl/storage_root.py#L249](https://github.com/zimeon/ocfl-py/blob/main/ocfl/storage_root.py#L249)\] |
-| | E003e | No OCFL Object to validate at path %s. The root of an OCFL Object must be a directory containing an object declaration \[[ocfl/validator.py#L121](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L121)\] |
+| | E003e | No OCFL Object to validate at path %s. The root of an OCFL Object must be a directory containing an object declaration \[[ocfl/validator.py#L124](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L124)\] |
 | [E004](https://ocfl.io/1.1/spec#E004) | 'The [version declaration] filename MUST conform to the pattern T=dvalue, where T must be 0, and dvalue must be ocfl_object_, followed by the OCFL specification version number.' | _See multiple cases identified with suffixes below_ |
 | | E004a | OCFL Storage Root hierarchy includes directory %s with an object declaration giving unknown version %s, ignoring \[[ocfl/storage_root.py#L256](https://github.com/zimeon/ocfl-py/blob/main/ocfl/storage_root.py#L256)\] |
 | | E004b | OCFL Storage Root hierarchy includes directory %s with an unrecognized object declaration %s, ignoring \[[ocfl/storage_root.py#L258](https://github.com/zimeon/ocfl-py/blob/main/ocfl/storage_root.py#L258)\] |
 | [E005](https://ocfl.io/1.1/spec#E005) | 'The [version declaration] filename must conform to the pattern T=dvalue, where T MUST be 0, and dvalue must be ocfl_object_, followed by the OCFL specification version number.' | _Not implemented_ |
-| [E006](https://ocfl.io/1.1/spec#E006) | 'The [version declaration] filename must conform to the pattern T=dvalue, where T must be 0, and dvalue MUST be ocfl_object_, followed by the OCFL specification version number.' | The OCFL object conformance declaration filename must be 0=ocfl_object_, followed by the OCFL specification version number, got %s instead \[[ocfl/validator.py#L138](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L138)\] |
-| [E007](https://ocfl.io/1.1/spec#E007) | 'The text contents of the [version declaration] file must be the same as dvalue, followed by a newline (\n).' | OCFL Object declaration file %s contents do not match file name without leading 0= (the 'dvalue') \[[ocfl/validator.py#L142](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L142)\] |
-| [E008](https://ocfl.io/1.1/spec#E008) | 'OCFL Object content must be stored as a sequence of one or more versions.' | OCFL Object %s inventory versions block does not contain any versions, there must be at least version 1 \[[ocfl/inventory_validator.py#L267](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L267)\] |
-| [E009](https://ocfl.io/1.1/spec#E009) | 'The version number sequence MUST start at 1 and must be continuous without missing integers.' | OCFL Object %s inventory versions block does not contain v1 or a zero padded equivalent \[[ocfl/inventory_validator.py#L287](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L287)\] |
-| [E010](https://ocfl.io/1.1/spec#E010) | 'The version number sequence must start at 1 and MUST be continuous without missing integers.' | OCFL Object %s inventory versions block includes an out-of-sequence version \[[ocfl/inventory_validator.py#L298](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L298)\] |
-| [E011](https://ocfl.io/1.1/spec#E011) | 'If zero-padded version directory numbers are used then they must start with the prefix v and then a zero.' | **Missing description** \[[ocfl/inventory_validator.py#L305](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L305)\] |
+| [E006](https://ocfl.io/1.1/spec#E006) | 'The [version declaration] filename must conform to the pattern T=dvalue, where T must be 0, and dvalue MUST be ocfl_object_, followed by the OCFL specification version number.' | The OCFL object conformance declaration filename must be 0=ocfl_object_, followed by the OCFL specification version number, got %s instead \[[ocfl/validator.py#L141](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L141)\] |
+| [E007](https://ocfl.io/1.1/spec#E007) | 'The text contents of the [version declaration] file must be the same as dvalue, followed by a newline (\n).' | OCFL Object declaration file %s contents do not match file name without leading 0= (the 'dvalue') \[[ocfl/validator.py#L145](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L145)\] |
+| [E008](https://ocfl.io/1.1/spec#E008) | 'OCFL Object content must be stored as a sequence of one or more versions.' | OCFL Object %s inventory versions block does not contain any versions, there must be at least version 1 \[[ocfl/inventory_validator.py#L281](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L281)\] |
+| [E009](https://ocfl.io/1.1/spec#E009) | 'The version number sequence MUST start at 1 and must be continuous without missing integers.' | OCFL Object %s inventory versions block does not contain v1 or a zero padded equivalent \[[ocfl/inventory_validator.py#L301](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L301)\] |
+| [E010](https://ocfl.io/1.1/spec#E010) | 'The version number sequence must start at 1 and MUST be continuous without missing integers.' | OCFL Object %s inventory versions block includes an out-of-sequence version \[[ocfl/inventory_validator.py#L312](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L312)\] |
+| [E011](https://ocfl.io/1.1/spec#E011) | 'If zero-padded version directory numbers are used then they must start with the prefix v and then a zero.' | **Missing description** \[[ocfl/inventory_validator.py#L319](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L319)\] |
 | [E012](https://ocfl.io/1.1/spec#E012) | 'All version directories of an object must use the same naming convention: either a non-padded version directory number, or a zero-padded version directory number of consistent length.' | _Not implemented_ |
 | [E013](https://ocfl.io/1.1/spec#E013) | 'Operations that add a new version to an object must follow the version directory naming convention established by earlier versions.' | _Not implemented_ |
 | [E014](https://ocfl.io/1.1/spec#E014) | 'In all cases, references to files inside version directories from inventory files must use the actual version directory names.' | _Not implemented_ |
-| [E015](https://ocfl.io/1.1/spec#E015) | 'There must be no other files as children of a version directory, other than an inventory file and a inventory digest.' | OCFL Object version directory %s includes an illegal file (%s) \[[ocfl/validator.py#L421](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L421)\] |
+| [E015](https://ocfl.io/1.1/spec#E015) | 'There must be no other files as children of a version directory, other than an inventory file and a inventory digest.' | OCFL Object version directory %s includes an illegal file (%s) \[[ocfl/validator.py#L424](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L424)\] |
 | [E016](https://ocfl.io/1.1/spec#E016) | 'Version directories must contain a designated content sub-directory if the version contains files to be preserved, and should not contain this sub-directory otherwise.' | _Not implemented_ |
-| [E017](https://ocfl.io/1.1/spec#E017) | 'The contentDirectory value MUST NOT contain the forward slash (/) path separator and must not be either one or two periods (. or ..).' | OCFL Object %s inventory contentDirectory must be a string and must not contain a forward slash (/) \[[ocfl/inventory_validator.py#L126](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L126)\] |
-| [E018](https://ocfl.io/1.1/spec#E018) | 'The contentDirectory value must not contain the forward slash (/) path separator and MUST NOT be either one or two periods (. or ..).' | OCFL Object %s inventory contentDirectory must not be either . or .. \[[ocfl/inventory_validator.py#L128](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L128)\] |
-| [E019](https://ocfl.io/1.1/spec#E019) | 'If the key contentDirectory is set, it MUST be set in the first version of the object and must not change between versions of the same object.' | OCFL Object %s inventory sets contentDirectory whereas it was not set in the first version inventory \[[ocfl/validator.py#L332](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L332) [ocfl/validator.py#L340](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L340)\] |
-| [E020](https://ocfl.io/1.1/spec#E020) | 'If the key contentDirectory is set, it must be set in the first version of the object and MUST NOT change between versions of the same object.' | OCFL Object %s inventory contentDirectory %s does not match root contentDirectory %s \[[ocfl/validator.py#L343](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L343)\] |
+| [E017](https://ocfl.io/1.1/spec#E017) | 'The contentDirectory value MUST NOT contain the forward slash (/) path separator and must not be either one or two periods (. or ..).' | OCFL Object %s inventory contentDirectory must be a string and must not contain a forward slash (/) \[[ocfl/inventory_validator.py#L140](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L140)\] |
+| [E018](https://ocfl.io/1.1/spec#E018) | 'The contentDirectory value must not contain the forward slash (/) path separator and MUST NOT be either one or two periods (. or ..).' | OCFL Object %s inventory contentDirectory must not be either . or .. \[[ocfl/inventory_validator.py#L142](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L142)\] |
+| [E019](https://ocfl.io/1.1/spec#E019) | 'If the key contentDirectory is set, it MUST be set in the first version of the object and must not change between versions of the same object.' | OCFL Object %s inventory sets contentDirectory whereas it was not set in the first version inventory \[[ocfl/validator.py#L335](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L335) [ocfl/validator.py#L343](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L343)\] |
+| [E020](https://ocfl.io/1.1/spec#E020) | 'If the key contentDirectory is set, it must be set in the first version of the object and MUST NOT change between versions of the same object.' | OCFL Object %s inventory contentDirectory %s does not match root contentDirectory %s \[[ocfl/validator.py#L346](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L346)\] |
 | [E021](https://ocfl.io/1.1/spec#E021) | 'If the key contentDirectory is not present in the inventory file then the name of the designated content sub-directory must be content.' | _Not implemented_ |
 | [E022](https://ocfl.io/1.1/spec#E022) | 'OCFL-compliant tools (including any validators) must ignore all directories in the object version directory except for the designated content directory.' | _Not implemented_ |
 | [E023](https://ocfl.io/1.1/spec#E023) | 'Every file within a version\'s content directory must be referenced in the manifest section of the inventory.' | _See multiple cases identified with suffixes below_ |
-| | E023a | OCFL Object includes one or more files that are not mentioned in the  %s inventory manifest (%s) \[[ocfl/validator.py#L459](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L459)\] |
-| | E023b | OCFL Object %s manifest does not include files listed in previous version manifests (%s) \[[ocfl/validator.py#L353](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L353)\] |
-| [E024](https://ocfl.io/1.1/spec#E024) | 'There must not be empty directories within a version\'s content directory.' | OCFL Object version %s content directory includes empty path %s \[[ocfl/validator.py#L412](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L412)\] |
+| | E023a | OCFL Object includes one or more files that are not mentioned in the  %s inventory manifest (%s) \[[ocfl/validator.py#L462](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L462)\] |
+| | E023b | OCFL Object %s manifest does not include files listed in previous version manifests (%s) \[[ocfl/validator.py#L356](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L356)\] |
+| [E024](https://ocfl.io/1.1/spec#E024) | 'There must not be empty directories within a version\'s content directory.' | OCFL Object version %s content directory includes empty path %s \[[ocfl/validator.py#L415](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L415)\] |
 | [E025](https://ocfl.io/1.1/spec#E025) | 'For content-addressing, OCFL Objects must use either sha512 or sha256, and should use sha512.' | _See multiple cases identified with suffixes below_ |
-| | E025a | OCFL Object %s inventory `digestAlgorithm` attribute not an allowed digest type (got '%s') \[[ocfl/inventory_validator.py#L120](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L120)\] |
-| | E025b | OCFL Object %s inventory manifest block includes a digest (%s) that doesn't have the correct form for the %s algorithm \[[ocfl/inventory_validator.py#L186](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L186)\] |
+| | E025a | OCFL Object %s inventory `digestAlgorithm` attribute not an allowed digest type (got '%s') \[[ocfl/inventory_validator.py#L134](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L134)\] |
+| | E025b | OCFL Object %s inventory manifest block includes a digest (%s) that doesn't have the correct form for the %s algorithm \[[ocfl/inventory_validator.py#L200](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L200)\] |
 | [E026](https://ocfl.io/1.1/spec#E026) | 'For storage of additional fixity values, or to support legacy content migration, implementers must choose from the following controlled vocabulary of digest algorithms, or from a list of additional algorithms given in the [Digest-Algorithms-Extension].' | _See multiple cases identified with suffixes below_ |
-| | E026a | OCFL Object %s inventory uses unknown digest type %s \[[ocfl/inventory_validator.py#L428](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L428)\] |
+| | E026a | OCFL Object %s inventory uses unknown digest type %s \[[ocfl/inventory_validator.py#L442](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L442)\] |
 | [E027](https://ocfl.io/1.1/spec#E027) | 'OCFL clients must support all fixity algorithms given in the table below, and may support additional algorithms from the extensions.' | _Not implemented_ |
 | [E028](https://ocfl.io/1.1/spec#E028) | 'Optional fixity algorithms that are not supported by a client must be ignored by that client.' | _Not implemented_ |
 | [E029](https://ocfl.io/1.1/spec#E029) | 'SHA-1 algorithm defined by [FIPS-180-4] and must be encoded using hex (base16) encoding [RFC4648].' | _Not implemented_ |
 | [E030](https://ocfl.io/1.1/spec#E030) | 'SHA-256 algorithm defined by [FIPS-180-4] and must be encoded using hex (base16) encoding [RFC4648].' | _Not implemented_ |
 | [E031](https://ocfl.io/1.1/spec#E031) | 'SHA-512 algorithm defined by [FIPS-180-4] and must be encoded using hex (base16) encoding [RFC4648].' | _Not implemented_ |
 | [E032](https://ocfl.io/1.1/spec#E032) | '[blake2b-512] must be encoded using hex (base16) encoding [RFC4648].' | _Not implemented_ |
-| [E033](https://ocfl.io/1.1/spec#E033) | 'An OCFL Object Inventory MUST follow the [JSON] structure described in this section and must be named inventory.json.' | OCFL Object %s inventory is not valid JSON (%s) \[[ocfl/object.py#L622](https://github.com/zimeon/ocfl-py/blob/main/ocfl/object.py#L622) [ocfl/validator.py#L196](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L196)\] |
+| [E033](https://ocfl.io/1.1/spec#E033) | 'An OCFL Object Inventory MUST follow the [JSON] structure described in this section and must be named inventory.json.' | OCFL Object %s inventory is not valid JSON (%s) \[[ocfl/object.py#L645](https://github.com/zimeon/ocfl-py/blob/main/ocfl/object.py#L645) [ocfl/validator.py#L199](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L199)\] |
 | [E034](https://ocfl.io/1.1/spec#E034) | 'An OCFL Object Inventory must follow the [JSON] structure described in this section and MUST be named inventory.json.' | _Not implemented_ |
 | [E035](https://ocfl.io/1.1/spec#E035) | 'The forward slash (/) path separator must be used in content paths in the manifest and fixity blocks within the inventory.' | _Not implemented_ |
 | [E036](https://ocfl.io/1.1/spec#E036) | 'An OCFL Object Inventory must include the following keys: [id, type, digestAlgorithm, head]' | _See multiple cases identified with suffixes below_ |
-| | E036a | OCFL Object %s inventory missing `id` attribute \[[ocfl/inventory_validator.py#L92](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L92)\] |
-| | E036b | OCFL Object %s inventory missing `type` attribute \[[ocfl/inventory_validator.py#L94](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L94)\] |
-| | E036c | OCFL Object %s inventory missing `digestAlgorithm` attribute \[[ocfl/inventory_validator.py#L111](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L111)\] |
-| | E036d | OCFL Object %s inventory missing `head` attribute \[[ocfl/inventory_validator.py#L144](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L144)\] |
+| | E036a | OCFL Object %s inventory missing `id` attribute \[[ocfl/inventory_validator.py#L106](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L106)\] |
+| | E036b | OCFL Object %s inventory missing `type` attribute \[[ocfl/inventory_validator.py#L108](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L108)\] |
+| | E036c | OCFL Object %s inventory missing `digestAlgorithm` attribute \[[ocfl/inventory_validator.py#L125](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L125)\] |
+| | E036d | OCFL Object %s inventory missing `head` attribute \[[ocfl/inventory_validator.py#L158](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L158)\] |
 | [E037](https://ocfl.io/1.1/spec#E037) | '[id] must be unique in the local context, and should be a URI [RFC3986].' | _See multiple cases identified with suffixes below_ |
-| | E037a | OCFL Object %s inventory `id` attribute is empty or badly formed \[[ocfl/inventory_validator.py#L84](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L84)\] |
-| | E037b | OCFL Object %s inventory id `%s` does not match the value in the root inventory `%s` \[[ocfl/validator.py#L328](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L328)\] |
+| | E037a | OCFL Object %s inventory `id` attribute is empty or badly formed \[[ocfl/inventory_validator.py#L98](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L98)\] |
+| | E037b | OCFL Object %s inventory id `%s` does not match the value in the root inventory `%s` \[[ocfl/validator.py#L331](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L331)\] |
 | [E038](https://ocfl.io/1.1/spec#E038) | 'In the object root inventory [the type value] must be the URI of the inventory section of the specification version matching the object conformance declaration.' | _See multiple cases identified with suffixes below_ |
-| | E038a | OCFL Object %s inventory `type` attribute has wrong value (expected %s, got %s) \[[ocfl/inventory_validator.py#L99](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L99)\] |
-| | E038b | OCFL Object %s inventory `type` attribute does not look like a valid specification URI (got %s), will proceed as if using version %s \[[ocfl/inventory_validator.py#L104](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L104)\] |
-| | E038c | OCFL Object %s inventory `type` attribute has an unsupported specification version number (%s), will proceed as if using version %s \[[ocfl/inventory_validator.py#L109](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L109)\] |
+| | E038a | OCFL Object %s inventory `type` attribute has wrong value (expected %s, got %s) \[[ocfl/inventory_validator.py#L113](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L113)\] |
+| | E038b | OCFL Object %s inventory `type` attribute does not look like a valid specification URI (got %s), will proceed as if using version %s \[[ocfl/inventory_validator.py#L118](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L118)\] |
+| | E038c | OCFL Object %s inventory `type` attribute has an unsupported specification version number (%s), will proceed as if using version %s \[[ocfl/inventory_validator.py#L123](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L123)\] |
 | [E039](https://ocfl.io/1.1/spec#E039) | '[digestAlgorithm] must be the algorithm used in the manifest and state blocks.' | _Not implemented_ |
-| [E040](https://ocfl.io/1.1/spec#E040) | [head] must be the version directory name with the highest version number.' | OCFL Object %s inventory head attribute doesn't match versions (got %s, expected %s) \[[ocfl/inventory_validator.py#L148](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L148)\] |
+| [E040](https://ocfl.io/1.1/spec#E040) | [head] must be the version directory name with the highest version number.' | OCFL Object %s inventory head attribute doesn't match versions (got %s, expected %s) \[[ocfl/inventory_validator.py#L162](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L162)\] |
 | [E041](https://ocfl.io/1.1/spec#E041) | 'In addition to these keys, there must be two other blocks present, manifest and versions, which are discussed in the next two sections.' | _See multiple cases identified with suffixes below_ |
-| | E041a | OCFL Object %s inventory missing `manifest` attribute \[[ocfl/inventory_validator.py#L134](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L134)\] |
-| | E041b | OCFL Object %s inventory missing `versions` attribute \[[ocfl/inventory_validator.py#L139](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L139)\] |
-| | E041c | OCFL Object %s inventory manifest block is not a JSON object \[[ocfl/inventory_validator.py#L179](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L179)\] |
+| | E041a | OCFL Object %s inventory missing `manifest` attribute \[[ocfl/inventory_validator.py#L148](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L148)\] |
+| | E041b | OCFL Object %s inventory missing `versions` attribute \[[ocfl/inventory_validator.py#L153](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L153)\] |
+| | E041c | OCFL Object %s inventory manifest block is not a JSON object \[[ocfl/inventory_validator.py#L193](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L193)\] |
 | [E042](https://ocfl.io/1.1/spec#E042) | 'Content paths within a manifest block must be relative to the OCFL Object Root.' | _See multiple cases identified with suffixes below_ |
-| | E042a | OCFL Object %s inventory manifest includes invalid content path %s \[[ocfl/inventory_validator.py#L463](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L463)\] |
-| | E042b | OCFL Object %s inventory manifest includes content path %s with invalid version directory \[[ocfl/inventory_validator.py#L409](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L409)\] |
-| | E042c | OCFL Object %s inventory manifest includes invalid content path %s that doesn't match the content directory name %s \[[ocfl/inventory_validator.py#L466](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L466)\] |
+| | E042a | OCFL Object %s inventory manifest includes invalid content path %s \[[ocfl/inventory_validator.py#L477](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L477)\] |
+| | E042b | OCFL Object %s inventory manifest includes content path %s with invalid version directory \[[ocfl/inventory_validator.py#L423](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L423)\] |
+| | E042c | OCFL Object %s inventory manifest includes invalid content path %s that doesn't match the content directory name %s \[[ocfl/inventory_validator.py#L480](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L480)\] |
 | [E043](https://ocfl.io/1.1/spec#E043) | 'An OCFL Object Inventory must include a block for storing versions.' | _Not implemented_ |
-| [E044](https://ocfl.io/1.1/spec#E044) | 'This block MUST have the key of versions within the inventory, and it must be a JSON object.' | OCFL Object %s inventory versions block is not a JSON object \[[ocfl/inventory_validator.py#L264](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L264)\] |
+| [E044](https://ocfl.io/1.1/spec#E044) | 'This block MUST have the key of versions within the inventory, and it must be a JSON object.' | OCFL Object %s inventory versions block is not a JSON object \[[ocfl/inventory_validator.py#L278](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L278)\] |
 | [E045](https://ocfl.io/1.1/spec#E045) | 'This block must have the key of versions within the inventory, and it MUST be a JSON object.' | _Not implemented_ |
 | [E046](https://ocfl.io/1.1/spec#E046) | 'The keys of [the versions object] must correspond to the names of the version directories used.' | _See multiple cases identified with suffixes below_ |
-| | E046a | OCFL Object root inventory describes version %s but no corresponding version directory is present \[[ocfl/validator.py#L423](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L423)\] |
-| | E046b | OCFL Object includes directory %s that looks like a version directory but isn't a valid version in the inventory \[[ocfl/validator.py#L258](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L258)\] |
+| | E046a | OCFL Object root inventory describes version %s but no corresponding version directory is present \[[ocfl/validator.py#L426](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L426)\] |
+| | E046b | OCFL Object includes directory %s that looks like a version directory but isn't a valid version in the inventory \[[ocfl/validator.py#L261](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L261)\] |
 | [E047](https://ocfl.io/1.1/spec#E047) | 'Each value [of the versions object] must be another JSON object that characterizes the version, as described in the 3.5.3.1 Version section.' | _Not implemented_ |
-| [E048](https://ocfl.io/1.1/spec#E048) | 'A JSON object to describe one OCFL Version, which must include the following keys: [created, state]' | OCFL Object %s inventory %s version block does not include a created date or it is malformed \[[ocfl/inventory_validator.py#L325](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L325)\] |
-| | E048c | OCFL Object %s inventory %s version block does not include a state block \[[ocfl/inventory_validator.py#L341](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L341)\] |
+| [E048](https://ocfl.io/1.1/spec#E048) | 'A JSON object to describe one OCFL Version, which must include the following keys: [created, state]' | OCFL Object %s inventory %s version block does not include a created date or it is malformed \[[ocfl/inventory_validator.py#L339](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L339)\] |
+| | E048c | OCFL Object %s inventory %s version block does not include a state block \[[ocfl/inventory_validator.py#L355](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L355)\] |
 | [E049](https://ocfl.io/1.1/spec#E049) | '[the value of the "created" key] must be expressed in the Internet Date/Time Format defined by [RFC3339].' | _See multiple cases identified with suffixes below_ |
-| | E049a | OCFL Object %s inventory %s version block created date SHOULD include a timezone designator \[[ocfl/inventory_validator.py#L333](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L333)\] |
-| | E049b | OCFL Object %s inventory %s version block created date SHOULD be granular to the seconds level \[[ocfl/inventory_validator.py#L335](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L335)\] |
-| | E049c | OCFL Object %s inventory %s version block has bad created date, must be IS8601 (%s) \[[ocfl/inventory_validator.py#L337](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L337)\] |
-| | E049d | OCFL Object %s inventory %s version block created value is not a JSON string \[[ocfl/inventory_validator.py#L327](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L327)\] |
+| | E049a | OCFL Object %s inventory %s version block created date SHOULD include a timezone designator \[[ocfl/inventory_validator.py#L347](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L347)\] |
+| | E049b | OCFL Object %s inventory %s version block created date SHOULD be granular to the seconds level \[[ocfl/inventory_validator.py#L349](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L349)\] |
+| | E049c | OCFL Object %s inventory %s version block has bad created date, must be IS8601 (%s) \[[ocfl/inventory_validator.py#L351](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L351)\] |
+| | E049d | OCFL Object %s inventory %s version block created value is not a JSON string \[[ocfl/inventory_validator.py#L341](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L341)\] |
 | [E050](https://ocfl.io/1.1/spec#E050) | 'The keys of [the "state" JSON object] are digest values, each of which must correspond to an entry in the manifest of the inventory.' | _See multiple cases identified with suffixes below_ |
-| | E050a | OCFL Object %s inventory state refers to one or more digests that are not in the manifest (%s) \[[ocfl/inventory_validator.py#L417](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L417)\] |
-| | E050c | OCFL Object %s inventory %s version block state block is not a JSON object \[[ocfl/inventory_validator.py#L374](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L374)\] |
-| | E050d | OCFL Object %s inventory %s version state block includes a bad digest (%s) \[[ocfl/inventory_validator.py#L379](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L379)\] |
-| | E050e | OCFL Object %s inventory %s version block state block value for digest %s is not list \[[ocfl/inventory_validator.py#L381](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L381)\] |
-| | E050f | OCFL Object %s inventory version %s state includes digest value %s that is not listed in the manifest block \[[ocfl/inventory_validator.py#L390](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L390)\] |
+| | E050a | OCFL Object %s inventory state refers to one or more digests that are not in the manifest (%s) \[[ocfl/inventory_validator.py#L431](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L431)\] |
+| | E050c | OCFL Object %s inventory %s version block state block is not a JSON object \[[ocfl/inventory_validator.py#L388](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L388)\] |
+| | E050d | OCFL Object %s inventory %s version state block includes a bad digest (%s) \[[ocfl/inventory_validator.py#L393](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L393)\] |
+| | E050e | OCFL Object %s inventory %s version block state block value for digest %s is not list \[[ocfl/inventory_validator.py#L395](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L395)\] |
+| | E050f | OCFL Object %s inventory version %s state includes digest value %s that is not listed in the manifest block \[[ocfl/inventory_validator.py#L404](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L404)\] |
 | [E051](https://ocfl.io/1.1/spec#E051) | 'The logical path [value of a "state" digest key] must be interpreted as a set of one or more path elements joined by a / path separator.' | NOTE - E051 is essentially a processing instruction and can't be tested for. \[_Not implemented_\] |
-| [E052](https://ocfl.io/1.1/spec#E052) | '[logical] Path elements must not be ., .., or empty (//).' | OCFL Object %s inventory %s version block state block includes an invalid path %s that starts or ends with / \[[ocfl/inventory_validator.py#L445](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L445)\] |
-| [E053](https://ocfl.io/1.1/spec#E053) | 'Additionally, a logical path must not begin or end with a forward slash (/).' | OCFL Object %s inventory %s version block state block includes an invalid path %s that includes . .. or // \[[ocfl/inventory_validator.py#L440](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L440)\] |
+| [E052](https://ocfl.io/1.1/spec#E052) | '[logical] Path elements must not be ., .., or empty (//).' | OCFL Object %s inventory %s version block state block includes an invalid path %s that starts or ends with / \[[ocfl/inventory_validator.py#L459](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L459)\] |
+| [E053](https://ocfl.io/1.1/spec#E053) | 'Additionally, a logical path must not begin or end with a forward slash (/).' | OCFL Object %s inventory %s version block state block includes an invalid path %s that includes . .. or // \[[ocfl/inventory_validator.py#L454](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L454)\] |
 | [E054](https://ocfl.io/1.1/spec#E054) | 'The value of the user key must contain a user name key, "name" and should contain an address key, "address".' | _See multiple cases identified with suffixes below_ |
-| | E054a | OCFL Object %s inventory %s version block has user key with value that isn't a JSON object \[[ocfl/inventory_validator.py#L351](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L351)\] |
-| | E054b | OCFL Object %s inventory %s version block has user/name key with value that isn't a string \[[ocfl/inventory_validator.py#L354](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L354)\] |
-| | E054c | OCFL Object %s inventory %s version block has user/address key with value that isn't a string \[[ocfl/inventory_validator.py#L358](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L358)\] |
+| | E054a | OCFL Object %s inventory %s version block has user key with value that isn't a JSON object \[[ocfl/inventory_validator.py#L365](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L365)\] |
+| | E054b | OCFL Object %s inventory %s version block has user/name key with value that isn't a string \[[ocfl/inventory_validator.py#L368](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L368)\] |
+| | E054c | OCFL Object %s inventory %s version block has user/address key with value that isn't a string \[[ocfl/inventory_validator.py#L372](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L372)\] |
 | [E055](https://ocfl.io/1.1/spec#E055) | 'If present, [the fixity] block must have the key of fixity within the inventory.' | _Not implemented_ |
 | [E056](https://ocfl.io/1.1/spec#E056) | 'The fixity block must contain keys corresponding to the controlled vocabulary given in the digest algorithms listed in the Digests section, or in a table given in an Extension.' | _See multiple cases identified with suffixes below_ |
-| | E056a | OCFL Object %s inventory includes a fixity key with value that isn't a JSON object \[[ocfl/inventory_validator.py#L217](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L217)\] |
-| | E056b | OCFL Object %s inventory fixity block includes a key that is not a known digest algorithm name: %s \[[ocfl/inventory_validator.py#L225](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L225)\] |
+| | E056a | OCFL Object %s inventory includes a fixity key with value that isn't a JSON object \[[ocfl/inventory_validator.py#L231](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L231)\] |
+| | E056b | OCFL Object %s inventory fixity block includes a key that is not a known digest algorithm name: %s \[[ocfl/inventory_validator.py#L239](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L239)\] |
 | [E057](https://ocfl.io/1.1/spec#E057) | 'The value of the fixity block for a particular digest algorithm must follow the structure of the manifest block; that is, a key corresponding to the digest value, and an array of content paths that match that digest.' | _See multiple cases identified with suffixes below_ |
-| | E057a | OCFL Object %s inventory fixity block entry for digest algorithm name %s is not a JSON object \[[ocfl/inventory_validator.py#L232](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L232)\] |
-| | E057b | OCFL Object %s inventory fixity block entry for digest algorithm %s includes digest %s which has the wrong form \[[ocfl/inventory_validator.py#L238](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L238)\] |
-| | E057c | OCFL Object %s inventory fixity block entry for digest algorithm %s, digest %s is not a JSON list \[[ocfl/inventory_validator.py#L240](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L240)\] |
-| | E057d | OCFL Object %s inventory fixity block entry for digest algorithm %s, digest %s includes a content path %s that is not in the manifest \[[ocfl/inventory_validator.py#L253](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L253)\] |
+| | E057a | OCFL Object %s inventory fixity block entry for digest algorithm name %s is not a JSON object \[[ocfl/inventory_validator.py#L246](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L246)\] |
+| | E057b | OCFL Object %s inventory fixity block entry for digest algorithm %s includes digest %s which has the wrong form \[[ocfl/inventory_validator.py#L252](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L252)\] |
+| | E057c | OCFL Object %s inventory fixity block entry for digest algorithm %s, digest %s is not a JSON list \[[ocfl/inventory_validator.py#L254](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L254)\] |
+| | E057d | OCFL Object %s inventory fixity block entry for digest algorithm %s, digest %s includes a content path %s that is not in the manifest \[[ocfl/inventory_validator.py#L267](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L267)\] |
 | [E058](https://ocfl.io/1.1/spec#E058) | 'Every occurrence of an inventory file must have an accompanying sidecar file stating its digest.' | _See multiple cases identified with suffixes below_ |
-| | E058a | OCFL Object %s inventory is missing sidecar digest file at %s \[[ocfl/validator.py#L208](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L208)\] |
-| | E058b | Cannot extract digest type from inventory digest file name %s \[[ocfl/validator.py#L231](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L231)\] |
+| | E058a | OCFL Object %s inventory is missing sidecar digest file at %s \[[ocfl/validator.py#L211](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L211)\] |
+| | E058b | Cannot extract digest type from inventory digest file name %s \[[ocfl/validator.py#L234](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L234)\] |
 | [E059](https://ocfl.io/1.1/spec#E059) | 'This value must match the value given for the digestAlgorithm key in the inventory.' | _Not implemented_ |
-| [E060](https://ocfl.io/1.1/spec#E060) | 'The digest sidecar file must contain the digest of the inventory file.' | Mismatch between actual and recorded inventory digests for %s (calculated %s but read %s from %s) \[[ocfl/validator.py#L227](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L227)\] |
-| [E061](https://ocfl.io/1.1/spec#E061) | '[The digest sidecar file] must follow the format: DIGEST inventory.json' | Cannot extract digest from inventory digest file (%s) \[[ocfl/validator.py#L229](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L229)\] |
+| [E060](https://ocfl.io/1.1/spec#E060) | 'The digest sidecar file must contain the digest of the inventory file.' | Mismatch between actual and recorded inventory digests for %s (calculated %s but read %s from %s) \[[ocfl/validator.py#L230](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L230)\] |
+| [E061](https://ocfl.io/1.1/spec#E061) | '[The digest sidecar file] must follow the format: DIGEST inventory.json' | Cannot extract digest from inventory digest file (%s) \[[ocfl/validator.py#L232](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L232)\] |
 | [E062](https://ocfl.io/1.1/spec#E062) | 'The digest of the inventory must be computed only after all changes to the inventory have been made, and thus writing the digest sidecar file is the last step in the versioning process.' | _Not implemented_ |
-| [E063](https://ocfl.io/1.1/spec#E063) | 'Every OCFL Object must have an inventory file within the OCFL Object Root, corresponding to the state of the OCFL Object at the current version.' | OCFL Object root inventory is missing \[[ocfl/validator.py#L153](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L153)\] |
-| [E064](https://ocfl.io/1.1/spec#E064) | 'Where an OCFL Object contains inventory.json in version directories, the inventory file in the OCFL Object Root must be the same as the file in the most recent version.' | Object root inventory and copy in last version MUST be identical but are not (%s and %s) \[[ocfl/validator.py#L310](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L310)\] |
+| [E063](https://ocfl.io/1.1/spec#E063) | 'Every OCFL Object must have an inventory file within the OCFL Object Root, corresponding to the state of the OCFL Object at the current version.' | OCFL Object root inventory is missing \[[ocfl/validator.py#L156](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L156)\] |
+| [E064](https://ocfl.io/1.1/spec#E064) | 'Where an OCFL Object contains inventory.json in version directories, the inventory file in the OCFL Object Root must be the same as the file in the most recent version.' | Object root inventory and copy in last version MUST be identical but are not (%s and %s) \[[ocfl/validator.py#L313](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L313)\] |
 | [E066](https://ocfl.io/1.1/spec#E066) | 'Each version block in each prior inventory file must represent the same object state as the corresponding version block in the current inventory file.' | _See multiple cases identified with suffixes below_ |
-| | E066a | OCFL Object inventory for %s doesn't have a subset of version blocks of inventory for %s \[[ocfl/inventory_validator.py#L490](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L490)\] |
-| | E066b | OCFL Object %s inventory %s version state doesn't have same logical paths as same version in %s inventory (paths %s only in %s inventory) \[[ocfl/inventory_validator.py#L510](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L510) [ocfl/inventory_validator.py#L512](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L512)\] |
-| | E066c | OCFL Object %s inventory %s version state has logical path %s that maps to content path %s, but in %s inventory it maps to content path %s \[[ocfl/inventory_validator.py#L517](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L517)\] |
-| | E066d | OCFL Object %s inventory %s version state has digest %s (mapping to logical files %s) that does not appear in the %s inventory \[[ocfl/inventory_validator.py#L546](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L546)\] |
-| | E066e | OCFL Object %s inventory %s version state has digest %s (mapping to logical files %s) that does not appear in the %s inventory \[[ocfl/inventory_validator.py#L549](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L549)\] |
-| [E067](https://ocfl.io/1.1/spec#E067) | 'The extensions directory must not contain any files or sub-directories other than extension sub-directories.' | OCFL Object extensions direct contains an unexpected non-directory entry: %s \[[ocfl/validator.py#L279](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L279)\] |
+| | E066a | OCFL Object inventory for %s doesn't have a subset of version blocks of inventory for %s \[[ocfl/inventory_validator.py#L504](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L504)\] |
+| | E066b | OCFL Object %s inventory %s version state doesn't have same logical paths as same version in %s inventory (paths %s only in %s inventory) \[[ocfl/inventory_validator.py#L524](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L524) [ocfl/inventory_validator.py#L526](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L526)\] |
+| | E066c | OCFL Object %s inventory %s version state has logical path %s that maps to content path %s, but in %s inventory it maps to content path %s \[[ocfl/inventory_validator.py#L531](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L531)\] |
+| | E066d | OCFL Object %s inventory %s version state has digest %s (mapping to logical files %s) that does not appear in the %s inventory \[[ocfl/inventory_validator.py#L560](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L560)\] |
+| | E066e | OCFL Object %s inventory %s version state has digest %s (mapping to logical files %s) that does not appear in the %s inventory \[[ocfl/inventory_validator.py#L563](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L563)\] |
+| [E067](https://ocfl.io/1.1/spec#E067) | 'The extensions directory must not contain any files or sub-directories other than extension sub-directories.' | OCFL Object extensions direct contains an unexpected non-directory entry: %s \[[ocfl/validator.py#L282](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L282)\] |
 | [E069](https://ocfl.io/1.1/spec#E069) | 'An OCFL Storage Root MUST contain a Root Conformance Declaration identifying it as such.' | _Not implemented_ |
 | [E070](https://ocfl.io/1.1/spec#E070) | 'If present, [the ocfl_layout.json document] MUST include the following two keys in the root JSON object: [extension, description]' | _Not implemented_ |
 | [E071](https://ocfl.io/1.1/spec#E071) | 'The value of the [ocfl_layout.json] extension key must be the registered extension name for the extension defining the arrangement under the storage root.' | _Not implemented_ |
@@ -153,57 +153,57 @@ The following tables show the implementation status of all errors and warnings i
 | [E089](https://ocfl.io/1.1/spec#E089) | 'If the preservation of non-OCFL-compliant features is required then the content MUST be wrapped in a suitable disk or filesystem image format which OCFL can treat as a regular file.' | _Not implemented_ |
 | [E090](https://ocfl.io/1.1/spec#E090) | 'Hard and soft (symbolic) links are not portable and MUST NOT be used within OCFL Storage hierarchies.' | NOTE - E090 is a processing instruction and can't be tested for \[_Not implemented_\] |
 | [E091](https://ocfl.io/1.1/spec#E091) | 'Filesystems MUST preserve the case of OCFL filepaths and filenames.' | OCFL Object %s inventory manifest file list for digest %s is not a JSON array \[_Not implemented_\] |
-| [E092](https://ocfl.io/1.1/spec#E092) | 'The value for each key in the manifest must be an array containing the content paths of files in the OCFL Object that have content with the given digest.' | **Missing description** \[[ocfl/inventory_validator.py#L188](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L188)\] |
-| | E092a | OCFL Object %s inventory manifest using digest algorithm %s has digest %s for file %s which doesn't match calculated digest %s for that file \[[ocfl/validator.py#L449](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L449) [ocfl/validator.py#L454](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L454)\] |
-| | E092b | OCFL Object %s inventory manifest refers to a file path that is not present in the object (%s) \[[ocfl/validator.py#L444](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L444)\] |
+| [E092](https://ocfl.io/1.1/spec#E092) | 'The value for each key in the manifest must be an array containing the content paths of files in the OCFL Object that have content with the given digest.' | **Missing description** \[[ocfl/inventory_validator.py#L202](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L202)\] |
+| | E092a | OCFL Object %s inventory manifest using digest algorithm %s has digest %s for file %s which doesn't match calculated digest %s for that file \[[ocfl/validator.py#L452](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L452) [ocfl/validator.py#L457](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L457)\] |
+| | E092b | OCFL Object %s inventory manifest refers to a file path that is not present in the object (%s) \[[ocfl/validator.py#L447](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L447)\] |
 | [E093](https://ocfl.io/1.1/spec#E093) | 'Where included in the fixity block, the digest values given must match the digests of the files at the corresponding content paths.' | _See multiple cases identified with suffixes below_ |
-| | E093a | OCFL Object %s inventory fixity block for digest algorithm %s has digest %s for file %s which doesn't match calculated digest %s for that file \[[ocfl/validator.py#L452](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L452) [ocfl/validator.py#L455](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L455)\] |
-| | E093b | OCFL Object %s inventory fixity block for digest algorithm %s has digest %s for a file %s which does not exist in the object \[[ocfl/validator.py#L438](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L438)\] |
-| [E094](https://ocfl.io/1.1/spec#E094) | 'The value of [the message] key is freeform text, used to record the rationale for creating this version. It must be a JSON string.' | OCFL Object %s inventory %s version block has message key with value that isn't a string \[[ocfl/inventory_validator.py#L345](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L345)\] |
+| | E093a | OCFL Object %s inventory fixity block for digest algorithm %s has digest %s for file %s which doesn't match calculated digest %s for that file \[[ocfl/validator.py#L455](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L455) [ocfl/validator.py#L458](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L458)\] |
+| | E093b | OCFL Object %s inventory fixity block for digest algorithm %s has digest %s for a file %s which does not exist in the object \[[ocfl/validator.py#L441](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L441)\] |
+| [E094](https://ocfl.io/1.1/spec#E094) | 'The value of [the message] key is freeform text, used to record the rationale for creating this version. It must be a JSON string.' | OCFL Object %s inventory %s version block has message key with value that isn't a string \[[ocfl/inventory_validator.py#L359](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L359)\] |
 | [E095](https://ocfl.io/1.1/spec#E095) | 'Within a version, logical paths must be unique and non-conflicting, so the logical path for a file cannot appear as the initial part of another logical path.' | _See multiple cases identified with suffixes below_ |
-| | E095a | OCFL Object %s inventory version %s state has logical path %s used more than once \[[ocfl/inventory_validator.py#L385](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L385)\] |
-| | E095b | OCFL Object %s inventory version %s state has logical path %s used as both a directory and a file path. \[[ocfl/inventory_validator.py#L396](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L396)\] |
-| [E096](https://ocfl.io/1.1/spec#E096) | 'As JSON keys are case sensitive, while digests may not be, there is an additional requirement that each digest value must occur only once in the manifest regardless of case.' | OCFL Object %s inventory manifest block includes digest %s more than once with different normalizations \[[ocfl/inventory_validator.py#L194](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L194)\] |
-| [E097](https://ocfl.io/1.1/spec#E097) | 'As JSON keys are case sensitive, while digests may not be, there is an additional requirement that each digest value must occur only once in the fixity block for any digest algorithm, regardless of case.' | OCFL Object %s inventory fixity block for digest algorithm %s, includes digest %s more than once with different normalizations \[[ocfl/inventory_validator.py#L248](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L248)\] |
+| | E095a | OCFL Object %s inventory version %s state has logical path %s used more than once \[[ocfl/inventory_validator.py#L399](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L399)\] |
+| | E095b | OCFL Object %s inventory version %s state has logical path %s used as both a directory and a file path. \[[ocfl/inventory_validator.py#L410](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L410)\] |
+| [E096](https://ocfl.io/1.1/spec#E096) | 'As JSON keys are case sensitive, while digests may not be, there is an additional requirement that each digest value must occur only once in the manifest regardless of case.' | OCFL Object %s inventory manifest block includes digest %s more than once with different normalizations \[[ocfl/inventory_validator.py#L208](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L208)\] |
+| [E097](https://ocfl.io/1.1/spec#E097) | 'As JSON keys are case sensitive, while digests may not be, there is an additional requirement that each digest value must occur only once in the fixity block for any digest algorithm, regardless of case.' | OCFL Object %s inventory fixity block for digest algorithm %s, includes digest %s more than once with different normalizations \[[ocfl/inventory_validator.py#L262](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L262)\] |
 | [E098](https://ocfl.io/1.1/spec#E098) | 'The content path must be interpreted as a set of one or more path elements joined by a / path separator.' | _Not implemented_ |
-| [E099](https://ocfl.io/1.1/spec#E099) | '[content] path elements must not be ., .., or empty (//).' | OCFL Object %s inventory manifest content path %s includes invalid element ., .., or empty (//). \[[ocfl/inventory_validator.py#L471](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L471)\] |
-| [E100](https://ocfl.io/1.1/spec#E100) | 'A content path must not begin or end with a forward slash (/).' | OCFL Object %s inventory manifest content path %s must not begin or end with /. \[[ocfl/inventory_validator.py#L459](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L459)\] |
+| [E099](https://ocfl.io/1.1/spec#E099) | '[content] path elements must not be ., .., or empty (//).' | OCFL Object %s inventory manifest content path %s includes invalid element ., .., or empty (//). \[[ocfl/inventory_validator.py#L485](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L485)\] |
+| [E100](https://ocfl.io/1.1/spec#E100) | 'A content path must not begin or end with a forward slash (/).' | OCFL Object %s inventory manifest content path %s must not begin or end with /. \[[ocfl/inventory_validator.py#L473](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L473)\] |
 | [E101](https://ocfl.io/1.1/spec#E101) | 'Within an inventory, content paths must be unique and non-conflicting, so the content path for a file cannot appear as the initial part of another content path.' | _See multiple cases identified with suffixes below_ |
-| | E101a | OCFL Object %s inventory manifest content path %s is repeated \[[ocfl/inventory_validator.py#L475](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L475)\] |
-| | E101b | OCFL Object %s inventory manifest content path %s used as both a directory and a file path \[[ocfl/inventory_validator.py#L204](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L204)\] |
+| | E101a | OCFL Object %s inventory manifest content path %s is repeated \[[ocfl/inventory_validator.py#L489](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L489)\] |
+| | E101b | OCFL Object %s inventory manifest content path %s used as both a directory and a file path \[[ocfl/inventory_validator.py#L218](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L218)\] |
 | [E102](https://ocfl.io/1.1/spec#E102) | 'An inventory file must not contain keys that are not specified.' | _Not implemented_ |
-| [E103](https://ocfl.io/1.1/spec#E103) | 'Each version directory within an OCFL Object MUST conform to either the same or a later OCFL specification version as the preceding version directory.' | OCFL Object %s inventory conforms to specification version %s which is an earlier version than the %s inventory which conforms to specification version %s \[[ocfl/validator.py#L385](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L385)\] |
+| [E103](https://ocfl.io/1.1/spec#E103) | 'Each version directory within an OCFL Object MUST conform to either the same or a later OCFL specification version as the preceding version directory.' | OCFL Object %s inventory conforms to specification version %s which is an earlier version than the %s inventory which conforms to specification version %s \[[ocfl/validator.py#L388](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L388)\] |
 | [E104](https://ocfl.io/1.1/spec#E104) | 'Version directory names MUST be constructed by prepending v to the version number.' | _Not implemented_ |
 | [E105](https://ocfl.io/1.1/spec#E105) | 'The version number MUST be taken from the sequence of positive, base-ten integers: 1, 2, 3, etc.' | _Not implemented_ |
 | [E106](https://ocfl.io/1.1/spec#E106) | 'The value of the manifest key MUST be a JSON object.' | _Not implemented_ |
-| [E107](https://ocfl.io/1.1/spec#E107) | 'The value of the manifest key must be a JSON object, and each key MUST correspond to a digest value key found in one or more state blocks of the current and/or previous version blocks of the OCFL Object.' | **Missing description** \[[ocfl/inventory_validator.py#L420](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L420)\] |
+| [E107](https://ocfl.io/1.1/spec#E107) | 'The value of the manifest key must be a JSON object, and each key MUST correspond to a digest value key found in one or more state blocks of the current and/or previous version blocks of the OCFL Object.' | **Missing description** \[[ocfl/inventory_validator.py#L434](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L434)\] |
 | [E108](https://ocfl.io/1.1/spec#E108) | 'The contentDirectory value MUST represent a direct child directory of the version directory in which it is found.' | _Not implemented_ |
 | [E110](https://ocfl.io/1.1/spec#E110) | 'A unique identifier for the OCFL Object MUST NOT change between versions of the same object.' | _Not implemented_ |
 | [E111](https://ocfl.io/1.1/spec#E111) | 'If present, [the value of the fixity key] MUST be a JSON object, which may be empty.' | OCFL Object %s inventory includes a fixity key with value that isn't a JSON object \[_Not implemented_\] |
 | [E112](https://ocfl.io/1.1/spec#E112) | 'The extensions directory must not contain any files or sub-directories other than extension sub-directories.' | _Not implemented_ |
-| E999 | **Not in specification** | **Missing description** \[[ocfl/inventory_validator.py#L96](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L96)\] |
+| E999 | **Not in specification** | **Missing description** \[[ocfl/inventory_validator.py#L110](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L110)\] |
 
 ## Warnings
 
 | Code | Specification text (or suffixed code) | Implementation status and message/links |
 | --- | --- | --- |
-| [W001](https://ocfl.io/1.1/spec#W001) | 'Implementations SHOULD use version directory names constructed without zero-padding the version number, ie. v1, v2, v3, etc.'' | OCFL Object %s inventory version numbers SHOULD NOT be zero-padded \[[ocfl/inventory_validator.py#L290](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L290)\] |
-| [W002](https://ocfl.io/1.1/spec#W002) | 'The version directory SHOULD NOT contain any directories other than the designated content sub-directory. Once created, the contents of a version directory are expected to be immutable.' | OCFL Object version directory %s SHOULD NOT contain any directory except the designated content directory (found %s) \[[ocfl/validator.py#L419](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L419)\] |
-| [W003](https://ocfl.io/1.1/spec#W003) | 'Version directories must contain a designated content sub-directory if the version contains files to be preserved, and SHOULD NOT contain this sub-directory otherwise.' | OCFL Object version directory %s SHOULD NOT contain an empty content directory \[[ocfl/validator.py#L417](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L417)\] |
-| [W004](https://ocfl.io/1.1/spec#W004) | 'For content-addressing, OCFL Objects SHOULD use sha512.' | OCFL Object %s inventory SHOULD use sha512 but uses sha256 as the DigestAlgorithm \[[ocfl/inventory_validator.py#L117](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L117)\] |
-| [W005](https://ocfl.io/1.1/spec#W005) | 'The OCFL Object Inventory id SHOULD be a URI.' | OCFL Object %s inventory id SHOULD be a URI (got %s) \[[ocfl/inventory_validator.py#L89](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L89)\] |
+| [W001](https://ocfl.io/1.1/spec#W001) | 'Implementations SHOULD use version directory names constructed without zero-padding the version number, ie. v1, v2, v3, etc.'' | OCFL Object %s inventory version numbers SHOULD NOT be zero-padded \[[ocfl/inventory_validator.py#L304](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L304)\] |
+| [W002](https://ocfl.io/1.1/spec#W002) | 'The version directory SHOULD NOT contain any directories other than the designated content sub-directory. Once created, the contents of a version directory are expected to be immutable.' | OCFL Object version directory %s SHOULD NOT contain any directory except the designated content directory (found %s) \[[ocfl/validator.py#L422](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L422)\] |
+| [W003](https://ocfl.io/1.1/spec#W003) | 'Version directories must contain a designated content sub-directory if the version contains files to be preserved, and SHOULD NOT contain this sub-directory otherwise.' | OCFL Object version directory %s SHOULD NOT contain an empty content directory \[[ocfl/validator.py#L420](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L420)\] |
+| [W004](https://ocfl.io/1.1/spec#W004) | 'For content-addressing, OCFL Objects SHOULD use sha512.' | OCFL Object %s inventory SHOULD use sha512 but uses sha256 as the DigestAlgorithm \[[ocfl/inventory_validator.py#L131](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L131)\] |
+| [W005](https://ocfl.io/1.1/spec#W005) | 'The OCFL Object Inventory id SHOULD be a URI.' | OCFL Object %s inventory id SHOULD be a URI (got %s) \[[ocfl/inventory_validator.py#L103](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L103)\] |
 | [W007](https://ocfl.io/1.1/spec#W007) | 'In the OCFL Object Inventory, the JSON object describing an OCFL Version, SHOULD include the message and user keys.' | _See multiple cases identified with suffixes below_ |
-| | W007a | OCFL Object %s inventory %s version block SHOULD include a message key \[[ocfl/inventory_validator.py#L343](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L343)\] |
-| | W007b | OCFL Object %s inventory %s version block SHOULD include a user key \[[ocfl/inventory_validator.py#L347](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L347)\] |
-| [W008](https://ocfl.io/1.1/spec#W008) | 'In the OCFL Object Inventory, in the version block, the value of the user key SHOULD contain an address key, address.' | OCFL Object %s inventory %s version block user description SHOULD have an address \[[ocfl/inventory_validator.py#L356](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L356)\] |
-| [W009](https://ocfl.io/1.1/spec#W009) | 'In the OCFL Object Inventory, in the version block, the address value SHOULD be a URI: either a mailto URI [RFC6068] with the e-mail address of the user or a URL to a personal identifier, e.g., an ORCID iD.' | OCFL Object %s inventory %s version block user description SHOULD be a mailto: or person identifier URI \[[ocfl/inventory_validator.py#L360](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L360)\] |
-| [W010](https://ocfl.io/1.1/spec#W010) | 'In addition to the inventory in the OCFL Object Root, every version directory SHOULD include an inventory file that is an Inventory of all content for versions up to and including that particular version.' | OCFL Object %s SHOULD have an inventory file but does not \[[ocfl/validator.py#L302](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L302)\] |
-| [W011](https://ocfl.io/1.1/spec#W011) | 'In the case that prior version directories include an inventory file, the values of the created, message and user keys in each version block in each prior inventory file SHOULD have the same values as the corresponding keys in the corresponding version block in the current inventory file.' | OCFL Object version metadata '%s' for %s in %s inventory does not match that in %s inventory \[[ocfl/inventory_validator.py#L525](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L525)\] |
+| | W007a | OCFL Object %s inventory %s version block SHOULD include a message key \[[ocfl/inventory_validator.py#L357](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L357)\] |
+| | W007b | OCFL Object %s inventory %s version block SHOULD include a user key \[[ocfl/inventory_validator.py#L361](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L361)\] |
+| [W008](https://ocfl.io/1.1/spec#W008) | 'In the OCFL Object Inventory, in the version block, the value of the user key SHOULD contain an address key, address.' | OCFL Object %s inventory %s version block user description SHOULD have an address \[[ocfl/inventory_validator.py#L370](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L370)\] |
+| [W009](https://ocfl.io/1.1/spec#W009) | 'In the OCFL Object Inventory, in the version block, the address value SHOULD be a URI: either a mailto URI [RFC6068] with the e-mail address of the user or a URL to a personal identifier, e.g., an ORCID iD.' | OCFL Object %s inventory %s version block user description SHOULD be a mailto: or person identifier URI \[[ocfl/inventory_validator.py#L374](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L374)\] |
+| [W010](https://ocfl.io/1.1/spec#W010) | 'In addition to the inventory in the OCFL Object Root, every version directory SHOULD include an inventory file that is an Inventory of all content for versions up to and including that particular version.' | OCFL Object %s SHOULD have an inventory file but does not \[[ocfl/validator.py#L305](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L305)\] |
+| [W011](https://ocfl.io/1.1/spec#W011) | 'In the case that prior version directories include an inventory file, the values of the created, message and user keys in each version block in each prior inventory file SHOULD have the same values as the corresponding keys in the corresponding version block in the current inventory file.' | OCFL Object version metadata '%s' for %s in %s inventory does not match that in %s inventory \[[ocfl/inventory_validator.py#L539](https://github.com/zimeon/ocfl-py/blob/main/ocfl/inventory_validator.py#L539)\] |
 | [W012](https://ocfl.io/1.1/spec#W012) | 'Implementers SHOULD use the logs directory, if present, for storing files that contain a record of actions taken on the object.' | _Not implemented_ |
-| [W013](https://ocfl.io/1.1/spec#W013) | 'In an OCFL Object, extension sub-directories SHOULD be named according to a registered extension name.' | OCFL Object includes unregistered extension directory '%s' \[[ocfl/validator.py#L277](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L277)\] |
+| [W013](https://ocfl.io/1.1/spec#W013) | 'In an OCFL Object, extension sub-directories SHOULD be named according to a registered extension name.' | OCFL Object includes unregistered extension directory '%s' \[[ocfl/validator.py#L280](https://github.com/zimeon/ocfl-py/blob/main/ocfl/validator.py#L280)\] |
 | [W014](https://ocfl.io/1.1/spec#W014) | 'Storage hierarchies within the same OCFL Storage Root SHOULD use just one layout pattern.' | _Not implemented_ |
 | [W015](https://ocfl.io/1.1/spec#W015) | 'Storage hierarchies within the same OCFL Storage Root SHOULD consistently use either a directory hierarchy of OCFL Objects or top-level OCFL Objects.' | _Not implemented_ |
 | [W016](https://ocfl.io/1.1/spec#W016) | 'In the Storage Root, extension sub-directories SHOULD be named according to a registered extension name.' | _Not implemented_ |
 | W901 | **Not in specification** | OCFL Storage Root includes unregistered extension directory '%s' \[[ocfl/storage_root.py#L274](https://github.com/zimeon/ocfl-py/blob/main/ocfl/storage_root.py#L274)\] |
 
-_Generated by `extract_codes.py` at 2024-10-24 18:30:20.962876_
\ No newline at end of file
+_Generated by `extract_codes.py` at 2024-11-03 16:31:49.469536_
\ No newline at end of file
diff --git a/extra_fixtures/1.0/content/spec-ex-full-metadata.json b/extra_fixtures/1.0/content/spec-ex-full-metadata.json
new file mode 100644
index 0000000..3e8ae98
--- /dev/null
+++ b/extra_fixtures/1.0/content/spec-ex-full-metadata.json
@@ -0,0 +1,28 @@
+{
+  "versions": {
+    "v1": {
+      "created": "2018-01-01T01:01:01Z",
+      "message": "Initial import",
+      "user": {
+        "address": "alice@example.com",
+        "name": "Alice"
+      }
+    },
+    "v2": {
+      "created": "2018-02-02T02:02:02Z",
+      "message": "Fix bar.xml, remove image.tiff, add empty2.txt",
+      "user": {
+        "address": "bob@example.com",
+        "name": "Bob"
+      }
+    },
+    "v3": {
+      "created": "2018-03-03T03:03:03Z",
+      "message": "Reinstate image.tiff, delete empty.txt",
+      "user": {
+        "address": "cecilia@example.com",
+        "name": "Cecilia"
+      }
+    }
+  }
+}
diff --git a/extra_fixtures/1.1/content/spec-ex-full-metadata.json b/extra_fixtures/1.1/content/spec-ex-full-metadata.json
new file mode 100644
index 0000000..3e8ae98
--- /dev/null
+++ b/extra_fixtures/1.1/content/spec-ex-full-metadata.json
@@ -0,0 +1,28 @@
+{
+  "versions": {
+    "v1": {
+      "created": "2018-01-01T01:01:01Z",
+      "message": "Initial import",
+      "user": {
+        "address": "alice@example.com",
+        "name": "Alice"
+      }
+    },
+    "v2": {
+      "created": "2018-02-02T02:02:02Z",
+      "message": "Fix bar.xml, remove image.tiff, add empty2.txt",
+      "user": {
+        "address": "bob@example.com",
+        "name": "Bob"
+      }
+    },
+    "v3": {
+      "created": "2018-03-03T03:03:03Z",
+      "message": "Reinstate image.tiff, delete empty.txt",
+      "user": {
+        "address": "cecilia@example.com",
+        "name": "Cecilia"
+      }
+    }
+  }
+}
diff --git a/ocfl-object.py b/ocfl-object.py
index adb4276..1b9477b 100755
--- a/ocfl-object.py
+++ b/ocfl-object.py
@@ -6,12 +6,13 @@
 OCFL Objects or just Inventory files alone.
 """
 import argparse
-import json
 import logging
 import sys
 
 import ocfl
-from ocfl.command_line_utils import add_version_arg, check_version_arg, add_version_metadata_args, add_object_args, add_verbosity_args, check_verbosity_args, validate_object
+from ocfl.command_line_utils import add_version_arg, check_version_arg, \
+    add_version_metadata_args, add_object_args, add_verbosity_args, \
+    check_verbosity_args, validate_object
 
 
 class FatalError(Exception):
@@ -22,18 +23,18 @@ def add_common_args(parser, include_version_metadata=False, objdir_required=True
     """Add argparse arguments that are common to many commands."""
     add_verbosity_args(parser)
     # Object files
-    parser.add_argument('--objdir', '--obj', required=objdir_required,
-                        help='read from or write to OCFL object directory objdir')
+    parser.add_argument("--objdir", "--obj", required=objdir_required,
+                        help="read from or write to OCFL object directory objdir")
     # Version metadata and object settings
     obj_params = parser.add_argument_group(title="OCFL object parameters")
-    obj_params.add_argument('--spec-version', '--spec', action='store', default='1.1',
-                            help='OCFL specification version to adhere to')
-    obj_params.add_argument('--digest', default='sha512',
-                            help='digest algorithm to use')
-    obj_params.add_argument('--fixity', action='append',
-                            help='add fixity type to add')
-    obj_params.add_argument('--id', default=None,
-                            help='identifier of object')
+    obj_params.add_argument("--spec-version", "--spec", action="store", default="1.1",
+                            help="OCFL specification version to adhere to")
+    obj_params.add_argument("--digest", default="sha512",
+                            help="digest algorithm to use")
+    obj_params.add_argument("--fixity", action="append",
+                            help="add fixity type to add")
+    obj_params.add_argument("--id", default=None,
+                            help="identifier of object")
     add_object_args(obj_params)
     if include_version_metadata:
         add_version_metadata_args(obj_params)
@@ -41,56 +42,69 @@ def add_common_args(parser, include_version_metadata=False, objdir_required=True
 
 def parse_arguments():
     """Parse command line arguments."""
-    parser = argparse.ArgumentParser(description='Manipulate or validate an OCFL Object or Inventory.',
-                                     formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+    parser = argparse.ArgumentParser(
+        description="Manipulate or validate an OCFL Object or Inventory.",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
     # The only options at the top level are --help and --version
     add_version_arg(parser)
 
-    subparsers = parser.add_subparsers(dest='cmd',
-                                       help='(Show sub-command help with command -h)')
+    subparsers = parser.add_subparsers(
+        dest="cmd",
+        help="(Show sub-command help with command -h)")
 
     # Separate sub-parsers for each command
-    create_parser = subparsers.add_parser('create',
-                                          help='create a new object with version 1 files in --srcdir or from --srcbag')
+    create_parser = subparsers.add_parser(
+        "create",
+        help="create a new object from version 1 files in --srcdir or "
+             "--srcbag, and metadata optionally specified via arguments")
     add_common_args(create_parser, include_version_metadata=True, objdir_required=False)
-    create_parser.add_argument('--srcdir', '--src', action='store',
-                               help='source directory path')
-    create_parser.add_argument('--srcbag', action='store',
-                               help='source Bagit bag path (alternative to --srcdir)')
-
-    build_parser = subparsers.add_parser('build',
-                                         help='build a new object from version directories in --srcdir')
-    add_common_args(build_parser, include_version_metadata=True, objdir_required=False)
-    build_parser.add_argument('--srcdir', '--src', action='store',
-                              help='source directory path')
-
-    update_parser = subparsers.add_parser('update',
-                                          help='update an object by adding a new version from files in --srcdir or from --srcbag')
+    create_parser.add_argument("--srcdir", "--src", action="store",
+                               help="source directory path")
+    create_parser.add_argument("--srcbag", action="store",
+                               help="source Bagit bag path (alternative to --srcdir)")
+
+    build_parser = subparsers.add_parser(
+        "build",
+        help="build a new object from version directories in --srcdir, and "
+             "metadata optionally specified in a JSON file")
+    add_common_args(build_parser, objdir_required=False)
+    build_parser.add_argument("--srcdir", "--src", action="store",
+                              help="source directory path")
+    build_parser.add_argument("--metadata", action="store",
+                              help="path to inventory format JSON file containing metadata (created, message, user) for each version to build")
+
+    update_parser = subparsers.add_parser(
+        "update",
+        help="update an object by adding a new version from files in --srcdir "
+             "or from --srcbag")
     add_common_args(update_parser, include_version_metadata=True)
-    update_parser.add_argument('--srcdir', '--src', action='store',
-                               help='source directory path')
-    update_parser.add_argument('--srcbag', action='store',
-                               help='source Bagit bag path (alternative to --srcdir)')
-
-    add_parser = subparsers.add_parser('show',
-                                       help='show versions and files in an OCFL object')
+    update_parser.add_argument("--srcdir", "--src", action="store",
+                               help="source directory path")
+    update_parser.add_argument("--srcbag", action="store",
+                               help="source Bagit bag path (alternative to --srcdir)")
+
+    add_parser = subparsers.add_parser(
+        "show",
+        help="show versions and files in an OCFL object")
     add_common_args(add_parser)
 
-    validate_parser = subparsers.add_parser('validate',
-                                            help='validate an OCFL object (use ocfl-validate.py for more control)')
+    validate_parser = subparsers.add_parser(
+        "validate",
+        help="validate an OCFL object (use ocfl-validate.py for more control)")
     add_common_args(validate_parser)
 
-    extract_parser = subparsers.add_parser('extract',
-                                           help='extract a specific version (or "head") into --dstdir')
+    extract_parser = subparsers.add_parser(
+        "extract",
+        help="extract a specific version (or `head`) into --dstdir")
     add_common_args(extract_parser)
-    extract_parser.add_argument('--objver', action='store', default='head',
-                                help='object version content to extract (defaults to latest version)')
-    extract_parser.add_argument('--dstdir', '--dst', action='store', default='/tmp/ocfl-out',
-                                help='destination directory path')
-    extract_parser.add_argument('--dstbag', action='store',
-                                help='destination Bagit bag path (alternative to --dstdir)')
-    extract_parser.add_argument('--logical-path', '--path', action='store', default=None,
-                                help='if specified, extract just the file at the specified logical path into --dstdir')
+    extract_parser.add_argument("--objver", action="store", default="head",
+                                help="object version content to extract (defaults to latest version)")
+    extract_parser.add_argument("--dstdir", "--dst", action="store", default="/tmp/ocfl-out",
+                                help="destination directory path")
+    extract_parser.add_argument("--dstbag", action="store",
+                                help="destination Bagit bag path (alternative to --dstdir)")
+    extract_parser.add_argument("--logical-path", "--path", action="store", default=None,
+                                help="if specified, extract just the file at the specified logical path into --dstdir")
 
     args = parser.parse_args()
     check_version_arg(args)
@@ -103,9 +117,8 @@ def parse_arguments():
 
 def print_inventory(inventory):
     """Print out the inventory."""
-    vdir = inventory["head"]
-    print("### Inventory for %s" % (vdir))
-    print(json.dumps(inventory, sort_keys=True, indent=2))
+    print("### Inventory for %s" % (inventory.head))
+    print(inventory.as_json())
 
 
 def do_object_operation(args):
@@ -118,17 +131,19 @@ def do_object_operation(args):
                       dedupe=not args.no_dedupe,
                       lax_digests=args.lax_digests,
                       fixity=args.fixity)
-    if args.cmd == 'create':
+    if args.cmd == "create":
         srcdir = args.srcdir
-        metadata = ocfl.VersionMetadata(args=args)
+        metadata = ocfl.VersionMetadata(created=args.created,
+                                        message=args.message,
+                                        name=args.name,
+                                        address=args.address)
         if args.srcbag is not None:
             srcdir = ocfl.bag_as_source(args.srcbag, metadata)
             if metadata.id is not None:
-                if obj.id:
-                    if obj.id != metadata.id:
-                        raise FatalError("Identifier specified (%s) and identifier from Bagit bag (%s) do not match!" % (obj.id, metadata.id))
-                else:
+                if obj.id is None:
                     obj.id = metadata.id
+                elif obj.id != metadata.id:
+                    raise FatalError("Identifier specified (%s) and identifier from Bagit bag (%s) do not match!" % (obj.id, metadata.id))
         elif args.srcdir is None:
             raise FatalError("Must specify either --srcdir or --srcbag containing v1 files when creating an OCFL object!")
         inventory = obj.create(srcdir=srcdir,
@@ -136,18 +151,28 @@ def do_object_operation(args):
                                objdir=args.objdir)
         if args.objdir is None:
             print_inventory(inventory)
-    elif args.cmd == 'build':
+    elif args.cmd == "build":
         if args.srcdir is None:
             raise FatalError("Must specify --srcdir containing version directories when building an OCFL object!")
-        metadata = ocfl.VersionMetadata(args=args)
+        # Read in per version metadata if given. This is just like reading an
+        # inventory
+        versions_metadata = {}
+        if args.metadata is not None:
+            inv = ocfl.Inventory(filepath=args.metadata)
+            for version in inv.versions():
+                versions_metadata[version.number] = ocfl.VersionMetadata(inventory=inv, version=version.vdir)
+        # Build the object
         inventory = obj.build(srcdir=args.srcdir,
-                              metadata=metadata,
+                              versions_metadata=versions_metadata,
                               objdir=args.objdir)
         if args.objdir is None:
             print_inventory(inventory)
-    elif args.cmd == 'update':
+    elif args.cmd == "update":
         srcdir = args.srcdir
-        metadata = ocfl.VersionMetadata(args=args)
+        metadata = ocfl.VersionMetadata(created=args.created,
+                                        message=args.message,
+                                        name=args.name,
+                                        address=args.address)
         if args.srcbag is not None:
             srcdir = ocfl.bag_as_source(args.srcbag, metadata)
         elif args.srcdir is None:
@@ -155,11 +180,11 @@ def do_object_operation(args):
         obj.update(objdir=args.objdir,
                    srcdir=srcdir,
                    metadata=metadata)
-    elif args.cmd == 'show':
+    elif args.cmd == "show":
         print("Object tree for %s\n%s" % (obj.id, obj.tree(objdir=args.objdir)))
-    elif args.cmd == 'validate':
+    elif args.cmd == "validate":
         validate_object(obj, objdir=args.objdir)
-    elif args.cmd == 'extract':
+    elif args.cmd == "extract":
         if args.logical_path:
             if args.dstbag:
                 raise FatalError("Cannot extract a single file to a Bagit bag.")
diff --git a/ocfl-validate.py b/ocfl-validate.py
index 28d7981..4b41dd8 100755
--- a/ocfl-validate.py
+++ b/ocfl-validate.py
@@ -22,18 +22,18 @@ def parse_arguments():
     Returns Namespace object from argparse parsing of command line arguments.
     """
     parser = argparse.ArgumentParser(
-        description='Validate one or more OCFL objects, storage roots or standalone '
-        'inventory files. By default shows any errors or warnings, and final '
-        'validation status. Use -q to show only errors, -Q to show only validation '
-        'status. HAS NO OPTIONS TO MAKE CHANGES TO THE STORAGE ROOT OR OBJECT.')
-    parser.add_argument('path', type=str, nargs='*',
-                        help='OCFL storage root, object or inventory path(s) to validate')
-    parser.add_argument('--very-quiet', '-Q', action='store_true',
+        description="Validate one or more OCFL objects, storage roots or standalone "
+        "inventory files. By default shows any errors or warnings, and final "
+        "validation status. Use -q to show only errors, -Q to show only validation "
+        "status. HAS NO OPTIONS TO MAKE CHANGES TO THE STORAGE ROOT OR OBJECT.")
+    parser.add_argument("path", type=str, nargs="*",
+                        help="OCFL storage root, object or inventory path(s) to validate")
+    parser.add_argument("--very-quiet", "-Q", action="store_true",
                         help="be very quiet, show only validation status not warnings or errors (implies -q)")
-    parser.add_argument('--lax-digests', action='store_true',
-                        help='allow use of any known digest')
-    parser.add_argument('--no-check-digests', action='store_true',
-                        help='do not check digest values')
+    parser.add_argument("--lax-digests", action="store_true",
+                        help="allow use of any known digest")
+    parser.add_argument("--no-check-digests", action="store_true",
+                        help="do not check digest values")
 
     add_version_arg(parser)
     add_verbosity_args(parser)
@@ -62,7 +62,7 @@ def do_validation(args):
     for path in args.path:
         num += 1
         path_type = ocfl.find_path_type(path)
-        if path_type == 'object':
+        if path_type == "object":
             logging.debug("Validating OCFL Object at %s", path)
             obj = ocfl.Object(lax_digests=args.lax_digests)
             if validate_object(obj, path,
@@ -70,7 +70,7 @@ def do_validation(args):
                                log_errors=log_errors,
                                check_digests=not args.no_check_digests):
                 num_good += 1
-        elif path_type == 'root':
+        elif path_type == "root":
             logging.debug("Validating OCFL Storage Root at %s", path)
             store = ocfl.StorageRoot(root=path,
                                      lax_digests=args.lax_digests)
@@ -78,7 +78,7 @@ def do_validation(args):
                               log_errors=log_errors,
                               check_digests=not args.no_check_digests):
                 num_good += 1
-        elif path_type == 'file':
+        elif path_type == "file":
             logging.debug("Validating separate OCFL Inventory at %s", path)
             obj = ocfl.Object(lax_digests=args.lax_digests)
             if validate_object_inventory(obj, path,
diff --git a/ocfl.py b/ocfl.py
index e1bf35d..5df722f 100755
--- a/ocfl.py
+++ b/ocfl.py
@@ -5,75 +5,89 @@
 import os.path
 import sys
 
-import ocfl  # pylint: disable=import-self; this isn't actually self import
-from ocfl.command_line_utils import add_version_arg, add_verbosity_args, check_version_arg, check_verbosity_args
+import ocfl  # pylint: disable=import-self; this isn"t actually self import
+from ocfl.command_line_utils import add_version_arg, add_verbosity_args, \
+    check_version_arg, check_verbosity_args
 
 
 def add_common_args(parser):
     """Add argparse arguments that are common to many commands."""
-    parser.add_argument('--root',
-                        help='OCFL Storage Root path (must be supplied either via --root or $OCFL_ROOT). The "zip://" prefix can be used with zipped roots.')
-    parser.add_argument('--layout', default=None,
-                        help='Layout of objects under storage root')
-    parser.add_argument('--lax-digests', action='store_true',
-                        help='allow use of any known digest')
+    parser.add_argument("--root",
+                        help="OCFL Storage Root path (must be supplied either via --root or $OCFL_ROOT). The `zip://` prefix can be used with zipped roots.")
+    parser.add_argument("--layout", default=None,
+                        help="Layout of objects under storage root")
+    parser.add_argument("--lax-digests", action="store_true",
+                        help="allow use of any known digest")
     add_verbosity_args(parser)
 
 
 def parse_arguments():
     """Parse command line arguments."""
-    parser = argparse.ArgumentParser(description='Manpulate or validate an OCFL Storage Root and Objects within.',
-                                     formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+    parser = argparse.ArgumentParser(
+        description="Manpulate or validate an OCFL Storage Root and Objects within.",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
     # The only options at the top level are --help and --version
     add_version_arg(parser)
 
-    subparsers = parser.add_subparsers(dest='cmd',
-                                       help='Show sub-command help with command -h')
+    subparsers = parser.add_subparsers(
+        dest="cmd",
+        help="Show sub-command help with command -h")
 
     # Separate sub-parsers for each command
-    create_parser = subparsers.add_parser('create',
-                                          help='Create and initialize storage root')
+    create_parser = subparsers.add_parser(
+        "create",
+        help="Create and initialize storage root")
     add_common_args(create_parser)
-    create_parser.add_argument('--spec-version', '--spec', action='store', default='1.1',
-                               help='OCFL specification version to adhere to')
-    create_parser.add_argument('--layout-params', action='store', default=None,
-                               help='Specify parameters for the selected storage layout as a JSON string (including the extensionName is optional)')
-
-    list_parser = subparsers.add_parser('list', help='List contents of storage root')
+    create_parser.add_argument("--spec-version", "--spec", action="store", default="1.1",
+                               help="OCFL specification version to adhere to")
+    create_parser.add_argument("--layout-params", action="store", default=None,
+                               help="Specify parameters for the selected storage layout as a JSON string (including the extensionName is optional)")
+
+    list_parser = subparsers.add_parser(
+        "list",
+        help="List contents of storage root")
     add_common_args(list_parser)
 
-    validate_parser = subparsers.add_parser('validate', help='Validate a storage root and optioally its contents')
+    validate_parser = subparsers.add_parser(
+        "validate",
+        help="Validate a storage root and optioally its contents")
     add_common_args(validate_parser)
-    validate_parser.add_argument('--validate-objects', action='store_true', default=False,
-                                 help='validate each object in the storage root (MAY TAKE TIME)')
-    validate_parser.add_argument('--check-digests', action='store_true', default=False,
-                                 help='if validating each object, also check all digest (MAY TAKE LOTS OF TIME)')
-    validate_parser.add_argument('--max_errors', default=100,
-                                 help='maximum number of errors to record/show')
-
-    add_parser = subparsers.add_parser('add', help='Add object at --src to the storage root')
+    validate_parser.add_argument("--validate-objects", action="store_true", default=False,
+                                 help="validate each object in the storage root (MAY TAKE TIME)")
+    validate_parser.add_argument("--check-digests", action="store_true", default=False,
+                                 help="if validating each object, also check all digest (MAY TAKE LOTS OF TIME)")
+    validate_parser.add_argument("--max_errors", default=100,
+                                 help="maximum number of errors to record/show")
+
+    add_parser = subparsers.add_parser("add", help="Add object at --src to the storage root")
     add_common_args(add_parser)
-    add_parser.add_argument('--src', default=None,
-                            help='source path of object or version')
+    add_parser.add_argument("--src", default=None,
+                            help="source path of object or version")
 
-    purge_parser = subparsers.add_parser('purge', help='Purge (delete) an object --id from the storage root')
+    purge_parser = subparsers.add_parser(
+        "purge",
+        help="Purge (delete) an object --id from the storage root")
     add_common_args(purge_parser)
-    purge_parser.add_argument('--id', default=None,
-                              help='identifier of object')
+    purge_parser.add_argument("--id", default=None,
+                              help="identifier of object")
 
-    show_parser = subparsers.add_parser('show', help='Show versions and files in an OCFL object')
+    show_parser = subparsers.add_parser(
+        "show",
+        help="Show versions and files in an OCFL object")
     add_common_args(show_parser)
-    show_parser.add_argument('--id', default=None,
-                             help='identifier of object')
+    show_parser.add_argument("--id", default=None,
+                             help="identifier of object")
 
-    path_parser = subparsers.add_parser('path', help='Show path to a specific OCFL object')
+    path_parser = subparsers.add_parser(
+        "path",
+        help="Show path to a specific OCFL object")
     add_common_args(path_parser)
-    path_parser.add_argument('--id', default=None,
-                             help='identifier of object')
+    path_parser.add_argument("--id", default=None,
+                             help="identifier of object")
 
-    validate_object_parser = subparsers.add_parser('validate-object', help='Validate an OCFL object')
-    validate_object_parser.add_argument('--id', default=None,
-                                        help='identifier of object')
+    validate_object_parser = subparsers.add_parser("validate-object", help="Validate an OCFL object")
+    validate_object_parser.add_argument("--id", default=None,
+                                        help="identifier of object")
 
     args = parser.parse_args()
     check_version_arg(args)
@@ -123,7 +137,7 @@ def get_storage_root(args):
     """
     if args.root:
         return args.root
-    env_root = os.getenv('OCFL_ROOT')
+    env_root = os.getenv("OCFL_ROOT")
     if env_root is not None:
         return env_root
     logging.error("The storage root must be set either via --root or $OCFL_ROOT")
@@ -135,27 +149,27 @@ def do_store_operation(args):
     store = ocfl.StorageRoot(root=get_storage_root(args),
                              layout_name=args.layout,
                              lax_digests=args.lax_digests)
-    if args.cmd == 'create':
+    if args.cmd == "create":
         store.initialize(spec_version=args.spec_version, layout_params=args.layout_params)
         print("Created OCFL storage root %s" % (store.root))
-    elif args.cmd == 'list':
+    elif args.cmd == "list":
         for (dirpath, identifier) in store.list_objects():
             print("%s -- id=%s" % (dirpath, identifier))
         print("Found %d OCFL Objects under root %s" % (store.num_objects, store.root))
-    elif args.cmd == 'validate':
+    elif args.cmd == "validate":
         validate(store, args)
-    elif args.cmd == 'add':
+    elif args.cmd == "add":
         if not args.src:
             raise ocfl.StorageRootException("Must specify object path with --src")
         (identifier, path) = store.add(object_path=args.src)
         print("Added object %s at path %s" % (identifier, path))
-    elif args.cmd == 'purge':
+    elif args.cmd == "purge":
         logging.error("purge not implemented")
-    elif args.cmd in ('show', 'path', 'validate_object'):
+    elif args.cmd in ("show", "path", "validate_object"):
         if not args.id:
             raise ocfl.StorageRootException("Must specify id to act on an object in the store")
         objdir = store.object_path(args.id)
-        if args.cmd == 'path':
+        if args.cmd == "path":
             if args.quiet:
                 # Just print the full path
                 print(os.path.join(store.root, objdir))
@@ -163,7 +177,7 @@ def do_store_operation(args):
                 print("Path to %s inside root %s is %s" % (args.id, store.root, objdir))
         else:
             obj = ocfl.Object(identifier=args.id)
-            if args.cmd == 'show':
+            if args.cmd == "show":
                 logging.warning("Object tree\n%s", obj.tree(objdir=objdir))
             else:
                 logging.error("validate not implemented")
diff --git a/ocfl/__init__.py b/ocfl/__init__.py
index a752e5f..c823357 100644
--- a/ocfl/__init__.py
+++ b/ocfl/__init__.py
@@ -2,11 +2,12 @@
 import sys
 from ._version import __version__
 from .object import *
+from .inventory import *
 from .version_metadata import *
 from .storage_root import *
 from .digest import *
 from .bagger import bag_as_source, bag_extracted_version, BaggerError
-from .object_utils import find_path_type
+from .object_utils import find_path_type, ObjectException
 
 if sys.version_info < (3, 6):  # pragma: no cover
     raise Exception("Must use python 3.6 or greater!")  # pragma: no cover
diff --git a/ocfl/_version.py b/ocfl/_version.py
index 1690b36..1e483a1 100644
--- a/ocfl/_version.py
+++ b/ocfl/_version.py
@@ -1,2 +1,2 @@
 """Version number for this Python implementation of OCFL."""
-__version__ = '1.9.0'
+__version__ = "1.9.0"
diff --git a/ocfl/bagger.py b/ocfl/bagger.py
index 2df9f96..97020b4 100644
--- a/ocfl/bagger.py
+++ b/ocfl/bagger.py
@@ -27,51 +27,53 @@ def bag_as_source(srcbag, metadata):
     """Validate and read metadata from srcbag as input.
 
     The notion of a bag being valid includes it being complete, ie. not having
-    a fetch.txt to provide URLs for files that are not included in local
-    filesystem. We thus don't need to test for that case, bagit.is_valid() is
+    a `fetch.txt` to provide URLs for files that are not included in local
+    filesystem. We thus don"t need to test for that case, `bagit.is_valid()` is
     enough.
 
     Arguments:
-        srcbag - The directory of the bag on disk
-        metadata - A VersionMetadata object that will be updated with metadata
-            from the bag
+        srcbag: the directory of the bag on disk
+        metadata: a VersionMetadata object that will be updated with metadata
+            from the bag, including the object id
 
     Returns the srcdir for OCFL object content within the bag as it should be
     expressed in the state block.
+
+    Raises BaggerError if the bag is not valid.
     """
     bag = bagit.Bag(srcbag)
     if not bag.is_valid():
         raise BaggerError("Source Bagit bag at %s is not valid" % (srcbag))
     # Local arguments override but otherwise take metadata from bag-info.txt
-    if not metadata.id and 'External-Identifier' in bag.info:
-        metadata.id = bag.info['External-Identifier']
-    if not metadata.created and 'Bagging-Date' in bag.info:
-        metadata.created = bag.info['Bagging-Date'] + 'T00:00:00Z'  # FIXME - timezone fudge
-    if not metadata.message and 'External-Description' in bag.info:
-        metadata.message = bag.info['External-Description']
-    if not metadata.name and 'Contact-Name' in bag.info:
-        metadata.name = bag.info['Contact-Name']
-    if not metadata.address and 'Contact-Email' in bag.info:
-        metadata.address = 'mailto:' + bag.info['Contact-Email']
-    return os.path.join(srcbag, 'data')
+    if not metadata.id and "External-Identifier" in bag.info:
+        metadata.id = bag.info["External-Identifier"]
+    if not metadata.created and "Bagging-Date" in bag.info:
+        metadata.created = bag.info["Bagging-Date"] + "T00:00:00Z"  # FIXME - timezone fudge
+    if not metadata.message and "External-Description" in bag.info:
+        metadata.message = bag.info["External-Description"]
+    if not metadata.name and "Contact-Name" in bag.info:
+        metadata.name = bag.info["Contact-Name"]
+    if not metadata.address and "Contact-Email" in bag.info:
+        metadata.address = "mailto:" + bag.info["Contact-Email"]
+    return os.path.join(srcbag, "data")
 
 
 def bag_extracted_version(dst, metadata):
     """Bag the extracted files in dst using metadata from metadata.
 
     Paramaters:
-        dst - Destination location for the bag that initially contains the
+        dst: destination location for the bag that initially contains the
             content to be bagged (ie. end up in the data directory)
-        metadata - A VersionMetadata object that has metadata to add into
-            the bag information
+        metadata: a VersionMetadata object that has metadata to add into
+            the bag information, including the id
     """
     tags = {}
     if metadata.id:
-        tags['External-Identifier'] = metadata.id
+        tags["External-Identifier"] = metadata.id
     if metadata.message:
-        tags['External-Description'] = metadata.message
+        tags["External-Description"] = metadata.message
     if metadata.name:
-        tags['Contact-Name'] = metadata.name
-    if metadata.address and metadata.address.startswith('mailto:'):
-        tags['Contact-Email'] = metadata.address[7:]
-    bagit.make_bag(dst, bag_info=tags, checksums=['sha512'])
+        tags["Contact-Name"] = metadata.name
+    if metadata.address and metadata.address.startswith("mailto:"):
+        tags["Contact-Email"] = metadata.address[7:]
+    bagit.make_bag(dst, bag_info=tags, checksums=["sha512"])
diff --git a/ocfl/command_line_utils.py b/ocfl/command_line_utils.py
index 3e18600..803d20c 100755
--- a/ocfl/command_line_utils.py
+++ b/ocfl/command_line_utils.py
@@ -8,7 +8,7 @@
 from ._version import __version__
 
 
-NORMALIZATIONS = ['uri', 'md5']  # Must match possibilities in map_filepaths()
+NORMALIZATIONS = ["uri", "md5"]  # Must match possibilities in map_filepaths()
 
 
 class ObjectException(Exception):
@@ -21,8 +21,8 @@ def add_version_arg(parser):
     Arguments:
         parser: argparse.ArgumentParser() object.
     """
-    parser.add_argument('--version', action='store_true',
-                        help='show version number and exit')
+    parser.add_argument("--version", action="store_true",
+                        help="show version number and exit")
 
 
 def check_version_arg(args):
@@ -43,15 +43,15 @@ def add_version_metadata_args(parser):
     Arguments:
         parser: argparse.ArgumentParser() object.
     """
-    parser.add_argument('--created', default=None,
-                        help='creation time to be used with version(s) added, else '
-                             'current time will be recorded')
-    parser.add_argument('--message', default=None,
-                        help='message to be recorded with version(s) added')
-    parser.add_argument('--name', default=None,
-                        help='name of user adding version(s) to object')
-    parser.add_argument('--address', default=None,
-                        help='address of user adding version(s) to object')
+    parser.add_argument("--created", default=None,
+                        help="creation time to be used with version(s) added, else "
+                             "current time will be recorded")
+    parser.add_argument("--message", default=None,
+                        help="message to be recorded with version(s) added")
+    parser.add_argument("--name", default=None,
+                        help="name of user adding version(s) to object")
+    parser.add_argument("--address", default=None,
+                        help="address of user adding version(s) to object")
 
 
 def add_object_args(parser):
@@ -61,19 +61,19 @@ def add_object_args(parser):
         parser: argparse.ArgumentParser() object.
     """
     # Disk scanning
-    parser.add_argument('--skip', action='append', default=['README.md', '.DS_Store'],
-                        help='directories and files to ignore')
-    parser.add_argument('--normalization', '--norm', default=None,
-                        help='filepath normalization strategy (None, %s)' %
-                        (', '.join(NORMALIZATIONS)))
+    parser.add_argument("--skip", action="append", default=["README.md", ".DS_Store"],
+                        help="directories and files to ignore")
+    parser.add_argument("--normalization", "--norm", default=None,
+                        help="filepath normalization strategy (None, %s)" %
+                        (", ".join(NORMALIZATIONS)))
     # Versioning strategy settings
-    parser.add_argument('--no-forward-delta', action='store_true',
-                        help='do not use forward deltas')
-    parser.add_argument('--no-dedupe', '--no-dedup', action='store_true',
-                        help='do not use deduplicate files within a version')
+    parser.add_argument("--no-forward-delta", action="store_true",
+                        help="do not use forward deltas")
+    parser.add_argument("--no-dedupe", "--no-dedup", action="store_true",
+                        help="do not use deduplicate files within a version")
     # Validation settings
-    parser.add_argument('--lax-digests', action='store_true',
-                        help='allow use of any known digest')
+    parser.add_argument("--lax-digests", action="store_true",
+                        help="allow use of any known digest")
 
 
 def add_verbosity_args(parser):
@@ -82,11 +82,11 @@ def add_verbosity_args(parser):
     Arguments:
         parser: argparse.ArgumentParser() object.
     """
-    parser.add_argument('--verbose', '-v', action='store_true',
+    parser.add_argument("--verbose", "-v", action="store_true",
                         help="be more verbose")
-    parser.add_argument('--debug', action='store_true',
+    parser.add_argument("--debug", action="store_true",
                         help="show debugging messages (more verbose than -v)")
-    parser.add_argument('--quiet', '-q', action='store_true',
+    parser.add_argument("--quiet", "-q", action="store_true",
                         help="be quiet, do not show warnings")
 
 
@@ -127,7 +127,7 @@ def validate_object(obj, objdir, log_warnings=True,
                                      log_errors=log_errors,
                                      check_digests=check_digests)
     messages = str(validator)
-    if messages != '':
+    if messages != "":
         print(messages)
     print("OCFL v%s Object at %s is %s" %
           (validator.spec_version, objdir, "VALID" if passed else "INVALID"))
@@ -158,7 +158,7 @@ def validate_object_inventory(obj, path, log_warnings=True,
                                                log_errors=log_errors,
                                                force_spec_version=force_spec_version)
     messages = str(validator)
-    if messages != '':
+    if messages != "":
         print(messages)
     print("Standalone OCFL v%s inventory at %s is %s" %
           (validator.spec_version, path, "VALID" if passed else "INVALID"))
diff --git a/ocfl/digest.py b/ocfl/digest.py
index ab5d143..31bed62 100755
--- a/ocfl/digest.py
+++ b/ocfl/digest.py
@@ -1,16 +1,9 @@
 """Digest handling for OCFL."""
 import hashlib
-import fs
-from .pyfs import open_fs
-
-BUFSIZE = 64 * 1024  # 64kB for want of better info...
 
+from .pyfs import pyfs_openbin
 
-def _fs_digest(pyfs, filename, digester):
-    """Update digester reading from filename."""
-    with pyfs.openbin(filename, 'r') as fh:
-        for b in iter(lambda: fh.read(BUFSIZE), b''):
-            digester.update(b)
+BUFSIZE = 64 * 1024  # 64kB for want of better info...
 
 
 def _file_digest(pyfs, filename, digester):
@@ -19,24 +12,21 @@ def _file_digest(pyfs, filename, digester):
     Like haslib.sha256 and hashlib.sha512, the digester object must
     support the .update() and .hexdigest() methods.
     """
-    if pyfs is None:
-        (dir_name, name) = fs.path.split(filename)
-        with open_fs(dir_name) as dir_fs:
-            _fs_digest(dir_fs, name, digester)
-    else:
-        _fs_digest(pyfs, filename, digester)
+    with pyfs_openbin(filename, "r", pyfs=pyfs) as fh:
+        for b in iter(lambda: fh.read(BUFSIZE), b""):
+            digester.update(b)
     return digester.hexdigest()
 
 
-def file_digest(filename, digest_type='sha512', pyfs=None):
+def file_digest(filename, digest_type="sha512", pyfs=None):
     """Digest of digest_type for file filename in normalized form.
 
     Supports digest_type values from OCFL spec:
-        'md5', 'sha1', 'sha256', 'sha512', 'blake2b-512'
+        "md5", "sha1", "sha256", "sha512", "blake2b-512"
     and from OCFL extensions
-        'blake2b-160', 'blake2b-256', 'blake2b-384'
+        "blake2b-160", "blake2b-256", "blake2b-384"
     and a truncated sha512 and sha256 useful for examples
-        'sha512-spec-ex',  'sha256-spec-ex'
+        "sha512-spec-ex",  "sha256-spec-ex"
 
     Arguments:
         filename: string with name of file to calculate digest for
@@ -49,36 +39,36 @@ def file_digest(filename, digest_type='sha512', pyfs=None):
     Raises a ValueError exception if the digest_type is not supported.
     """
     # From spec
-    if digest_type == 'sha512':
+    if digest_type == "sha512":
         return _file_digest(pyfs, filename, hashlib.sha512())
-    if digest_type == 'sha256':
+    if digest_type == "sha256":
         return _file_digest(pyfs, filename, hashlib.sha256())
-    if digest_type == 'sha1':
+    if digest_type == "sha1":
         return _file_digest(pyfs, filename, hashlib.sha1())
-    if digest_type == 'md5':
+    if digest_type == "md5":
         return _file_digest(pyfs, filename, hashlib.md5())
-    if digest_type == 'blake2b-512':
+    if digest_type == "blake2b-512":
         return _file_digest(pyfs, filename, hashlib.blake2b())
     # From extensions
-    if digest_type == 'blake2b-160':
+    if digest_type == "blake2b-160":
         return _file_digest(pyfs, filename, hashlib.blake2b(digest_size=20))
-    if digest_type == 'blake2b-256':
+    if digest_type == "blake2b-256":
         return _file_digest(pyfs, filename, hashlib.blake2b(digest_size=32))
-    if digest_type == 'blake2b-384':
+    if digest_type == "blake2b-384":
         return _file_digest(pyfs, filename, hashlib.blake2b(digest_size=48))
     # Specification examples: 15/6 chars ... 3 chars. The truncated
     # sha512 is twice as many chars as the truncated sha256 to give
     # a appropriate impression in examples
-    if digest_type == 'sha512-spec-ex':
+    if digest_type == "sha512-spec-ex":
         d = _file_digest(pyfs, filename, hashlib.sha512())
-        return d[:15] + '...' + d[-3:]
-    if digest_type == 'sha256-spec-ex':
+        return d[:15] + "..." + d[-3:]
+    if digest_type == "sha256-spec-ex":
         d = _file_digest(pyfs, filename, hashlib.sha256())
-        return d[:6] + '...' + d[-3:]
+        return d[:6] + "..." + d[-3:]
     raise ValueError("Unsupport digest type %s" % (digest_type))
 
 
-def string_digest(txt, digest_type='sha512'):
+def string_digest(txt, digest_type="sha512"):
     """Digest of txt string with given digest in normalized form.
 
     Argument:
@@ -89,33 +79,33 @@ def string_digest(txt, digest_type='sha512'):
 
     Raises ValueError if the digest type is not supported.
     """
-    txt_enc = txt.encode('utf8')
-    if digest_type == 'sha512':
+    txt_enc = txt.encode("utf8")
+    if digest_type == "sha512":
         return hashlib.sha512(txt_enc).hexdigest()
-    if digest_type == 'sha256':
+    if digest_type == "sha256":
         return hashlib.sha256(txt_enc).hexdigest()
-    if digest_type == 'sha1':
+    if digest_type == "sha1":
         return hashlib.sha1(txt_enc).hexdigest()
-    if digest_type == 'md5':
+    if digest_type == "md5":
         return hashlib.md5(txt_enc).hexdigest()
     raise ValueError("Unsupport digest type %s" % (digest_type))
 
 
 DIGEST_REGEXES = {
-    'sha512': r'''^[0-9a-fA-F]{128}$''',
-    'sha256': r'''^[0-9a-fA-F]{64}$''',
-    'sha1': r'''^[0-9a-fA-F]{40}$''',
-    'md5': r'''^[0-9a-fA-F]{32}$''',
-    'blake2b-512': r'''^[0-9a-fA-F]{128}$''',
-    'blake2b-384': r'''^[0-9a-fA-F]{96}$''',
-    'blake2b-256': r'''^[0-9a-fA-F]{64}$''',
-    'blake2b-160': r'''^[0-9a-fA-F]{40}$''',
-    'sha512-spec-ex': r'''^[0-9a-f]{15}\.\.\.[0-9a-f]{3}$''',
-    'sha256-spec-ex': r'''^[0-9a-f]{6}\.\.\.[0-9a-f]{3}$'''
+    "sha512": r"""^[0-9a-fA-F]{128}$""",
+    "sha256": r"""^[0-9a-fA-F]{64}$""",
+    "sha1": r"""^[0-9a-fA-F]{40}$""",
+    "md5": r"""^[0-9a-fA-F]{32}$""",
+    "blake2b-512": r"""^[0-9a-fA-F]{128}$""",
+    "blake2b-384": r"""^[0-9a-fA-F]{96}$""",
+    "blake2b-256": r"""^[0-9a-fA-F]{64}$""",
+    "blake2b-160": r"""^[0-9a-fA-F]{40}$""",
+    "sha512-spec-ex": r"""^[0-9a-f]{15}\.\.\.[0-9a-f]{3}$""",
+    "sha256-spec-ex": r"""^[0-9a-f]{6}\.\.\.[0-9a-f]{3}$"""
 }
 
 
-def digest_regex(digest_type='sha512'):
+def digest_regex(digest_type="sha512"):
     """Regex to be used to check the un-normalized form of a digest string.
 
     Argument:
@@ -131,7 +121,7 @@ def digest_regex(digest_type='sha512'):
         raise ValueError("Unsupport digest type %s" % (digest_type))
 
 
-def normalized_digest(digest, digest_type='sha512'):
+def normalized_digest(digest, digest_type="sha512"):
     """Normalize the digest to return version that enables string comparison.
 
     Arguments:
@@ -141,6 +131,6 @@ def normalized_digest(digest, digest_type='sha512'):
     At present, digest types supported (except the spec example forms) are case
     insensitive. We use lowercase as the normalized form.
     """
-    if digest_type in ('sha512-spec-ex', 'sha256-spec-ex'):
+    if digest_type in ("sha512-spec-ex", "sha256-spec-ex"):
         return digest
     return digest.lower()
diff --git a/ocfl/inventory.py b/ocfl/inventory.py
new file mode 100644
index 0000000..27a8cd7
--- /dev/null
+++ b/ocfl/inventory.py
@@ -0,0 +1,640 @@
+"""OCFL Inventory and Version.
+
+The storage mechanism for the inventory data is the python dict()
+structure resulting from reading the inventory JSON file and suitable
+for writing an inventory JSON file. Here we provide convenient
+property based access to read and set this data safely without
+needing such intimate knowledge of the JSON format.
+"""
+import copy
+import json
+import os.path
+import re
+
+from .digest import normalized_digest
+from .object_utils import first_version_directory, next_version_directory, \
+    parse_version_directory, make_unused_filepath
+from .pyfs import pyfs_openfile
+
+
+class InventoryException(Exception):
+    """Exception class for Inventory class."""
+
+
+class Inventory():  # pylint: disable=too-many-public-methods
+    """Class wrapping OCFL inventory data.
+
+    In general, the property accessors and methods return None if the
+    attribute is a string that is not set in the underlying data, else
+    the value if it is. In the cases that the normal return value would
+    be an array or a dict, then and empty array or empty dict are returned
+    if not present in the underlying data.
+
+    Instance attributes:
+        data: dict that is the top level JSON object of the parsed JSON
+            representation of the inventory file. This is the only place
+            that an Inventory instance stores information.
+    """
+
+    def __init__(self, data=None, filepath=None, pyfs=None):
+        """Initialize Inventory object.
+
+        Argument:
+            data: If not None (default) the must be either an Inventory
+                object or inventory data to initialise from. In either
+                case the underlying data is deep copied to create a separate
+                new object.
+            filepath: If not None then the string file path from which to
+                read a JSON inventory file to initialize from.
+            pyfs: pyfs object for the filesystem to use, else None to use the
+                local filesystem (default). filepath is interpretted within
+                pyfs
+        """
+        if data is None:
+            if filepath is None:
+                self.data = {}
+            else:
+                with pyfs_openfile(filepath, "r", pyfs=pyfs, encoding="utf-8") as fh:
+                    self.data = json.load(fh)
+        elif isinstance(data, Inventory):
+            self.data = copy.deepcopy(data.data)
+        elif isinstance(data, dict):
+            self.data = data
+        else:
+            raise InventoryException("Bad data type supplied to Inventory() creator, " + str(type(data)))
+
+    @property
+    def spec_version(self):
+        """Get specification version from the conformance declaration."""
+        decl = self.data.get("type", "")
+        m = re.match(r"""https://ocfl.io/(\d+.\d)/spec/#inventory""", decl)
+        if m:
+            return m.group(1)
+        return None
+
+    @spec_version.setter
+    def spec_version(self, value):
+        """Set specification version in the conformance declaration."""
+        self.data["type"] = "https://ocfl.io/" + value + "/spec/#inventory"
+
+    @property
+    def digest_algorithm(self):
+        """Get digest algorithm."""
+        return self.data.get("digestAlgorithm")
+
+    @digest_algorithm.setter
+    def digest_algorithm(self, value):
+        """Set digest algorithm."""
+        self.data["digestAlgorithm"] = value
+
+    @property
+    def id(self):
+        """Get object id."""
+        return self.data.get("id")
+
+    @id.setter
+    def id(self, value):
+        """Set object id."""
+        self.data["id"] = value
+
+    @property
+    def head(self):
+        """Get head version directory."""
+        return self.data.get("head")
+
+    @head.setter
+    def head(self, value):
+        """Set head version directory."""
+        self.data["head"] = value
+
+    @property
+    def content_directory(self):
+        """Get contentDirectory."""
+        return self.data.get("contentDirectory")
+
+    @property
+    def content_directory_to_use(self):
+        """Get contentDirectory to use, default 'content' is not specified."""
+        return self.data.get("contentDirectory", "content")
+
+    @content_directory.setter
+    def content_directory(self, value):
+        """Set the contentDirectory."""
+        self.data["contentDirectory"] = value
+
+    @property
+    def manifest(self):
+        """Get the manifest of digests and corresponding content paths.
+
+        Returns dict of digest -> [file paths], an empty dict is there
+        is no manifest.
+        """
+        return self.data.get("manifest", {})
+
+    @manifest.setter
+    def manifest(self, value):
+        """Set the manifest to the supplied dict()."""
+        self.data["manifest"] = value
+
+    @property
+    def manifest_add_if_not_present(self):
+        """Get the manifest of digests and corresponding content paths.
+
+        Returns dict of digest -> [file paths]. As a side effect will create
+        an empty dict in the data structure if none was present, so that new
+        data can be added.
+        """
+        if "manifest" not in self.data:
+            self.data["manifest"] = {}
+        return self.data["manifest"]
+
+    @property
+    def fixity(self):
+        """Get fixity block as dict().
+
+        Returns fixity block else an empty dict() if there is no fixity block.
+        """
+        return self.data.get("fixity", {})
+
+    @fixity.setter
+    def fixity(self, value):
+        """Set the fixity to the supplied dict()."""
+        self.data["fixity"] = value
+
+    @property
+    def content(self):
+        """Get all the content paths and their digests stored within the object.
+
+        Returns a dictionary of content paths with values that are the
+        digests for each file. Essentially an inversion of the manifest.
+        """
+        files = {}
+        for digest, files_for_digest in self.manifest.items():
+            for file in files_for_digest:
+                files[file] = digest
+        return files
+
+    @property
+    def content_paths(self):
+        """Get all the content paths.
+
+        Returns a list of content paths for all files in the object. Will
+        be and empty list if there is no content.
+        """
+        paths = []
+        for files in self.manifest.values():
+            paths += files
+        return paths
+
+    @property
+    def versions_block(self):
+        """Dict of the versions block.
+
+        Returns a dict whether or not there is a versions block in the
+        underlying data.
+        """
+        return self.data.get("versions", {})
+
+    @versions_block.setter
+    def versions_block(self, value):
+        """Set dict of the versions block."""
+        self.data["versions"] = value
+
+    @property
+    def version_directories(self):
+        """List of all version directories.
+
+        Returns a list of all version in the versions block. The values
+        in the list are the version directory names so they will have
+        the "v" prefix and may or may not be zero padded.
+
+        See also: inv.version_numbers for just the numbers: [1, 2, 3].
+        """
+        return list(self.versions_block.keys())
+
+    @property
+    def version_numbers(self):
+        """List of all version numbers as integers."""
+        vnums = []
+        for vdir in self.version_directories:
+            vnums.append(parse_version_directory(vdir))
+        return vnums
+
+    def versiondata(self, vdir):
+        """Return data for the version in vdir.
+
+        Returns a dict whether or not any data exists.
+        """
+        return self.versions_block.get(vdir, {})
+
+    def version(self, vdir):
+        """Version object for the specified version directory."""
+        return Version(self, vdir)
+
+    def versions(self):
+        """Generate Version objects for each version.
+
+        Yields a Version() object for each version in this Inventiry in
+        numeric order.
+        """
+        for vdir in sorted(self.version_directories, key=parse_version_directory):
+            yield Version(self, vdir)
+
+    def digest_for_content_path(self, path):
+        """Return digest corresponding to specified content path.
+
+        Argument:
+            path: string of content path
+
+        Returns None if the content path is not specified in the
+        manifest, else the path string.
+        """
+        for digest, paths in self.manifest.items():
+            if path in paths:
+                return digest
+        return None
+
+    def content_paths_for_digest(self, digest):
+        """Content paths for the given digest.
+
+        Returns a list of content paths or and empty list if there
+        isn't one for the given digest. The list is used because there
+        may be more than one content path for the given digest.
+        """
+        return self.manifest.get(digest, [])
+
+    def content_path_for_digest(self, digest):
+        """Content path for the given digest.
+
+        Arguments:
+            digest: string value of digest
+
+        Returns a content path or None if there isn't one for the
+        given digest. There may actually be more than one content
+        path for the given digest, we return the first in the
+        underlying data.
+        """
+        paths = self.content_paths_for_digest(digest)
+        if len(paths) > 0:
+            return paths[0]
+        return None
+
+    def _next_version_directory(self, zero_padded_width=None):
+        """Work out next version directory.
+
+        Arguments:
+            zero_padded_width: an integer to set the number if digits
+                used for zero padded identifiers, else None (default)
+
+        Returns the next version directory name as a string, based on the
+        set of existing versions in this inventory. Will only use the
+        zero_padded_width setting if there are no current versions and thus
+        we are creating the first version directory name.
+
+        Raises InventoryException if the value of zero_padded_width doesn't
+        make sense,
+        """
+        # Find higest version and add one to get next version number
+        highest_version = 0
+        vdir = None
+        for vdir in self.version_directories:
+            highest_version = max(highest_version,
+                                  parse_version_directory(vdir))
+        if highest_version == 0:
+            return first_version_directory(zero_padded_width)
+        return next_version_directory(vdir)
+
+    def add_version(self, vdir=None, metadata=None, state=None,
+                    zero_padded_width=None):
+        """Add new version object to the versions block.
+
+        Arguments:
+            vdir: string with the version directory name (e.g. "v1" or "v0006").
+                If None then will create the next version in sequence
+            metadata: dict to initialize version metadata with, else None to
+                create empty (default)
+            state: either a dict with the state block for the version, an
+                object with an as_dict() method to producde such a
+                dictionary (e.g. from VersionMetadat), else None (default)
+            zero_padded_width: an integer to set the number if digits
+                used for zero padded identifiers, else None (default). Applies
+                only when creating first version
+
+        Returns a Version object that may be used to access version properties.
+        """
+        # Work out the new version directory if not specified
+        if vdir is None:
+            vdir = self._next_version_directory(zero_padded_width=zero_padded_width)
+        # Add the new version information
+        if "versions" not in self.data:
+            self.data["versions"] = {}
+        if metadata is None:
+            self.data["versions"][vdir] = {}
+        elif isinstance(metadata, dict):
+            self.data["versions"][vdir] = metadata
+        else:
+            self.data["versions"][vdir] = metadata.as_dict()
+        if state is not None:
+            self.data["versions"][vdir]["state"] = state
+        # Update head to point to the newly added version
+        self.head = vdir
+        return self.version(vdir)
+
+    def add_file(self, *, digest, content_path):
+        """Add file to the manifest.
+
+        Arguments:
+            digest: the digest string computed with the specified digest
+                algorithm.
+            content_path: the full content path including version directory,
+                content directory, and the path with the content directory.
+
+        Adds and entry to the manifest with the specified digest and the
+        specified content_path. Takes account of mutliple content paths with
+        the same digest.
+
+        Raises and InventoryException if there is an attempt to add a
+        content_path that is already included.
+
+        WARNING: Does not check that the content path is valid in that it
+        is within an extant version director, or that it is within the
+        specified content_directory for a version.
+
+        See also: Version.add_file() to add a file with logical_path in the
+        context of a specific version.
+        """
+        if content_path in self.content_paths:
+            raise InventoryException("Attempt to add a content path that already exists: %s" % content_path)
+        # Does this digest already exist?
+        if digest in self.manifest_add_if_not_present:
+            # Yes, add to file list
+            self.manifest[digest].append(content_path)
+        else:
+            # No, new manifest etry
+            self.manifest[digest] = [content_path]
+
+    def as_json(self):
+        """Serlialize JSON representation."""
+        return json.dumps(self.data, sort_keys=True, indent=2)
+
+    def write_json(self, fh):
+        """Serialise JSON representation to file.
+
+        Arguments:
+            fh - filehandle to write to
+        """
+        json.dump(self.data, fh, sort_keys=True, indent=2)
+
+    def init_manifest_and_versions(self):
+        """Initialize manifest and versions blocks for building new inventory."""
+        self.manifest = {}
+        self.versions_block = {}
+
+    def add_fixity_type(self, digest_algorithm, map=None):
+        """Add fixity type with no file data.
+
+        Arguments:
+            digest_algorithm: string of the digest algorithm specifying this
+                fixity type
+            map: None (default) to create an empty entry for the specified
+                digest algorithm, else a dict() with mapping from digest
+                to array of files according to the specified digest algorithm
+
+        If there is no fixity data then will start a fixity block.
+        """
+        if "fixity" not in self.data:
+            self.data["fixity"] = {}
+        self.data["fixity"][digest_algorithm] = {} if map is None else map
+
+    def add_fixity_data(self, digest_algorithm, digest, filepath):
+        """Add fixity information for a file.
+
+        Arguments:
+            digest_algorithm: string of the digest algorithm specifying this
+                fixity type
+
+        Assumes that there is already fixity block and within that a block for
+        the specific digest_algorithm.
+        """
+        fixities = self.fixity[digest_algorithm]
+        if digest not in fixities:
+            fixities[digest] = [filepath]
+        else:
+            fixities[digest].append(filepath)
+
+    def normalize_digests(self, digest_algorithm=None):
+        """Normalize the digests used in manifest and state.
+
+        Arguments:
+            digest_algorithm: string with the name of the digest algorithm
+                used
+
+        No arguments and no return value. Operates on the current object
+        in-place, normalizing the digest values use in the manifest and
+        state blocks. Does not change any separate fixity information.
+        """
+        from_to = {}
+        manifest = self.manifest
+        for digest in manifest:
+            norm_digest = normalized_digest(digest, digest_algorithm)
+            if digest != norm_digest:
+                from_to[digest] = norm_digest
+        for (digest, norm_digest) in from_to.items():
+            manifest[norm_digest] = manifest.pop(digest)
+        for v in self.versions():
+            state = v.state
+            from_to = {}
+            for digest in state:
+                norm_digest = normalized_digest(digest, digest_algorithm)
+                if digest != norm_digest:
+                    from_to[digest] = norm_digest
+            for (digest, norm_digest) in from_to.items():
+                state[norm_digest] = state.pop(digest)
+
+
+class Version():
+    """Version class to represent version information in an Inventory.
+
+    The class stores only pointers to the appropriate inventory and
+    version directory with the versions block. These are used to access
+    data in the inventory.
+    """
+
+    def __init__(self, inv, vdir):
+        """Initialize Version object.
+
+        Arguments:
+            inv: Inventory object for which this is one version.
+            vdir: Version directory string for the version of interest.
+        """
+        self.inv = inv
+        self.vdir = vdir
+
+    @property
+    def created(self):
+        """Created string for this version."""
+        return self.inv.versiondata(self.vdir).get("created")
+
+    @created.setter
+    def created(self, value):
+        """Set created string for this version."""
+        self.inv.versiondata(self.vdir)["created"] = value
+
+    @property
+    def message(self):
+        """Message string for this verion."""
+        return self.inv.versiondata(self.vdir).get("message")
+
+    @message.setter
+    def message(self, value):
+        """Set message string for this version."""
+        self.inv.versiondata(self.vdir)["message"] = value
+
+    @property
+    def state(self):
+        """State block for this version.
+
+        Returns a dict for the state block or and empty dict if
+        there is no state block.
+        """
+        return self.inv.versiondata(self.vdir).get("state", {})
+
+    @state.setter
+    def state(self, value):
+        """Set state block for this version."""
+        self.inv.versiondata(self.vdir)["state"] = value
+
+    @property
+    def state_add_if_not_present(self):
+        """State block for this version.
+
+        Returns a dict for the state block.As a side effect will create
+        an empty dict in the data structure if none was present, so that new
+        data can be added.
+        """
+        if "state" not in self.inv.versiondata(self.vdir):
+            self.inv.versiondata(self.vdir)["state"] = {}
+        return self.inv.versiondata(self.vdir)["state"]
+
+    @property
+    def user(self):
+        """User block for this version."""
+        return self.inv.versiondata(self.vdir).get("user", {})
+
+    @property
+    def user_add_if_not_present(self):
+        """User block for this version, add if not present."""
+        if "user" not in self.inv.versiondata(self.vdir):
+            self.inv.versiondata(self.vdir)["user"] = {}
+        return self.inv.versiondata(self.vdir)["user"]
+
+    @property
+    def user_address(self):
+        """Address element in the user description for this version.
+
+        Returns a string or None is there is no name.
+        """
+        return self.user.get("address")
+
+    @user_address.setter
+    def user_address(self, value):
+        """Set address element in the user description for this version."""
+        self.user_add_if_not_present["address"] = value
+
+    @property
+    def user_name(self):
+        """Name element in the user description for this version.
+
+        Returns a string or None is there is no name.
+        """
+        return self.user.get("name")
+
+    @user_name.setter
+    def user_name(self, value):
+        """Set name element in the user description for this version."""
+        self.user_add_if_not_present["name"] = value
+
+    @property
+    def logical_paths(self):
+        """List of the logical paths in this version.
+
+        Returns a list, will be empty if there are no logical paths.
+        """
+        paths = []
+        for files in self.state.values():
+            paths += files
+        return paths
+
+    @property
+    def number(self):
+        """Version number for this version.
+
+        See also vdir attribute for version directory name.
+        """
+        return parse_version_directory(self.vdir)
+
+    def digest_for_logical_path(self, path):
+        """Digest for the given logical path in this version.
+
+        Return digest string or None is path not found.
+        """
+        for digest, paths in self.state.items():
+            if path in paths:
+                return digest
+        return None
+
+    def content_path_for_logical_path(self, path):
+        """Content path for the file in this version for the logical path.
+
+        Note that there could be more than one content path with the
+        digest. Here we just return the first one. Returns None if there
+        no matching content path.
+        """
+        digest = self.digest_for_logical_path(path)
+        if digest is None:
+            return None
+        return self.inv.content_path_for_digest(digest)
+
+    def add_file(self, *, digest, logical_path, content_path=None, dedupe=True):
+        """Add information for a file with given digest in this version.
+
+        Arguments:
+            digest: the file digest that must be created using the
+                appropriate digestAlgorithm.
+            logical_path: path within the state for this version.
+            content_path: local content path to be used if a file is created
+                within this version. If not specified then the logical_path name
+                will be used as the basis.
+            dedupe: bool, True (default) to not add files for which there is
+                already a file with the same digest, False to add anyway
+
+        Returns the full content path (including vdir and content directory) of
+        the file added, else None if no file was added. Makes changes to both
+        the state for this version and the inventory manifest.
+
+        Raises an InventoryException if there is an attempt to add a logical
+        path that already exists in this version.
+        """
+        # Check logical_file not already present
+        if logical_path in self.logical_paths:
+            raise InventoryException("Logical path already exists in this version: %s" % logical_path)
+        # Do we have any files with this digest already?
+        files = self.inv.content_paths_for_digest(digest)
+        if len(files) == 0 or not dedupe:
+            # Add the file because no match exists or we don't want to dedupe
+            suggested = os.path.join(self.vdir,
+                                     self.inv.content_directory_to_use,
+                                     logical_path if content_path is None else content_path)
+            content_path = make_unused_filepath(filepath=suggested,
+                                                used=self.inv.content_paths)
+            # Have location now, add to manifest
+            self.inv.add_file(digest=digest, content_path=content_path)
+        else:
+            # File or files with same digest exist
+            content_path = None
+        # Now add to the version state
+        if digest in self.state_add_if_not_present:
+            self.state[digest].append(logical_path)
+        else:
+            self.state[digest] = [logical_path]
+        return content_path
diff --git a/ocfl/inventory_validator.py b/ocfl/inventory_validator.py
index 459beaf..fa48922 100644
--- a/ocfl/inventory_validator.py
+++ b/ocfl/inventory_validator.py
@@ -20,8 +20,8 @@ def get_logical_path_map(inventory, version):
     The set of content_files may includes references to duplicate files in
     later versions than the version being described.
     """
-    state = inventory['versions'][version]['state']
-    manifest = inventory['manifest']
+    state = inventory["versions"][version]["state"]
+    manifest = inventory["manifest"]
     file_map = {}
     for digest in state:
         if digest in manifest:
@@ -33,27 +33,41 @@ def get_logical_path_map(inventory, version):
 class InventoryValidator():
     """Class for OCFL Inventory Validator."""
 
-    def __init__(self, log=None, where='???',
-                 lax_digests=False, default_spec_version='1.1'):
-        """Initialize OCFL Inventory Validator."""
+    def __init__(self, *, log=None, where="???",
+                 lax_digests=False, default_spec_version="1.1"):
+        """Initialize OCFL Inventory Validator.
+
+        Keyword arguments:
+            log: a ValidationLogger instance to log errors and
+                warnings encountered during validation. If not
+                set (None, the default) then a new instance with
+                logging defaults is created.
+            where: a string used in log messages to indicate where
+                the issues occurs. Is it the root inventory or a
+                specific version? Defaults to "???".
+            lax_digests: True to allow any digest to be used for
+                content addressing, as opposed to only those allowed
+                by the specification.
+            default_spec_version: string (default "1.1") indicating
+               the specification version to assume if it is not set.
+        """
         self.log = ValidationLogger() if log is None else log
         self.where = where
         self.default_spec_version = default_spec_version
+        self.lax_digests = lax_digests
         # Object state
         self.inventory = None
         self.id = None
         self.spec_version = self.default_spec_version
-        self.digest_algorithm = 'sha512'
-        self.content_directory = 'content'
+        self.digest_algorithm = "sha512"
+        self.content_directory = "content"
         self.content_directory_set = False
         self.all_versions = []
         self.manifest_files = None
         self.unnormalized_digests = None
-        self.head = 'UNKNOWN'
-        # Validation control
-        self.lax_digests = lax_digests
-        # Configuration
-        self.spec_versions_supported = ('1.0', '1.1')
+        self.head = "UNKNOWN"
+        # Configuration data
+        self.spec_versions_supported = ("1.0", "1.1")
 
     def error(self, code, **args):
         """Error with added context."""
@@ -68,7 +82,7 @@ def validate(self, inventory, force_spec_version=None):
 
         Normally, code will look at the type value to determine the specification
         version against which to validate the inventory. In the case that there
-        is no type value or it isn't valid, then other tests will be based on the
+        is no type value or it isn"t valid, then other tests will be based on the
         specification version given in self.default_spec_version.
 
         If force_spec_version is set then the specified specification version
@@ -78,74 +92,74 @@ def validate(self, inventory, force_spec_version=None):
         self.inventory = inventory
         self.spec_version = self.default_spec_version if force_spec_version is None else force_spec_version
         self.log.spec_version = self.spec_version
-        if 'id' in inventory:
-            iid = inventory['id']
-            if not isinstance(iid, str) or iid == '':
+        if "id" in inventory:
+            iid = inventory["id"]
+            if not isinstance(iid, str) or iid == "":
                 self.error("E037a")
             else:
                 # URI syntax https://www.rfc-editor.org/rfc/rfc3986.html#section-3.1 :
                 # scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
-                if not re.match(r'''[a-z][a-z\d\+\-\.]*:.+''', iid, re.IGNORECASE):
+                if not re.match(r"""[a-z][a-z\d\+\-\.]*:.+""", iid, re.IGNORECASE):
                     self.warning("W005", id=iid)
                 self.id = iid
         else:
             self.error("E036a")
-        if 'type' not in inventory:
+        if "type" not in inventory:
             self.error("E036b")
-        elif not isinstance(inventory['type'], str):
+        elif not isinstance(inventory["type"], str):
             self.error("E999")
         elif (force_spec_version
-                and inventory['type'] != 'https://ocfl.io/' + force_spec_version + '/spec/#inventory'):
-            self.error("E038a", expected='https://ocfl.io/' + force_spec_version + '/spec/#inventory', got=inventory['type'])
+                and inventory["type"] != "https://ocfl.io/" + force_spec_version + "/spec/#inventory"):
+            self.error("E038a", expected="https://ocfl.io/" + force_spec_version + "/spec/#inventory", got=inventory["type"])
         else:
             # Extract specification version
-            m = re.match(r'''https://ocfl.io/(\d+.\d)/spec/#inventory''', inventory['type'])
+            m = re.match(r"""https://ocfl.io/(\d+.\d)/spec/#inventory""", inventory["type"])
             if not m:
-                self.error('E038b', got=inventory['type'], assumed_spec_version=self.spec_version)
+                self.error("E038b", got=inventory["type"], assumed_spec_version=self.spec_version)
             elif m.group(1) in self.spec_versions_supported:
                 self.spec_version = m.group(1)
                 self.log.spec_version = self.spec_version
             else:
                 self.error("E038c", got=m.group(1), assumed_spec_version=self.spec_version)
-        if 'digestAlgorithm' not in inventory:
+        if "digestAlgorithm" not in inventory:
             self.error("E036c")
-        elif inventory['digestAlgorithm'] == 'sha512':
+        elif inventory["digestAlgorithm"] == "sha512":
             pass
         elif self.lax_digests:
-            self.digest_algorithm = inventory['digestAlgorithm']
-        elif inventory['digestAlgorithm'] == 'sha256':
+            self.digest_algorithm = inventory["digestAlgorithm"]
+        elif inventory["digestAlgorithm"] == "sha256":
             self.warning("W004")
-            self.digest_algorithm = inventory['digestAlgorithm']
+            self.digest_algorithm = inventory["digestAlgorithm"]
         else:
-            self.error("E025a", digest_algorithm=inventory['digestAlgorithm'])
+            self.error("E025a", digest_algorithm=inventory["digestAlgorithm"])
             return
-        if 'contentDirectory' in inventory:
+        if "contentDirectory" in inventory:
             # Careful only to set self.content_directory if value is safe
-            cd = inventory['contentDirectory']
-            if not isinstance(cd, str) or '/' in cd:
+            cd = inventory["contentDirectory"]
+            if not isinstance(cd, str) or "/" in cd:
                 self.error("E017")
-            elif cd in ('.', '..'):
+            elif cd in (".", ".."):
                 self.error("E018")
             else:
                 self.content_directory = cd
                 self.content_directory_set = True
         manifest_files_correct_format = None
-        if 'manifest' not in inventory:
+        if "manifest" not in inventory:
             self.error("E041a")
         else:
-            (self.manifest_files, manifest_files_correct_format, self.unnormalized_digests) = self.validate_manifest(inventory['manifest'])
+            (self.manifest_files, manifest_files_correct_format, self.unnormalized_digests) = self.validate_manifest(inventory["manifest"])
         digests_used = []
-        if 'versions' not in inventory:
+        if "versions" not in inventory:
             self.error("E041b")
         else:
-            self.all_versions = self.validate_version_sequence(inventory['versions'])
-            digests_used = self.validate_versions(inventory['versions'], self.all_versions, self.unnormalized_digests)
-        if 'head' not in inventory:
+            self.all_versions = self.validate_version_sequence(inventory["versions"])
+            digests_used = self.validate_versions(inventory["versions"], self.all_versions, self.unnormalized_digests)
+        if "head" not in inventory:
             self.error("E036d")
         elif len(self.all_versions) > 0:
             self.head = self.all_versions[-1]
-            if inventory['head'] != self.head:
-                self.error("E040", got=inventory['head'], expected=self.head)
+            if inventory["head"] != self.head:
+                self.error("E040", got=inventory["head"], expected=self.head)
         if len(self.all_versions) == 0:
             # Abort tests is we don't have a valid version sequence, otherwise
             # there will likely be spurious subsequent error reports
@@ -155,8 +169,8 @@ def validate(self, inventory, force_spec_version=None):
                 self.check_content_paths_map_to_versions(manifest_files_correct_format, self.all_versions)
             if self.manifest_files is not None:
                 self.check_digests_present_and_used(self.manifest_files, digests_used)
-        if 'fixity' in inventory:
-            self.validate_fixity(inventory['fixity'], self.manifest_files)
+        if "fixity" in inventory:
+            self.validate_fixity(inventory["fixity"], self.manifest_files)
 
     def validate_manifest(self, manifest):
         """Validate manifest block in inventory.
@@ -176,16 +190,16 @@ def validate_manifest(self, manifest):
         unnormalized_digests = set()
         manifest_digests = set()
         if not isinstance(manifest, dict):
-            self.error('E041c')
+            self.error("E041c")
         else:
             content_paths = set()
             content_directories = set()
             for digest in manifest:
                 m = re.match(self.digest_regex(), digest)
                 if not m:
-                    self.error('E025b', digest=digest, algorithm=self.digest_algorithm)  # wrong form of digest
+                    self.error("E025b", digest=digest, algorithm=self.digest_algorithm)  # wrong form of digest
                 elif not isinstance(manifest[digest], list):
-                    self.error('E092', digest=digest)  # must have path list value
+                    self.error("E092", digest=digest)  # must have path list value
                 else:
                     unnormalized_digests.add(digest)
                     norm_digest = normalized_digest(digest, self.digest_algorithm)
@@ -214,7 +228,7 @@ def validate_fixity(self, fixity, manifest_files):
             # The value of fixity must be a JSON object. In v1.0 I catch not an object
             # as part of E056 but this was clarified as E111 in v1.1. The value may
             # be an empty object in either case
-            self.error('E056a' if self.spec_version == '1.0' else 'E111')
+            self.error("E056a" if self.spec_version == "1.0" else "E111")
         else:
             for digest_algorithm in fixity:
                 known_digest = True
@@ -222,22 +236,22 @@ def validate_fixity(self, fixity, manifest_files):
                     regex = digest_regex(digest_algorithm)
                 except ValueError:
                     if not self.lax_digests:
-                        self.error('E056b', algorithm=self.digest_algorithm)
+                        self.error("E056b", algorithm=self.digest_algorithm)
                         continue
                     # Match anything
-                    regex = r'''^.*$'''
+                    regex = r"""^.*$"""
                     known_digest = False
                 fixity_algoritm_block = fixity[digest_algorithm]
                 if not isinstance(fixity_algoritm_block, dict):
-                    self.error('E057a', algorithm=self.digest_algorithm)
+                    self.error("E057a", algorithm=self.digest_algorithm)
                 else:
                     digests_seen = set()
                     for digest in fixity_algoritm_block:
                         m = re.match(regex, digest)
                         if not m:
-                            self.error('E057b', digest=digest, algorithm=digest_algorithm)  # wrong form of digest
+                            self.error("E057b", digest=digest, algorithm=digest_algorithm)  # wrong form of digest
                         elif not isinstance(fixity_algoritm_block[digest], list):
-                            self.error('E057c', digest=digest, algorithm=digest_algorithm)  # must have path list value
+                            self.error("E057c", digest=digest, algorithm=digest_algorithm)  # must have path list value
                         else:
                             if known_digest:
                                 norm_digest = normalized_digest(digest, digest_algorithm)
@@ -270,13 +284,13 @@ def validate_version_sequence(self, versions):
         # https://ocfl.io/draft/spec/#version-directories
         zero_padded = None
         max_version_num = 999999  # Excessive limit
-        if 'v1' in versions:
-            fmt = 'v%d'
+        if "v1" in versions:
+            fmt = "v%d"
             zero_padded = False
-            all_versions.append('v1')
+            all_versions.append("v1")
         else:  # Find padding size
             for n in range(2, 11):
-                fmt = 'v%0' + str(n) + 'd'
+                fmt = "v%0" + str(n) + "d"
                 vkey = fmt % 1
                 if vkey in versions:
                     all_versions.append(vkey)
@@ -321,43 +335,43 @@ def validate_versions(self, versions, all_versions, unnormalized_digests):
         digests_used = []
         for v in all_versions:
             version = versions[v]
-            if 'created' not in version:
-                self.error('E048', version=v)  # No created
-            elif not isinstance(versions[v]['created'], str):
-                self.error('E049d', version=v)  # Bad created
+            if "created" not in version:
+                self.error("E048", version=v)  # No created
+            elif not isinstance(versions[v]["created"], str):
+                self.error("E049d", version=v)  # Bad created
             else:
-                created = versions[v]['created']
+                created = versions[v]["created"]
                 try:
                     str_to_datetime(created)  # catch ValueError if fails
-                    if not re.search(r'''(Z|[+-]\d\d:\d\d)$''', created):  # FIXME - kludge
-                        self.error('E049a', version=v)
-                    if not re.search(r'''T\d\d:\d\d:\d\d''', created):  # FIXME - kludge
-                        self.error('E049b', version=v)
+                    if not re.search(r"""(Z|[+-]\d\d:\d\d)$""", created):  # FIXME - kludge
+                        self.error("E049a", version=v)
+                    if not re.search(r"""T\d\d:\d\d:\d\d""", created):  # FIXME - kludge
+                        self.error("E049b", version=v)
                 except ValueError as e:
-                    self.error('E049c', version=v, description=str(e))
-            if 'state' in version:
-                digests_used += self.validate_state_block(version['state'], version=v, unnormalized_digests=unnormalized_digests)
+                    self.error("E049c", version=v, description=str(e))
+            if "state" in version:
+                digests_used += self.validate_state_block(version["state"], version=v, unnormalized_digests=unnormalized_digests)
             else:
-                self.error('E048c', version=v)
-            if 'message' not in version:
-                self.warning('W007a', version=v)
-            elif not isinstance(version['message'], str):
-                self.error('E094', version=v)
-            if 'user' not in version:
-                self.warning('W007b', version=v)
+                self.error("E048c", version=v)
+            if "message" not in version:
+                self.warning("W007a", version=v)
+            elif not isinstance(version["message"], str):
+                self.error("E094", version=v)
+            if "user" not in version:
+                self.warning("W007b", version=v)
             else:
-                user = version['user']
+                user = version["user"]
                 if not isinstance(user, dict):
-                    self.error('E054a', version=v)
+                    self.error("E054a", version=v)
                 else:
-                    if 'name' not in user or not isinstance(user['name'], str):
-                        self.error('E054b', version=v)
-                    if 'address' not in user:
-                        self.warning('W008', version=v)
-                    elif not isinstance(user['address'], str):
-                        self.error('E054c', version=v)
-                    elif not re.match(r'''\w{3,6}:''', user['address']):
-                        self.warning('W009', version=v)
+                    if "name" not in user or not isinstance(user["name"], str):
+                        self.error("E054b", version=v)
+                    if "address" not in user:
+                        self.warning("W008", version=v)
+                    elif not isinstance(user["address"], str):
+                        self.error("E054c", version=v)
+                    elif not re.match(r"""\w{3,6}:""", user["address"]):
+                        self.warning("W009", version=v)
         return digests_used
 
     def validate_state_block(self, state, version, unnormalized_digests):
@@ -371,14 +385,14 @@ def validate_state_block(self, state, version, unnormalized_digests):
         logical_paths = set()
         logical_directories = set()
         if not isinstance(state, dict):
-            self.error('E050c', version=version)
+            self.error("E050c", version=version)
         else:
             digest_re = re.compile(self.digest_regex())
             for digest in state:
                 if not digest_re.match(digest):
-                    self.error('E050d', version=version, digest=digest)
+                    self.error("E050d", version=version, digest=digest)
                 elif not isinstance(state[digest], list):
-                    self.error('E050e', version=version, digest=digest)
+                    self.error("E050e", version=version, digest=digest)
                 else:
                     for path in state[digest]:
                         if path in logical_paths:
@@ -404,9 +418,9 @@ def check_content_paths_map_to_versions(self, manifest_files, all_versions):
         to know that they can be split into at least 2 components.
         """
         for path in manifest_files:
-            version_dir, dummy_rest = path.split('/', 1)
+            version_dir, dummy_rest = path.split("/", 1)
             if version_dir not in all_versions:
-                self.error('E042b', path=path)
+                self.error("E042b", path=path)
 
     def check_digests_present_and_used(self, manifest_files, digests_used):
         """Check all digests in manifest that are needed are present and used."""
@@ -425,9 +439,9 @@ def digest_regex(self):
             return digest_regex(self.digest_algorithm)
         except ValueError:
             if not self.lax_digests:
-                self.error('E026a', digest=self.digest_algorithm)
+                self.error("E026a", digest=self.digest_algorithm)
         # Match anything
-        return r'''^.*$'''
+        return r"""^.*$"""
 
     def check_logical_path(self, path, version, logical_paths, logical_directories):
         """Check logical path and accumulate paths/directories for E095b check.
@@ -436,38 +450,38 @@ def check_logical_path(self, path, version, logical_paths, logical_directories):
 
         Only adds good paths to the accumulated paths/directories.
         """
-        if path.startswith('/') or path.endswith('/'):
+        if path.startswith("/") or path.endswith("/"):
             self.error("E053", version=version, path=path)
         else:
-            elements = path.split('/')
+            elements = path.split("/")
             for element in elements:
-                if element in ['.', '..', '']:
+                if element in [".", "..", ""]:
                     self.error("E052", version=version, path=path)
                     return
             # Accumulate paths and directories
             logical_paths.add(path)
-            logical_directories.add('/'.join(elements[0:-1]))
+            logical_directories.add("/".join(elements[0:-1]))
 
     def check_content_path(self, path, content_paths, content_directories):
         """Check logical path and accumulate paths/directories for E101 check.
 
         Returns True if valid, else False. Only adds good paths to the
         accumulated paths/directories. We don't yet know the set of valid
-        version directories so the check here is just for 'v' + digits.
+        version directories so the check here is just for "v" + digits.
         """
-        if path.startswith('/') or path.endswith('/'):
+        if path.startswith("/") or path.endswith("/"):
             self.error("E100", path=path)
             return False
-        m = re.match(r'''^(v\d+)/([^/]+)/(.+)''', path)
+        m = re.match(r"""^(v\d+)/([^/]+)/(.+)""", path)
         if not m:
             self.error("E042a", path=path)
             return False
         if m.group(2) != self.content_directory:
             self.error("E042c", path=path, content_directory=self.content_directory)
             return False
-        elements = m.group(3).split('/')
+        elements = m.group(3).split("/")
         for element in elements:
-            if element in ('', '.', '..'):
+            if element in ("", ".", ".."):
                 self.error("E099", path=path)
                 return False
         # Accumulate paths and directories if not seen before
@@ -475,7 +489,7 @@ def check_content_path(self, path, content_paths, content_directories):
             self.error("E101a", path=path)
             return False
         content_paths.add(path)
-        content_directories.add('/'.join([m.group(1), m.group(2)] + elements[0:-1]))
+        content_directories.add("/".join([m.group(1), m.group(2)] + elements[0:-1]))
         return True
 
     def validate_as_prior_version(self, prior):
@@ -487,11 +501,11 @@ def validate_as_prior_version(self, prior):
         """
         # Must have a subset of versions which also checks zero padding format etc.
         if not set(prior.all_versions) < set(self.all_versions):
-            self.error('E066a', prior_head=prior.head)
+            self.error("E066a", prior_head=prior.head)
         else:
             # Check references to files but realize that there might be different
             # digest algorithms between versions
-            version = 'no-version'
+            version = "no-version"
             for version in prior.all_versions:
                 # If the digest algorithm is the same then we can make a
                 # direct check on whether the state blocks match
@@ -507,22 +521,22 @@ def validate_as_prior_version(self, prior):
                 only_in_self = self_map.keys() - prior_map.keys()
                 if only_in_prior or only_in_self:
                     if only_in_prior:
-                        self.error('E066b', version=version, prior_head=prior.head, only_in=prior.head, logical_paths=','.join(only_in_prior))
+                        self.error("E066b", version=version, prior_head=prior.head, only_in=prior.head, logical_paths=",".join(only_in_prior))
                     if only_in_self:
-                        self.error('E066b', version=version, prior_head=prior.head, only_in=self.where, logical_paths=','.join(only_in_self))
+                        self.error("E066b", version=version, prior_head=prior.head, only_in=self.where, logical_paths=",".join(only_in_self))
                 else:
                     # Check them all in details - digests must match
                     for logical_path, this_map in prior_map.items():
                         if not this_map.issubset(self_map[logical_path]):
-                            self.error('E066c', version=version, prior_head=prior.head,
-                                       logical_path=logical_path, prior_content=','.join(this_map),
-                                       current_content=','.join(self_map[logical_path]))
+                            self.error("E066c", version=version, prior_head=prior.head,
+                                       logical_path=logical_path, prior_content=",".join(this_map),
+                                       current_content=",".join(self_map[logical_path]))
                 # Check metadata
-                prior_version = prior.inventory['versions'][version]
-                self_version = self.inventory['versions'][version]
-                for key in ('created', 'message', 'user'):
+                prior_version = prior.inventory["versions"][version]
+                self_version = self.inventory["versions"][version]
+                for key in ("created", "message", "user"):
                     if prior_version.get(key) != self_version.get(key):
-                        self.warning('W011', version=version, prior_head=prior.head, key=key)
+                        self.warning("W011", version=version, prior_head=prior.head, key=key)
 
     def compare_states_for_version(self, prior, version):
         """Compare state blocks for version between self and prior.
@@ -539,12 +553,12 @@ def compare_states_for_version(self, prior, version):
         here provide more detailed diagnostics in the case that the digest
         algorithm is the same across versions being compared.
         """
-        self_state = self.inventory['versions'][version]['state']
-        prior_state = prior.inventory['versions'][version]['state']
+        self_state = self.inventory["versions"][version]["state"]
+        prior_state = prior.inventory["versions"][version]["state"]
         for digest in set(self_state.keys()).union(prior_state.keys()):
             if digest not in prior_state:
-                self.error('E066d', version=version, prior_head=prior.head,
-                           digest=digest, logical_files=', '.join(self_state[digest]))
+                self.error("E066d", version=version, prior_head=prior.head,
+                           digest=digest, logical_files=", ".join(self_state[digest]))
             elif digest not in self_state:
-                self.error('E066e', version=version, prior_head=prior.head,
-                           digest=digest, logical_files=', '.join(prior_state[digest]))
+                self.error("E066e", version=version, prior_head=prior.head,
+                           digest=digest, logical_files=", ".join(prior_state[digest]))
diff --git a/ocfl/layout.py b/ocfl/layout.py
index 379d9c0..500be49 100644
--- a/ocfl/layout.py
+++ b/ocfl/layout.py
@@ -52,14 +52,14 @@ def __init__(self):
         this __init__. Instead they will replace it and define PARAMS for the
         new class.
         """
-        self.NAME = 'BASE'  # Override this in sub-class with real layout name
-        self.DESCRIPTION = 'BASE LAYOUT CLASS'  # Override with layout description
+        self.NAME = "BASE"  # Override this in sub-class with real layout name
+        self.DESCRIPTION = "BASE LAYOUT CLASS"  # Override with layout description
         self.PARAMS = None  # Overrride this in sub-class with a dictionary
 
     @property
     def config_file(self):
         """Location of config.json configuration file for the layout extenstion."""
-        return os.path.join('extensions', self.NAME, 'config.json')
+        return os.path.join("extensions", self.NAME, "config.json")
 
     @property
     def config(self):
@@ -134,11 +134,11 @@ def check_and_set_layout_params(self, config, require_extension_name=True):
         method in self.PARAMS is called. The methods set instance attributes.
         """
         # Check the extensionName if required and/or specified
-        if 'extensionName' not in config:
+        if "extensionName" not in config:
             if require_extension_name:
                 raise LayoutException("Storage root extension config missing extensionName")
-        elif config.get('extensionName') != self.NAME:
-            raise LayoutException("Storage root extension config extensionName is %s, expected %s" % (config.get('extensionName'), self.NAME))
+        elif config.get("extensionName") != self.NAME:
+            raise LayoutException("Storage root extension config extensionName is %s, expected %s" % (config.get("extensionName"), self.NAME))
         # Read and check the parameters (ignore any extra params)
         for key, method in self.PARAMS.items():
             method(config.get(key))
@@ -160,7 +160,7 @@ def write_layout_params(self, root_fs=None):
             raise LayoutException("Storage root extension layout config %s already exists" % (self.config_file))
         try:
             root_fs.makedirs(os.path.dirname(self.config_file))
-            with root_fs.open(self.config_file, 'w') as fh:
+            with root_fs.open(self.config_file, "w") as fh:
                 json.dump(config, fh, indent=2)
         except Exception as e:
             raise LayoutException("Storage root extension config file %s couldn't be written (%s)" % (self.config_file, str(e)))
diff --git a/ocfl/layout_0002_flat_direct.py b/ocfl/layout_0002_flat_direct.py
index 489a7c0..646d97d 100644
--- a/ocfl/layout_0002_flat_direct.py
+++ b/ocfl/layout_0002_flat_direct.py
@@ -19,6 +19,6 @@ def __init__(self):
 
     def identifier_to_path(self, identifier):
         """Convert identifier to path relative to root."""
-        if identifier in ('', '.', '..') or os.sep in identifier:
+        if identifier in ("", ".", "..") or os.sep in identifier:
             raise LayoutException("Identifier '%s' unsafe for %s layout" % (identifier, self.NAME))
         return identifier
diff --git a/ocfl/layout_0003_hash_and_id_n_tuple.py b/ocfl/layout_0003_hash_and_id_n_tuple.py
index e166e61..7231f89 100644
--- a/ocfl/layout_0003_hash_and_id_n_tuple.py
+++ b/ocfl/layout_0003_hash_and_id_n_tuple.py
@@ -13,23 +13,23 @@
 
 def _percent_encode(c):
     """Return % encoded version of string c."""
-    c_bytes = c.encode('utf8')
-    s = ''
+    c_bytes = c.encode("utf8")
+    s = ""
     for b in c_bytes:
-        s += '%' + codecs.encode(bytes([b]), encoding='hex_codec').decode('utf8')
+        s += "%" + codecs.encode(bytes([b]), encoding="hex_codec").decode("utf8")
     return s
 
 
 def _get_encapsulation_directory(object_id, digest):
     """Return directory to encapsulate object."""
-    d = ''
+    d = ""
     for c in object_id:
-        if re.match(r'[A-Za-z0-9-_]{1}', c):
+        if re.match(r"[A-Za-z0-9-_]{1}", c):
             d += c
         else:
             d += _percent_encode(c)
     if len(d) > 100:
-        return f'{d[:100]}-{digest}'
+        return f"{d[:100]}-{digest}"
     return d
 
 
@@ -37,7 +37,7 @@ def _id_to_path(identifier, digest_algorithm, tuple_size, number_of_tuples):
     """Return storage path from identifier."""
     digest = string_digest(identifier, digest_algorithm)
     digest = digest.lower()  # Not necessary for current digests
-    path = ''
+    path = ""
     for i in range(number_of_tuples):
         tuple = digest[i * tuple_size:i * tuple_size + tuple_size]
         path = os.path.join(path, tuple)
@@ -52,15 +52,15 @@ class Layout_0003_Hash_And_Id_N_Tuple(Layout):
     def __init__(self):
         """Initialize."""
         super().__init__()
-        self.digest_algorithm = 'sha256'
+        self.digest_algorithm = "sha256"
         self.tuple_size = 3
         self.number_of_tuples = 3
         # Config
-        self.NAME = '0003-hash-and-id-n-tuple-storage-layout'
+        self.NAME = "0003-hash-and-id-n-tuple-storage-layout"
         self.DESCRIPTION = "Extension 0003: Hashed Truncated N-tuple Trees with Object ID Encapsulating Directory for OCFL Storage Hierarchies"
-        self.PARAMS = {'digestAlgorithm': self.check_digest_algorithm,
-                       'tupleSize': self.check_tuple_size,
-                       'numberOfTuples': self.check_number_of_tuples}
+        self.PARAMS = {"digestAlgorithm": self.check_digest_algorithm,
+                       "tupleSize": self.check_tuple_size,
+                       "numberOfTuples": self.check_number_of_tuples}
 
     def check_digest_algorithm(self, value):
         """Check digestAlgorithm parameter.
@@ -74,11 +74,11 @@ def check_digest_algorithm(self, value):
             Default: sha256
         """
         if value is None:
-            raise LayoutException('digestAlgorithm parameter must be specified')
+            raise LayoutException("digestAlgorithm parameter must be specified")
         try:
-            string_digest('aa', digest_type=value)
+            string_digest("aa", digest_type=value)
         except ValueError as e:
-            raise LayoutException('digestAlgorithm parameter specifies unknown or unsupported digests %s (%s)' % (value, str(e)))
+            raise LayoutException("digestAlgorithm parameter specifies unknown or unsupported digests %s (%s)" % (value, str(e)))
         self.digest_algorithm = value
 
     def check_tuple_size(self, value):
@@ -93,9 +93,9 @@ def check_tuple_size(self, value):
             Default: 3
         """
         if value is None:
-            raise LayoutException('tupleSize parameter must be specified')
+            raise LayoutException("tupleSize parameter must be specified")
         if not isinstance(value, int) or value < 0 or value > 32:
-            raise LayoutException('tupleSize parameter must be aninteger between 0 and 32 inclusive')
+            raise LayoutException("tupleSize parameter must be aninteger between 0 and 32 inclusive")
         self.tuple_size = value
 
     def check_number_of_tuples(self, value):
@@ -109,18 +109,18 @@ def check_number_of_tuples(self, value):
             Default: 3
         """
         if value is None:
-            raise LayoutException('numberOfTuples parameter must be specified')
+            raise LayoutException("numberOfTuples parameter must be specified")
         if not isinstance(value, int) or value < 0 or value > 32:
-            raise LayoutException('numberOfTuples parameter must be aninteger between 0 and 32 inclusive')
+            raise LayoutException("numberOfTuples parameter must be aninteger between 0 and 32 inclusive")
         self.number_of_tuples = value
 
     @property
     def config(self):
         """Dictionary with config.json configuration for the layout extenstion."""
-        return {'extensionName': self.NAME,
-                'digestAlgorithm': self.digest_algorithm,
-                'tupleSize': self.tuple_size,
-                'numberOfTuples': self.number_of_tuples}
+        return {"extensionName": self.NAME,
+                "digestAlgorithm": self.digest_algorithm,
+                "tupleSize": self.tuple_size,
+                "numberOfTuples": self.number_of_tuples}
 
     def identifier_to_path(self, identifier):
         """Convert identifier to path relative to root.
@@ -135,7 +135,7 @@ def identifier_to_path(self, identifier):
         4. The tuples are joined, in order, using the filesystem path separator.
         5. The OCFL object identifier is percent-encoded to create the
             encapsulation directory name. However, if this is > 100 characters
-            then it is tuncated at 100 characters and then '-' and the digest
+            then it is tuncated at 100 characters and then "-" and the digest
             appended (so it would be 165 chars with sha256).
         6. The encapsulation directory name is joined to the end of the path.
         """
diff --git a/ocfl/layout_nnnn_tuple_tree.py b/ocfl/layout_nnnn_tuple_tree.py
index 722dc6b..cfd6e06 100644
--- a/ocfl/layout_nnnn_tuple_tree.py
+++ b/ocfl/layout_nnnn_tuple_tree.py
@@ -28,13 +28,13 @@ def __init__(self, tuple_size=2):
         self.tuple_size = tuple_size
         self.NAME = "nnnn-tuple-tree-layout"
         self.DESCRIPTION = "Local extension, unhashed tuple-tree with configurable tuple size"
-        self.PARAMS = {'tupleSize': self.check_tuple_size}
+        self.PARAMS = {"tupleSize": self.check_tuple_size}
 
     @property
     def config(self):
         """Dictionary with config.json configuration for the layout extenstion."""
-        return {'extensionName': self.NAME,
-                'tupleSize': self.tuple_size}
+        return {"extensionName": self.NAME,
+                "tupleSize": self.tuple_size}
 
     def check_tuple_size(self, value):
         """Check tuple size paremeter.
@@ -48,9 +48,9 @@ def check_tuple_size(self, value):
             Default: 3
         """
         if value is None:
-            raise LayoutException('tupleSize parameter must be specified')
+            raise LayoutException("tupleSize parameter must be specified")
         if not isinstance(value, int) or value < 2 or value > 6:
-            raise LayoutException('tupleSize parameter must be an integer between 2 and 6 inclusive')
+            raise LayoutException("tupleSize parameter must be an integer between 2 and 6 inclusive")
         self.tuple_size = value
 
     def encode(self, identifier):
diff --git a/ocfl/layout_nnnn_uuid_quadtree.py b/ocfl/layout_nnnn_uuid_quadtree.py
index 93c2a55..ba1414d 100644
--- a/ocfl/layout_nnnn_uuid_quadtree.py
+++ b/ocfl/layout_nnnn_uuid_quadtree.py
@@ -12,19 +12,19 @@
 class Layout_NNNN_UUID_Quadtree(Layout):
     """Class to support UUID URIs with quadtree directory names."""
 
-    def __init__(self, prefix='urn:uuid:'):
+    def __init__(self, prefix="urn:uuid:"):
         """Initialize Layout."""
         super().__init__()
         self.prefix = prefix
-        self.NAME = 'nnnn-uuid-quadtree'
+        self.NAME = "nnnn-uuid-quadtree"
         self.DESCRIPTION = "Local extension to support UUID URIs with quadtree directory names"
-        self.PARAMS = {'prefix': self.check_prefix}
+        self.PARAMS = {"prefix": self.check_prefix}
 
     @property
     def config(self):
         """Dictionary with config.json configuration for the layout extenstion."""
-        return {'extensionName': self.NAME,
-                'prefix': self.prefix}
+        return {"extensionName": self.NAME,
+                "prefix": self.prefix}
 
     def check_prefix(self, value):
         """Check prefix paremeter.
@@ -32,9 +32,9 @@ def check_prefix(self, value):
         Any string is allowed.
         """
         if value is None:
-            raise LayoutException('prefix parameter must be specified')
+            raise LayoutException("prefix parameter must be specified")
         if not isinstance(value, str):
-            raise LayoutException('prefix parameter must be a string')
+            raise LayoutException("prefix parameter must be a string")
         self.prefix = value
 
     def encode(self, identifier):
@@ -54,7 +54,7 @@ def identifier_to_path(self, identifier):
             identifier = identifier[len(self.prefix):]
         else:
             raise Exception("UUIDQuadtree identifier %s does not start with prefix %s" % (identifier, self.prefix))
-        match = re.match(r'''([\da-f]{4})([\da-f]{4})\-([\da-f]{4})\-([\da-f]{4})\-([\da-f]{4})\-([\da-f]{4})([\da-f]{4})([\da-f]{4})$''', identifier)
+        match = re.match(r"""([\da-f]{4})([\da-f]{4})\-([\da-f]{4})\-([\da-f]{4})\-([\da-f]{4})\-([\da-f]{4})([\da-f]{4})([\da-f]{4})$""", identifier)
         if not match:
             raise Exception("UUIDQuadtree identifier %s not valid" % (identifier))
         return os.path.join(match.group(1), match.group(2), match.group(3), match.group(4),
diff --git a/ocfl/namaste.py b/ocfl/namaste.py
index 08149d0..4f95ae0 100644
--- a/ocfl/namaste.py
+++ b/ocfl/namaste.py
@@ -18,10 +18,10 @@ def content_to_tvalue(content):
     will be converted to underscore.
     """
     content = content.strip()
-    return re.sub(r'''[^\w\.\-:]''', '_', content[:40])
+    return re.sub(r"""[^\w\.\-:]""", "_", content[:40])
 
 
-def find_namastes(d, dir='', pyfs=None, limit=10):
+def find_namastes(d, dir="", pyfs=None, limit=10):
     """Find NAMASTE files with tag d in dir, return list of Namaste objects.
 
     limit sets a limit on the number of Namaste objects returned, a NamasteException
@@ -29,7 +29,7 @@ def find_namastes(d, dir='', pyfs=None, limit=10):
     sorter by filename to ensure consistent behaviour when multiple match files are
     present.
     """
-    prefix = str(d) + '='
+    prefix = str(d) + "="
     if pyfs is not None:
         filenames = [f for f in pyfs.listdir(dir) if f.startswith(prefix)]
     else:
@@ -57,7 +57,7 @@ class NamasteException(Exception):
 class Namaste():
     """Class implementing NAMASTE specification."""
 
-    def __init__(self, d=0, content='', tvalue=None, tr_func=content_to_tvalue):
+    def __init__(self, d=0, content="", tvalue=None, tr_func=content_to_tvalue):
         """Initialize Namaste object.
 
         Arguments:
@@ -75,7 +75,7 @@ def __init__(self, d=0, content='', tvalue=None, tr_func=content_to_tvalue):
     @property
     def filename(self):
         """Filename of Namaste file."""
-        return str(self.d) + '=' + self.tvalue
+        return str(self.d) + "=" + self.tvalue
 
     @property
     def tvalue(self):
@@ -84,27 +84,27 @@ def tvalue(self):
             return self._tvalue
         return self._tr_func(self.content)
 
-    def write(self, dir='', pyfs=None):
+    def write(self, dir="", pyfs=None):
         """Write NAMASTE file to dir, optionally in fs.
 
         Handle both a dirctory with in a pyfs filesystem (if pyfs is set) or
         else just a directory with plain os file support.
 
         e.g.
-            Namaste(0, 'ocfl_1.0').write(pyfs=obj_fs)
+            Namaste(0, "ocfl_1.0").write(pyfs=obj_fs)
         or
-            Namaste(0, 'ocfl_1.0').write(dir)
+            Namaste(0, "ocfl_1.0").write(dir)
         """
         if pyfs is not None:
             pyfs.writetext(fs.path.join(dir, self.filename), self.content + "\n")
         else:
-            with open(os.path.join(dir, self.filename), 'w', encoding="utf-8") as fh:
+            with open(os.path.join(dir, self.filename), "w", encoding="utf-8") as fh:
                 fh.write(self.content + "\n")
 
-    def check_content(self, dir='', pyfs=None):
+    def check_content(self, dir="", pyfs=None):
         """Check that the file content is compatible with the tvalue based on tr_func, else raise NamasteException."""
         filepath = fs.path.join(dir, self.filename)
-        if self.tvalue == '':
+        if self.tvalue == "":
             raise NamasteException("Cannot check Namaste file %s without tvalue being set!" % (filepath))
         if pyfs is not None:
             try:
@@ -114,12 +114,12 @@ def check_content(self, dir='', pyfs=None):
         else:
             if not os.path.isfile(filepath):
                 raise NamasteException("Namaste file %s does not exist!" % (filepath))
-            with open(filepath, 'r', encoding="utf-8") as fh:
+            with open(filepath, "r", encoding="utf-8") as fh:
                 content = fh.read()
         if self.tvalue != self._tr_func(content):
             raise NamasteException("Content of Namaste file %s doesn't match tvalue %s" % (filepath, self.tvalue))
 
-    def content_ok(self, dir='', pyfs=None):
+    def content_ok(self, dir="", pyfs=None):
         """Return True if check_content() does not raise a NamasteException exception."""
         try:
             self.check_content(dir, pyfs)
diff --git a/ocfl/object.py b/ocfl/object.py
index 8692dd1..af7799f 100755
--- a/ocfl/object.py
+++ b/ocfl/object.py
@@ -21,26 +21,17 @@
 import fs.path
 import fs.copy
 
-from .digest import file_digest, normalized_digest
+from .digest import file_digest
+from .inventory import Inventory
 from .inventory_validator import InventoryValidator
-from .object_utils import make_unused_filepath, next_version, ObjectException
-from .pyfs import open_fs
+from .object_utils import make_unused_filepath, next_version_directory, \
+    parse_version_directory, ObjectException
+from .pyfs import pyfs_openfs
 from .namaste import Namaste
 from .validator import Validator, ValidatorAbortException
 from .version_metadata import VersionMetadata
 
-INVENTORY_FILENAME = 'inventory.json'
-
-
-def parse_version_directory(dirname):
-    """Get version number from version directory name."""
-    m = re.match(r'''v(\d{1,5})$''', dirname)
-    if not m:
-        raise ObjectException("Bad version directory name: %s" % (dirname))
-    v = int(m.group(1))
-    if v == 0:
-        raise ObjectException("Bad version directory name: %s, v0 no allowed" % (dirname))
-    return v
+INVENTORY_FILENAME = "inventory.json"
 
 
 class Object():  # pylint: disable=too-many-public-methods
@@ -53,32 +44,32 @@ class Object():  # pylint: disable=too-many-public-methods
     >>> passed
     True
     >>> validator.spec_version
-    '1.1'
+    "1.1"
 
     >>> inv = object.parse_inventory()  # parsed JSON as dict
-    >>> inv['digestAlgorithm']
-    'sha512'
-    >>> inv['versions']['v1']
-    {'created': '2018-01-01T01:01:01Z',
-    'message': 'Initial import', 'state':
-    {'7dcc352...7785947ac31': ['foo/bar.xml'],
-    'cf83e135...27af927da3e': ['empty.txt'],
-    'ffccf6ba...336cbfb862e': ['image.tiff']},
-    'user': {'address': 'mailto:alice@example.com', 'name': 'Alice'}}
+    >>> inv["digestAlgorithm"]
+    "sha512"
+    >>> inv["versions"]["v1"]
+    {"created": "2018-01-01T01:01:01Z",
+    "message": "Initial import", "state":
+    {"7dcc352...7785947ac31": ["foo/bar.xml"],
+    "cf83e135...27af927da3e": ["empty.txt"],
+    "ffccf6ba...336cbfb862e": ["image.tiff"]},
+    "user": {"address": "mailto:alice@example.com", "name": "Alice"}}
     """
 
-    def __init__(self, *, identifier=None, content_directory='content',
-                 digest_algorithm='sha512', filepath_normalization='uri',
-                 spec_version='1.1', forward_delta=True, dedupe=True,
+    def __init__(self, *, identifier=None, content_directory="content",
+                 digest_algorithm="sha512", filepath_normalization="uri",
+                 spec_version="1.1", forward_delta=True, dedupe=True,
                  lax_digests=False, fixity=None,
                  obj_fs=None, path=None, create=False):
         """Initialize OCFL object.
 
         Arguments relevant to building an object:
             identifier: id for this object
-            content_directory: allow override of the default 'content'
-            digest_algorithm: allow override of the default 'sha512'
-            filepath_normalization: allow override of default 'uri'
+            content_directory: allow override of the default "content"
+            digest_algorithm: allow override of the default "sha512"
+            filepath_normalization: allow override of default "uri"
             spec_version: OCFL specification version
             forward_delta: set False to turn off foward delta. With forward delta
                 turned off, the same content will be repeated in a new version
@@ -109,9 +100,9 @@ def __init__(self, *, identifier=None, content_directory='content',
         self.src_files = {}
         self.obj_fs = obj_fs  # fs filesystem (or sub-filesystem) for object
         if path is not None:
-            self.open_fs(path, create=create)
+            self.open_obj_fs(path, create=create)
 
-    def open_fs(self, objdir, create=False):
+    def open_obj_fs(self, objdir, create=False):
         """Open an fs filesystem for this object.
 
         Arguments:
@@ -120,12 +111,12 @@ def open_fs(self, objdir, create=False):
                 or "mem://")
             create: True to create path/filesystem as needed, defaults to False.
 
-        Sets obj_fs attribute.
+        Sets obj_fs attribute with the filesystem instance.
 
         Raises ObjectException on failure to open filesystem.
         """
         try:
-            self.obj_fs = open_fs(fs_url=objdir, create=create)
+            self.obj_fs = pyfs_openfs(fs_url=objdir, create=create)
         except (fs.opener.errors.OpenerError, fs.errors.CreateFailed) as e:
             raise ObjectException("Failed to open object filesystem '%s' (%s)" % (objdir, e))
 
@@ -144,31 +135,35 @@ def map_filepath(self, filepath, vdir, used):
         """Map source filepath to a content path within the object.
 
         The purpose of the mapping might be normalization, sanitization,
-        content distribution, or something else.
+        content distribution, or something else. The mapping is set by the
+        filepath_normalization attribute where None indicates no mapping, the
+        source file name and path are preserved.
 
         Arguments:
-            filepath: the source filepath
-            vdir: the current version directory
-            used: disctionary used to check whether a given vfilepath has
-                been used already
+            filepath: the source filepath (possibly including directories) that
+                will be mapped into the object content path.
+            vdir: the current version directory name.
+            used: discionary used to check whether a given vfilepath has
+                been used already.
 
         Returns vfilepath, the version filepath for this content that starts
-            with "vdir/content_directory/"."
+        with `vdir/content_directory/`.
         """
-        if self.filepath_normalization == 'uri':
+        if self.filepath_normalization == "uri":
             filepath = urlquote(filepath)
             # also encode any leading period to unhide files
-            if filepath[0] == '.':
-                filepath = '%2E' + filepath[1:]
-        elif self.filepath_normalization == 'md5':
-            # Truncated MD5 hash of the _filepath_ as an illustration of diff paths for spec,
-            # not sure there could be any real application of this
-            filepath = hashlib.md5(filepath.encode('utf-8')).hexdigest()[0:16]
+            if filepath[0] == ".":
+                filepath = "%2E" + filepath[1:]
+        elif self.filepath_normalization == "md5":
+            # Truncated MD5 hash of the _filepath_ as an illustration of diff
+            # paths for the specification. Not sure whether there should be any
+            # real application of this
+            filepath = hashlib.md5(filepath.encode("utf-8")).hexdigest()[0:16]
         elif self.filepath_normalization is not None:
             raise Exception("Unknown filepath normalization '%s' requested" % (self.filepath_normalization))
         vfilepath = fs.path.join(vdir, self.content_directory, filepath)  # path relative to root, inc v#/content
-        # Check we don't already have this vfilepath from many to one normalization,
-        # add suffix to distinguish if necessary
+        # Check we don"t already have this vfilepath from many to one
+        # normalization, add suffix to distinguish if necessary
         if vfilepath in used:
             vfilepath = make_unused_filepath(vfilepath, used)
         return vfilepath
@@ -176,25 +171,23 @@ def map_filepath(self, filepath, vdir, used):
     def start_inventory(self):
         """Create inventory start with metadata from self.
 
-        Returns the start of an inventory dict based on the instance data
+        Returns the start of an Inventory object based on the instance data
         in this object.
         """
-        inventory = dict({
-            'id': self.id,
-            'type': 'https://ocfl.io/' + self.spec_version + '/spec/#inventory',
-            'digestAlgorithm': self.digest_algorithm,
-            'versions': {},
-            'manifest': {}
-        })
-        # Add contentDirectory if not 'content'
-        if self.content_directory != 'content':
-            inventory['contentDirectory'] = self.content_directory
+        inventory = Inventory()
+        inventory.id = self.id
+        inventory.spec_version = self.spec_version
+        inventory.digest_algorithm = self.digest_algorithm
+        inventory.init_manifest_and_versions()
+        # Add contentDirectory if not "content"
+        if self.content_directory != "content":
+            inventory.content_directory = self.content_directory
         # Add fixity section if requested
         if self.fixity is not None and len(self.fixity) > 0:
-            inventory['fixity'] = {}
             for fixity_type in self.fixity:
-                inventory['fixity'][fixity_type] = {}
+                inventory.add_fixity_type(fixity_type)
         else:
+            # Make sure None rather than just zero length
             self.fixity = None
         return inventory
 
@@ -202,23 +195,22 @@ def add_version(self, *, inventory, src_fs, src_dir, vdir, metadata=None):
         """Add to inventory data for new version based on files in srcdir.
 
         Arguments:
-          inventory - the inventory up to (vdir-1) which must include blocks
-            for ['manifest'] and ['versions']. It must also include
-            a ['fixity'][algorithm] block for every algorithm in self.fixity
-          src_fs - pyfs filesystem where this new version exist
-          src_dir - the version directory in src_fs that files are being added
-            from
-          vdir - the version name of the version being created
-          metadata - a VersionMetadata object with any metadata for this
-            version
-
-        Returns:
-          manifest_to_srcfile - dict mapping from paths in manifest to the path
-            of the source file in src_fs that should be include in the content
-            for this new version
+            inventory: an Invenory object with data up to versio (vdir-1) which
+                must include blocks for the manifest and versions. It must also
+                include a fixity block for every algorithm in self.fixity
+            src_fs: pyfs filesystem where this new version exist
+            src_dir: the version directory in src_fs that files are being added
+               from
+            vdir: the version name of the version being created
+            metadata: a VersionMetadata object with any metadata for this
+              version
+
+        Returns manifest_to_srcfile, a dict mapping from paths in manifest to
+        the path of the source file in src_fs that should be include in the
+        content for this new version.
         """
         state = {}  # state for this new version
-        manifest = inventory['manifest']
+        manifest = inventory.manifest
         digests_in_version = {}
         manifest_to_srcfile = {}
         # Go through all files to find new files in manifest and state for this version
@@ -253,118 +245,144 @@ def add_version(self, *, inventory, src_fs, src_dir, vdir, metadata=None):
         # Add extra fixity entries if required
         if self.fixity is not None:
             for fixity_type in self.fixity:
-                fixities = inventory['fixity'][fixity_type]
                 for digest, vfilepaths in digests_in_version.items():
                     for vfilepath in vfilepaths:
                         fixity_digest = file_digest(manifest_to_srcfile[vfilepath], fixity_type, pyfs=src_fs)
-                        if fixity_digest not in fixities:
-                            fixities[fixity_digest] = [vfilepath]
-                        else:
-                            fixities[fixity_digest].append(vfilepath)
-        # Set head to this latest version, and add this version to inventory
-        inventory['head'] = vdir
-        inventory['versions'][vdir] = metadata.as_dict(state=state)
+                        inventory.add_fixity_data(digest_algorithm=fixity_type,
+                                                  digest=fixity_digest,
+                                                  filepath=vfilepath)
+        # Add this new version to inventory (also updates head)
+        inventory.add_version(vdir=vdir, metadata=metadata, state=state)
         return manifest_to_srcfile
 
-    def build_inventory(self, src_fs, metadata=None):
+    def build_inventory(self, src_fs, versions_metadata=None):
         """Generate an OCFL inventory from a set of source files.
 
         Arguments:
-            src_fc - pyfs filesystem of source files
-            metadata - metadata to apply to each version
-
-        Yields (vdir, inventory, manifest_to_srcfile) for each version in sequence,
-        where vdir is the version directory name, inventory is the inventory for that
-        version, and manifest_to_srcfile is a dictionary that maps filepaths in the
-        manifest to actual source filepaths.
+            src_fc - pyfs filesystem of source files.
+            versions_metadata - dict of VersionMetadata objects for each
+                version, key is the integer version number. Default is None
+                in which case no metadata is added.
+
+        Yields (vdir, inventory, manifest_to_srcfile) for each version in
+        sequence, where vdir is the version directory name, inventory is the
+        Inventory object for that version, and manifest_to_srcfile is a
+        dictionary that maps filepaths in the manifest to actual source
+        filepaths.
         """
+        if versions_metadata is None:
+            versions_metadata = {}
         inventory = self.start_inventory()
         # Find the versions
         versions = {}
-        for vdir in src_fs.listdir('/'):
+        for vdir in src_fs.listdir("/"):
             if not src_fs.isdir(vdir):
                 continue
-            vn = parse_version_directory(vdir)
-            versions[vn] = vdir
-        # Go through versions in order building versions array, deduping if selected
+            try:
+                vn = parse_version_directory(vdir)
+                versions[vn] = vdir
+            except ObjectException:
+                # Ignore directories that are not valid version dirs
+                pass
+        # Go through versions in order building versions array, deduping
+        # files to include if selected
         for vn in sorted(versions.keys()):
             vdir = versions[vn]
-            manifest_to_srcfile = self.add_version(inventory=inventory, src_fs=src_fs,
-                                                   src_dir=vdir, vdir=vdir,
+            # Do we have metadata for this version? Else empty.
+            metadata = versions_metadata.get(vn, VersionMetadata())
+            manifest_to_srcfile = self.add_version(inventory=inventory,
+                                                   src_fs=src_fs,
+                                                   src_dir=vdir,
+                                                   vdir=vdir,
                                                    metadata=metadata)
             yield (vdir, inventory, manifest_to_srcfile)
 
     def object_declaration_object(self):
         """NAMASTE object declaration Namaste object."""
-        return Namaste(0, 'ocfl_object_' + self.spec_version)
+        return Namaste(0, "ocfl_object_" + self.spec_version)
 
     def write_object_declaration(self):
         """Write NAMASTE object declaration.
 
-        Assumes self.obj_fs is open for this object.
+        Assumes self.obj_fs is open for this object and writes into the
+        root directory of that filesystem.
         """
         self.object_declaration_object().write(pyfs=self.obj_fs)
 
-    def write_inventory_and_sidecar(self, inventory, vdir='', write_inventory=True):
+    def write_inventory_and_sidecar(self, inventory=None, vdir=""):
         """Write inventory and sidecar to vdir in the current object.
 
+        Arguments:
+            inventory: an Inventory object to write the inventory, else None
+                if only the sidecar should be written (default)
+            vdir: string of the directory name within self.obj_fs that the
+                inventory and sidecar should be written to. Default is ""
+
         Assumes self.obj_fs is open for this object. Will create vdir if that
         does not exist. If vdir is not specified then will write to root of
-        the object.
+        the object filesystem.
 
         Returns the inventory sidecar filename.
         """
         if not self.obj_fs.exists(vdir):
             self.obj_fs.makedir(vdir)
         invfile = fs.path.join(vdir, INVENTORY_FILENAME)
-        if write_inventory:
-            with self.obj_fs.open(invfile, 'w') as fh:
-                json.dump(inventory, fh, sort_keys=True, indent=2)
+        if inventory is not None:
+            with self.obj_fs.open(invfile, "w") as fh:
+                inventory.write_json(fh)
         digest = file_digest(invfile, self.digest_algorithm, pyfs=self.obj_fs)
-        sidecar = fs.path.join(vdir, INVENTORY_FILENAME + '.' + self.digest_algorithm)
-        with self.obj_fs.open(sidecar, 'w') as fh:
-            fh.write(digest + ' ' + INVENTORY_FILENAME + '\n')
+        sidecar = fs.path.join(vdir, INVENTORY_FILENAME + "." + self.digest_algorithm)
+        with self.obj_fs.open(sidecar, "w") as fh:
+            fh.write(digest + " " + INVENTORY_FILENAME + "\n")
         return sidecar
 
     def write_inventory_sidecar(self):
-        """Write just sidecare for this object's already existing root inventory file.
+        """Write just sidecare for this object"s already existing root inventory file.
 
         Returns the inventory sidecar filename.
         """
-        return self.write_inventory_and_sidecar(None, write_inventory=False)
+        return self.write_inventory_and_sidecar(inventory=None)
 
-    def build(self, srcdir, metadata=None, objdir=None):
-        """Build an OCFL object and write to objdir if set, else just build inventory.
+    def build(self, srcdir, versions_metadata=None, objdir=None):
+        """Build an OCFL object with multiple versions.
+
+        Will write the object to objdir if set, else just build inventory.
 
         Arguments:
           srcdir: source directory with version sub-directories.
-          metadata: VersionMetadata object applied to all versions.
+          versions_metadata: dict of VersionMetadata objects for each
+              version, key is the integer version number. Default is None
+              in which case no metadata is added.
           objdir: output directory for object (must not already exist), if not
               set then will just return head inventory that would have been
-              created.
+              created as a dry-run.
+
+        Returns the Inventory object for the last version.
 
-        Returns the last version inventory.
+        See also create(...) for creating a new object with one version.
         """
         if self.id is None:
             raise ObjectException("Can't build object, identifier is not set!")
         if objdir is not None:
-            self.open_fs(objdir, create=True)
+            self.open_obj_fs(objdir, create=True)
         num_versions = 0
-        src_fs = open_fs(srcdir)
+        src_fs = pyfs_openfs(srcdir)
         inventory = None
-        for (vdir, inventory, manifest_to_srcfile) in self.build_inventory(src_fs, metadata):
+        # Create each version of the object
+        for (vdir, inventory, manifest_to_srcfile) in self.build_inventory(src_fs, versions_metadata):
             num_versions += 1
             if objdir is not None:
                 self.write_inventory_and_sidecar(inventory, vdir)
                 # Copy files into this version
                 for (path, srcfile) in manifest_to_srcfile.items():
                     self.copy_into_object(src_fs, srcfile, path, create_dirs=True)
+        # Finally populate the object root
         if objdir is not None:
             # Write object declaration, inventory and sidecar
             self.write_object_declaration()
             self.write_inventory_and_sidecar(inventory)
             logging.info("Built object %s at %s with %s versions", self.id, objdir, num_versions)
-        # Whether object written or not, return the set of inventories
+        # Whether object written or not, return the last inventory
         return inventory
 
     def create(self, srcdir, metadata=None, objdir=None):
@@ -376,29 +394,32 @@ def create(self, srcdir, metadata=None, objdir=None):
             objdir - output directory for object (must not already exist), if not
                 set then will just return inventory for object that would have been
                 created.
+
+        Returns the Inventory object for the last version.
+
+        See also build(...) for building a new object with multiple versions.
         """
         if self.id is None:
             raise ObjectException("Identifier is not set!")
-        src_fs = open_fs(srcdir)
+        src_fs = pyfs_openfs(srcdir)
         if objdir is not None:
-            self.open_fs(objdir, create=True)
+            self.open_obj_fs(objdir, create=True)
         inventory = self.start_inventory()
-        vdir = 'v1'
+        vdir = "v1"
         manifest_to_srcfile = self.add_version(inventory=inventory, src_fs=src_fs,
-                                               src_dir='', vdir=vdir,
+                                               src_dir="", vdir=vdir,
                                                metadata=metadata)
         if objdir is None:
             return inventory
-        # Else write out object
+        # Write out v1 object
         self.write_inventory_and_sidecar(inventory, vdir)
-        # Write object declaration, inventory and sidecar
+        # Write object root with object declaration, inventory and sidecar
         self.write_object_declaration()
         self.write_inventory_and_sidecar(inventory)
         # Write version files
-        for paths in inventory['manifest'].values():
-            for path in paths:
-                srcfile = manifest_to_srcfile[path]
-                self.copy_into_object(src_fs, srcfile, path, create_dirs=True)
+        for path in inventory.content_paths:
+            srcfile = manifest_to_srcfile[path]
+            self.copy_into_object(src_fs, srcfile, path, create_dirs=True)
         logging.info("Created OCFL object %s in %s", self.id, objdir)
         return inventory
 
@@ -414,51 +435,50 @@ def update(self, objdir, srcdir=None, metadata=None):
         (such as using a new digest). There will be no content change between
         versions.
         """
-        self.open_fs(objdir)
+        self.open_obj_fs(objdir)
         validator = Validator(check_digests=False, lax_digests=self.lax_digests)
         if not validator.validate_object(objdir):
             raise ObjectException("Object at '%s' is not valid, aborting" % objdir)
         inventory = self.parse_inventory()
-        self.id = inventory['id']
-        old_head = inventory['head']
-        head = next_version(old_head)
+        self.id = inventory.id
+        old_head = inventory.head
+        head = next_version_directory(old_head)
         logging.info("Will update %s %s -> %s", self.id, old_head, head)
         self.obj_fs.makedir(head)
         # Is this a request to change the digest algorithm?
-        old_digest_algorithm = inventory['digestAlgorithm']
+        old_digest_algorithm = inventory.digest_algorithm
         digest_algorithm = self.digest_algorithm
         if digest_algorithm is None:
             digest_algorithm = old_digest_algorithm
         elif digest_algorithm != old_digest_algorithm:
             logging.info("New version with use %s instead of %s digestAlgorithm",
                          digest_algorithm, old_digest_algorithm)
-            inventory['digestAlgorithm'] = digest_algorithm
+            inventory.digest_algorithm = digest_algorithm
         # Is this a request to change the set of fixity information?
         fixity = self.fixity
-        old_fixity = set(inventory['fixity'].keys()) if 'fixity' in inventory else set()
+        old_fixity = set(inventory.fixity.keys())
         if fixity is None:
             # Not explicit, carry forward from previous version. Only change will
             # be adding old digest information if we are changing digestAlgorithm
             fixity = old_fixity.copy()
             if digest_algorithm != old_digest_algorithm and old_digest_algorithm not in old_fixity:
                 # Add old manifest digests to fixity block
-                if 'fixity' not in inventory:
-                    inventory['fixity'] = {}
-                inventory['fixity'][old_digest_algorithm] = inventory['manifest'].copy()
+                inventory.add_fixity_type(digest_algorithm=old_digest_algorithm,
+                                          map=inventory.manifest.copy())
                 fixity.add(old_digest_algorithm)
         else:
             # Fixity to be stored is explicit, may be a change
             fixity = set(fixity)
             if fixity != old_fixity:
                 for digest in old_fixity.difference(fixity):
-                    inventory['fixity'].pop(digest)
+                    inventory.fixity.pop(digest)
                 for digest in fixity.difference(old_fixity):
                     logging.info("FIXME - need to add fixity with digest %s", digest)
         if fixity != old_fixity:
             logging.info("New version will have %s instead of %s fixity",
-                         ','.join(sorted(fixity)), ','.join(sorted(old_fixity)))
+                         ",".join(sorted(fixity)), ",".join(sorted(old_fixity)))
         # Now look at contents, manifest and state
-        manifest = copy.deepcopy(inventory['manifest'])
+        manifest = copy.deepcopy(inventory.manifest)
         if digest_algorithm != old_digest_algorithm:
             old_to_new_digest = {}
             new_manifest = {}
@@ -474,23 +494,26 @@ def update(self, objdir, srcdir=None, metadata=None):
                 new_manifest[digest] = manifest[old_digest]
             manifest = new_manifest
             # Now update all state blocks
-            for vdir in inventory['versions']:
-                old_state = inventory['versions'][vdir]['state']
+            for vdir in inventory.version_directories:
+                old_state = inventory.version(vdir).state
                 state = {}
                 for old_digest, files in old_state.items():
                     state[old_to_new_digest[old_digest]] = old_state[old_digest]
-                inventory['versions'][vdir]['state'] = state
-        inventory['manifest'] = manifest
+                inventory.version(vdir).state = state
+        inventory.manifest = manifest
         # Add and remove any contents by comparing srcdir with existing state and manifest
         if srcdir is None:
             # No content Update
-            inventory['head'] = head
-            state = copy.deepcopy(inventory['versions'][old_head]['state'])
-            inventory['versions'][head] = metadata.as_dict(state=state)
+            inventory.head = head
+            state = copy.deepcopy(inventory.version(old_head).state)
+            inventory.versions_block[head] = metadata.as_dict()
+            inventory.versions_block[head]["state"] = state
         else:
-            src_fs = open_fs(srcdir)
-            manifest_to_srcfile = self.add_version(inventory=inventory, src_fs=src_fs,
-                                                   src_dir='', vdir=head,
+            src_fs = pyfs_openfs(srcdir)
+            manifest_to_srcfile = self.add_version(inventory=inventory,
+                                                   src_fs=src_fs,
+                                                   src_dir="",
+                                                   vdir=head,
                                                    metadata=metadata)
             # Copy files into this version
             for (path, srcfile) in manifest_to_srcfile.items():
@@ -500,7 +523,7 @@ def update(self, objdir, srcdir=None, metadata=None):
         self.write_inventory_and_sidecar(inventory)
         # Delete old root inventory sidecar if we changed digest algorithm
         if digest_algorithm != old_digest_algorithm:
-            self.obj_fs.remove(INVENTORY_FILENAME + '.' + old_digest_algorithm)
+            self.obj_fs.remove(INVENTORY_FILENAME + "." + old_digest_algorithm)
         logging.info("Updated OCFL object %s in %s by adding %s", self.id, objdir, head)
 
     def tree(self, objdir):
@@ -513,10 +536,10 @@ def tree(self, objdir):
         """
         def _show_indent(level, last=False, last_v=False):
             """Indent string for tree view at level for intermediate or last."""
-            tree_next = '├── '
-            tree_last = '└── '
-            tree_pass = '│   '
-            tree_indent = '    '
+            tree_next = "├── "
+            tree_last = "└── "
+            tree_pass = "│   "
+            tree_indent = "    "
             if level == 0:
                 return tree_last if last else tree_next
             return (tree_indent if last else tree_pass) + (tree_last if last_v else tree_next)
@@ -534,42 +557,42 @@ def _show_indent(level, last=False, last_v=False):
         else:
             logging.warning("OCFL v%s Object at %s is INVALID",
                             validator.spec_version, objdir)
-        tree = '[' + objdir + ']\n'
-        self.open_fs(objdir)
-        entries = sorted(self.obj_fs.listdir(''))
+        tree = "[" + objdir + "]\n"
+        self.open_obj_fs(objdir)
+        entries = sorted(self.obj_fs.listdir(""))
         n = 0
         seen_sidecar = False
         object_declaration_filename = self.object_declaration_object().filename
         for entry in entries:
             n += 1
-            note = entry + ' '
+            note = entry + " "
             v_notes = []
-            if re.match(r'''v\d+$''', entry):
+            if re.match(r"""v\d+$""", entry):
                 seen_v_sidecar = False
                 for v_entry in sorted(self.obj_fs.listdir(entry)):
-                    v_note = v_entry + ' '
+                    v_note = v_entry + " "
                     if v_entry == INVENTORY_FILENAME:
                         pass
-                    elif v_entry.startswith(INVENTORY_FILENAME + '.'):
+                    elif v_entry.startswith(INVENTORY_FILENAME + "."):
                         if seen_v_sidecar:
-                            v_note += '<--- multiple inventory digests?'
+                            v_note += "<--- multiple inventory digests?"
                             seen_v_sidecar = True
                     elif v_entry == self.content_directory:
                         num_files = 0
                         for (v_dirpath, v_dirs, v_files) in self.obj_fs.walk(fs.path.join(entry, v_entry)):  # pylint: disable=unused-variable
                             num_files += len(v_files)
-                        v_note += '(%d files)' % num_files
+                        v_note += "(%d files)" % num_files
                     else:
-                        v_note += '<--- ???'
+                        v_note += "<--- ???"
                     v_notes.append(v_note)
             elif entry in (object_declaration_filename, INVENTORY_FILENAME):
                 pass
-            elif entry.startswith(INVENTORY_FILENAME + '.'):
+            elif entry.startswith(INVENTORY_FILENAME + "."):
                 if seen_sidecar:
-                    note += '<--- multiple inventory digests?'
+                    note += "<--- multiple inventory digests?"
                 seen_sidecar = True
             else:
-                note += '<--- ???'
+                note += "<--- ???"
             last = (n == len(entries))
             tree += _show_indent(0, last) + note + "\n"
             nn = 0
@@ -616,10 +639,10 @@ def validate_inventory(self, path, log_warnings=True,
                               log_errors=log_errors)
         try:
             (inv_dir, inv_file) = fs.path.split(path)
-            validator.obj_fs = open_fs(inv_dir, create=False)
-            validator.validate_inventory(inv_file, where='standalone', force_spec_version=force_spec_version)
+            validator.obj_fs = pyfs_openfs(inv_dir, create=False)
+            validator.validate_inventory(inv_file, where="standalone", force_spec_version=force_spec_version)
         except fs.errors.ResourceNotFound:
-            validator.log.error('E033', where='standalone', explanation='failed to open directory')
+            validator.log.error("E033", where="standalone", explanation="failed to open directory")
         except ValidatorAbortException:
             pass
         return (validator.log.num_errors == 0), validator
@@ -629,21 +652,21 @@ def _extract_setup(self, objdir, version):
 
         Arguments:
             objdir - directory for the object
-            version - version to be extracted ('v1', etc.) or 'head' for latest
+            version - version to be extracted ("v1", etc.) or "head" for latest
 
         Returns tuple of (inv, version) where inv is the parsed inventory and
         version if the checked object version.
 
-        Raises an ObjectException is the inventory can't be parsed or if the
-        version doesn't exist.
+        Raises an ObjectException is the inventory Can't be parsed or if the
+        version doesn"t exist.
         """
-        self.open_fs(objdir)
+        self.open_obj_fs(objdir)
         # Read inventory, set up version
         inv = self.parse_inventory()
-        if version == 'head':
-            version = inv['head']
+        if version == "head":
+            version = inv.head
             logging.debug("Object at %s has head %s", objdir, version)
-        elif version not in inv['versions']:
+        elif version not in inv.version_directories:
             raise ObjectException("Object at %s does not include a version '%s'" % (objdir, version))
         return inv, version
 
@@ -652,7 +675,7 @@ def extract(self, objdir, version, dstdir):
 
         Arguments:
             objdir - directory for the object
-            version - version to be extracted ('v1', etc.) or 'head' for latest
+            version - version to be extracted ("v1", etc.) or "head" for latest
             dstdir - directory to create with extracted version
 
         The dstdir itself may exist bit if it is then it must be empty. The
@@ -664,7 +687,7 @@ def extract(self, objdir, version, dstdir):
         # Check the destination
         (parentdir, dir) = os.path.split(os.path.normpath(dstdir))
         try:
-            parent_fs = open_fs(parentdir)
+            parent_fs = pyfs_openfs(parentdir)
         except (fs.opener.errors.OpenerError, fs.errors.CreateFailed) as e:
             raise ObjectException("Destination parent %s does not exist or could not be opened (%s)" % (parentdir, e))
         if parent_fs.isdir(dir):
@@ -674,8 +697,8 @@ def extract(self, objdir, version, dstdir):
             parent_fs.makedir(dir)
         dst_fs = parent_fs.opendir(dir)  # Open a sub-filesystem as our destination
         # Now extract...
-        manifest = inv['manifest']
-        state = inv['versions'][version]['state']
+        manifest = inv.manifest
+        state = inv.version(version).state
         # Extract all files for this version
         for (digest, logical_files) in state.items():
             existing_file = manifest[digest][0]  # First entry with the digest, there could be > 1
@@ -684,19 +707,19 @@ def extract(self, objdir, version, dstdir):
                 dst_fs.makedirs(fs.path.dirname(logical_file), recreate=True)
                 fs.copy.copy_file(self.obj_fs, existing_file, dst_fs, logical_file)
         logging.info("Extracted %s into %s", version, dstdir)
-        return VersionMetadata(inventory=inv, version=version)
+        return VersionMetadata(inventory=inv.data, version=version)
 
     def extract_file(self, objdir, version, dstdir, logical_path):
         """Extract one file from version from object at objdir into dstdir.
 
         Arguments:
             objdir - directory for the object
-            version - version to be extracted ('v1', etc.) or 'head' for latest
+            version - version to be extracted ("v1", etc.) or "head" for latest
             dstdir - directory to create with extracted version
             logical_path - extract just one logical path into dstdir, without
                 any path segments below dstdir
 
-        If dstdir doesn't exists then create it. The parent directory of dstdir
+        If dstdir doesn"t exists then create it. The parent directory of dstdir
         must exist. If dstdir exists, then a file of the same name must not
         exist.
 
@@ -705,14 +728,14 @@ def extract_file(self, objdir, version, dstdir, logical_path):
         inv, version = self._extract_setup(objdir, version)
         # Check the destination
         try:
-            dst_fs = open_fs(dstdir)
+            dst_fs = pyfs_openfs(dstdir)
         except (fs.opener.errors.OpenerError, fs.errors.CreateFailed):
-            # Doesn't exist, can we create it?
+            # Doesn"t exist, can we create it?
             (parentdir, dir) = os.path.split(os.path.normpath(dstdir))
             if parentdir == "":
                 parentdir = "."
             try:
-                parent_fs = open_fs(parentdir)
+                parent_fs = pyfs_openfs(parentdir)
             except (fs.opener.errors.OpenerError, fs.errors.CreateFailed) as e:
                 raise ObjectException("Destination parent %s does not exist or could not be opened (%s)" % (parentdir, e))
             dst_fs = parent_fs.makedir(dir)
@@ -721,8 +744,8 @@ def extract_file(self, objdir, version, dstdir, logical_path):
         if dst_fs.exists(basename):
             raise ObjectException("Destination file %s in %s already exists" % (basename, dstdir))
         # Now extract...
-        manifest = inv['manifest']
-        state = inv['versions'][version]['state']
+        manifest = inv.manifest
+        state = inv.version(version).state
         # Extract the specified file
         copied = False
         for (digest, logical_files) in state.items():
@@ -745,37 +768,22 @@ def parse_inventory(self):
         Will validate the inventory and normalize the digests so that the rest
         of the Object methods can assume correctness and matching string digests
         between state and manifest blocks.
+
+        Returns and Inventory object for the parsed inventory.
         """
         with self.obj_fs.open(INVENTORY_FILENAME) as fh:
-            inventory = json.load(fh)
+            inventory = Inventory(json.load(fh))
         # Validate
         iv = InventoryValidator()
-        iv.validate(inventory=inventory)
+        iv.validate(inventory=inventory.data)
         if iv.log.num_errors > 0:
             raise ObjectException("Root inventory is not valid (%d errors)" % iv.log.num_errors)
         self.spec_version = iv.spec_version
-        digest_algorithm = iv.digest_algorithm
         # Normalize digests in place
-        manifest = inventory['manifest']
-        from_to = {}
-        for digest in manifest:
-            norm_digest = normalized_digest(digest, digest_algorithm)
-            if digest != norm_digest:
-                from_to[digest] = norm_digest
-        for (digest, norm_digest) in from_to.items():
-            manifest[norm_digest] = manifest.pop(digest)
-        for v in inventory['versions']:
-            state = inventory['versions'][v]['state']
-            from_to = {}
-            for digest in state:
-                norm_digest = normalized_digest(digest, digest_algorithm)
-                if digest != norm_digest:
-                    from_to[digest] = norm_digest
-            for (digest, norm_digest) in from_to.items():
-                state[norm_digest] = state.pop(digest)
+        inventory.normalize_digests(iv.digest_algorithm)
         return inventory
 
-    def id_from_inventory(self, failure_value='UNKNOWN-ID'):
+    def id_from_inventory(self, failure_value="UNKNOWN-ID"):
         """Read JSON root inventory file for this object and extract id.
 
         Returns the id from the inventory or failure_value is none can
@@ -783,6 +791,6 @@ def id_from_inventory(self, failure_value='UNKNOWN-ID'):
         """
         try:
             inventory = self.parse_inventory()
-            return inventory['id']
+            return inventory.id
         except ObjectException:
             return failure_value
diff --git a/ocfl/object_utils.py b/ocfl/object_utils.py
index beb091c..6738493 100755
--- a/ocfl/object_utils.py
+++ b/ocfl/object_utils.py
@@ -7,83 +7,135 @@
 
 from ._version import __version__
 from .namaste import find_namastes
-from .pyfs import open_fs
+from .pyfs import pyfs_openfs
 
 
-NORMALIZATIONS = ['uri', 'md5']  # Must match possibilities in map_filepaths()
+NORMALIZATIONS = ["uri", "md5"]  # Must match possibilities in map_filepaths()
 
 
 class ObjectException(Exception):
     """Exception class for OCFL Object."""
 
 
-def next_version(version):
-    """Next version identifier following existing pattern.
+def first_version_directory(zero_padded_width=None):
+    """First version directory name with possible zero padding.
 
-    Must deal with both zero-prefixed and non-zero prefixed versions.
+    Arguments:
+        zero_padded_width: either None (default) to use an unpadded version
+            directory name ("v1"), or an integer between 2 and 5 inclusive to
+            specify the number of digits used in the version directory name
+            (e.g. "v0001" if zero_padded_width=4).
+
+    Returns string of the version directory name.
+
+    Raises Object extension if zero_padded_width is specified but out or range.
+    """
+    if zero_padded_width is None:
+        return "v1"
+    # Make appropriate width zero-padded version
+    if zero_padded_width < 2 or zero_padded_width > 5:
+        raise ObjectException("Width of zero padded version numbers is out of "
+                              "range (%d)" % (zero_padded_width))
+    return "v" + "0" * (zero_padded_width - 1) + "1"
+
+
+def next_version_directory(vdir):
+    """Next version directory name following existing pattern.
+
+    Arguments:
+        vdir: last version directory name string which will follow
+            either the recommended unpadded convention (e.g. "v3") or else
+            the zero padded convention (e.g. "v00003")
+
+    Returns the next version directory following the existing style of
+    zero-padded or non zero-padded.
+
+    Raises an ObjectExtension if the format of version cannot be understood
+    or if there is overflow of a zero-padded form.
     """
-    m = re.match(r'''v((\d)\d*)$''', version)
+    m = re.match(r"""v((\d)\d*)$""", vdir)
     if not m:
-        raise ObjectException("Bad version '%s'" % version)
+        raise ObjectException("Bad version directory '%s'" % vdir)
     next_n = int(m.group(1)) + 1
-    if m.group(2) == '0':
+    if m.group(2) == "0":
         # Zero-padded version
-        next_v = ('v0%0' + str(len(version) - 2) + 'd') % next_n
-        if len(next_v) != len(version):
-            raise ObjectException("Version number overflow for zero-padded version %d to %d" % (version, next_v))
+        next_v = ("v0%0" + str(len(vdir) - 2) + "d") % next_n
+        if len(next_v) != len(vdir):
+            raise ObjectException("Version number overflow for zero-padded "
+                                  "version directory %s to %s" % (vdir, next_v))
         return next_v
     # Not zero-padded
-    return 'v' + str(next_n)
+    return "v" + str(next_n)
 
 
 def remove_first_directory(path):
     """Remove first directory from input path.
 
+    Aguments:
+        path: filesystem path string
+
     The return value will not have a trailing parh separator, even if
     the input path does. Will return an empty string if the input path
     has just one path segment.
     """
     # FIXME - how to do this efficiently? Current code does complete
     # split and rejoins, excluding the first directory
-    rpath = ''
+    rpath = ""
     while True:
         (head, tail) = fs.path.split(path)
         if path in (head, tail):
             break
         path = head
-        rpath = tail if rpath == '' else fs.path.join(tail, rpath)
+        rpath = tail if rpath == "" else fs.path.join(tail, rpath)
     return rpath
 
 
-def make_unused_filepath(filepath, used, separator='__'):
-    """Find filepath with string appended that makes it disjoint from those in used."""
+def make_unused_filepath(filepath, used, separator="__"):
+    """Find filepath with string appended that makes it disjoint from those in used.
+
+    Arguments:
+        filepath: string of filepath to use as basis for one that has not been
+            used
+        used: dict() or set() that is used to test for filepaths that have
+            already be used
+        separator: string used to separate the basis filepath from the added
+            sequence number in order to build and unused filepath
+
+    Returns filepath string that does not exist in used. Will return the
+    supplied filepath if that hasn't been used, otherwise a filepath based
+    on that with separator and a sequence integer added.
+    """
+    print("#USED= " + str(used))
     n = 1
-    while True:
+    f = filepath
+    while f in used:
         n += 1
         f = filepath + separator + str(n)
-        if f not in used:
-            return f
+    return f
 
 
 def find_path_type(path):
     """Return a string indicating the type of thing at the given path.
 
+    Arguments:
+        path: filesystem path string
+
     Return values:
-        'root' - looks like an OCFL Storage Root
-        'object' - looks like an OCFL Object
-        'file' - a file, might be an inventory
+        "root" - looks like an OCFL Storage Root
+        "object" - looks like an OCFL Object
+        "file" - a file, might be an inventory
         other string explains error description
 
     Looks only at "0=*" Namaste files to determine the directory type.
     """
     try:
-        pyfs = open_fs(path, create=False)
+        pyfs = pyfs_openfs(path, create=False)
     except (fs.opener.errors.OpenerError, fs.errors.CreateFailed):
         # Failed to open path as a filesystem, try enclosing directory
         # in case path is a file
         (parent, filename) = fs.path.split(path)
         try:
-            pyfs = open_fs(parent, create=False)
+            pyfs = pyfs_openfs(parent, create=False)
         except (fs.opener.errors.OpenerError, fs.errors.CreateFailed) as e:
             return "path cannot be opened, and nor can parent (" + str(e) + ")"
         # Can open parent, is filename a file there?
@@ -93,14 +145,31 @@ def find_path_type(path):
             return "path does not exist"
         if info.is_dir:
             return "directory that could not be opened as a filesystem, this should not happen"  # pragma: no cover
-        return 'file'
+        return "file"
     namastes = find_namastes(0, pyfs=pyfs)
     if len(namastes) == 0:
         return "no 0= declaration file"
     # Look at the first 0= Namaste file that is of OCFL form to determine type, if there are
     # multiple declarations this will be caught later
     for namaste in namastes:
-        m = re.match(r'''ocfl(_object)?_(\d+\.\d+)$''', namaste.tvalue)
+        m = re.match(r"""ocfl(_object)?_(\d+\.\d+)$""", namaste.tvalue)
         if m:
-            return 'root' if m.group(1) is None else 'object'
+            return "root" if m.group(1) is None else "object"
     return "unrecognized 0= declaration file or files (first is %s)" % (namastes[0].tvalue)
+
+
+def parse_version_directory(dirname):
+    """Get version number from version directory name.
+
+    Arguments:
+        dirname (str): the directory name to parse.
+
+    Returns the integer version number from this version directory name.
+    """
+    m = re.match(r"""v(\d{1,5})$""", dirname)
+    if not m:
+        raise ObjectException("Bad version directory name: %s" % (dirname))
+    v = int(m.group(1))
+    if v == 0:
+        raise ObjectException("Bad version directory name: %s, v0 no allowed" % (dirname))
+    return v
diff --git a/ocfl/pyfs.py b/ocfl/pyfs.py
index 21bc086..5e7790d 100644
--- a/ocfl/pyfs.py
+++ b/ocfl/pyfs.py
@@ -9,7 +9,7 @@
 
 It seems to be the case the using `strict=False` avoids checks for these empty
 directories. There is no way to pass the `strict` parameter via the open_fs()
-function so we need to call the SF3
+function so we need to call the S3FS creator method directly.
 """
 import fs
 import fs.base
@@ -18,11 +18,27 @@
 from fs_s3fs import S3FS
 
 
-def open_fs(fs_url, **kwargs):
+def _pyfs_or_local(pyfs):
+    """Open local filesytem is pyfs not set.
+
+    Arguments:
+        pyfs: an fs filesystem else None
+
+    Simply returns pyfs if set, else if pyfs is None then we open a local
+    filesystem and return that.
+    """
+    return fs.open_fs(".") if pyfs is None else pyfs
+
+
+def pyfs_openfs(fs_url, **kwargs):
     """Open a pyfs filesystem.
 
+    Arguments:
+        fs_url: filesystem url to open, use "." for local filesystem rooted
+            in current working directory
+
     Like fs.open_fs will simply return FS if an instance is given as
-    the fs_url parameter. Otherwwise will attempt to open the fs_url
+    the fs_url parameter. Otherwise will attempt to open the fs_url
     as either a local filesystem path (no `://` in string) or else
     as a FS filesystem with special handling for the case of an S3
     filesystem which has
@@ -38,7 +54,7 @@ def open_fs(fs_url, **kwargs):
 
     # We have a URL, parse it
     parse_result = fs.opener.parse(fs_url)
-    if parse_result.protocol == 's3':
+    if parse_result.protocol == "s3":
         # And S3 URL, mostly repeat
         # https://github.com/PyFilesystem/s3fs/blob/master/fs_s3fs/opener.py
         # but adjust the handling of strict to default to strict=False
@@ -69,36 +85,43 @@ def open_fs(fs_url, **kwargs):
     return fs.open_fs(fs_url, **kwargs)
 
 
-def ocfl_walk(f, dir='/', is_storage_root=False):
+def pyfs_walk(pyfs, dir="/", is_storage_root=False):
     """Walk that works on pyfs filesystems including S3 without the need for directory objects.
 
+    Arguments:
+        pyfs: fs filesytem to use
+        dir: string of directory to start from (default /)
+        is_storage_root: boolean that affects behaviour according to whether
+            this is a storage root, default False
+
     Assumes that f.getinfo() will work for a file/resource that exists and
     that fs.errors.ResourceNotFound might be raised if called on a filesystem
     without directories (and no directory objects).
 
     For walking storage roots (is_storage_root=True) then the condition to
     descend is:
-        1) this is the root (dirpath == '/'), or
-        2) there are no files in this directory (see https://ocfl.io/1.0/spec/#root-structure)
+        1) this is the root (dirpath == "/"), or
+        2) there are no files in this directory (see
+           https://ocfl.io/1.0/spec/#root-structure)
 
     For each directory will yield (dirpath, dirs, files) as does os.walk. The
     value of dirs my be pruned to avoid descending into particular directories.
 
     FIXME - QUICK AND DIRTY HACK, CAN DO BETTER!
     """
-    if not dir.startswith('/'):
-        dir = '/' + dir
+    if not dir.startswith("/"):
+        dir = "/" + dir
     stack = [dir]
     while len(stack) > 0:
         dirpath = stack.pop()
-        entries = f.listdir(dirpath)
+        entries = pyfs.listdir(dirpath)
         files = []
         dirs = []
         for entry in entries:
             entry_path = fs.path.join(dirpath, entry)
             is_dir = True
             try:
-                info = f.getinfo(entry_path)
+                info = pyfs.getinfo(entry_path)
                 is_dir = info.is_dir
             except fs.errors.ResourceNotFound:
                 pass  # Assume to be a directory
@@ -108,18 +131,25 @@ def ocfl_walk(f, dir='/', is_storage_root=False):
                 files.append(entry)
         yield (dirpath, dirs, files)
         # dirs may have been modified to prune
-        if not is_storage_root or dirpath == '/' or len(files) == 0:
+        if not is_storage_root or dirpath == "/" or len(files) == 0:
             # If this is not the storage root itself and there are files
             # present then we should not descend further
             for entry in dirs:
                 stack.append(fs.path.join(dirpath, entry))
 
 
-def ocfl_opendir(pyfs, dir, **kwargs):
+def pyfs_opendir(*, pyfs=None, dir="/", **kwargs):
     """Open directory while handling the case of S3 without directory objects.
 
-    FIXME - DIRTY HACK
+    Arguments:
+        pyfs: fs filesytem to use, else None for local
+        dir: string of directory to open
+        **kwargs: additional arguments to pass to fs.opendir
+
+    Returns an fs filesystem for the new directory within pyfs. Has special
+    handling to deal with S3 filesystems which don't have directory objects.
     """
+    pyfs = _pyfs_or_local(pyfs)
     if isinstance(pyfs, S3FS):
         # Hack for S3 because the standard opendir(..) fails when there
         # isn't a directory object (even with strict=False)
@@ -140,16 +170,50 @@ def ocfl_opendir(pyfs, dir, **kwargs):
     return pyfs.opendir(dir, **kwargs)
 
 
-def ocfl_files_identical(pyfs, file1, file2):
-    """Compare file1 and file2 on the filesystem pyfs.
+def pyfs_openfile(filepath, mode, pyfs=None, **kwargs):
+    """Open file on either local filesystem or fs filesystem.
+
+    Arguments:
+        filepath: string of file path
+        mode: file mode
+        pyfs: fs filesystem to use, else None (default) will use the
+            local file
+        **kwargs: additional arguments to pass to fs.open
+
+    Returns open fs filehandle.
+    """
+    pyfs = _pyfs_or_local(pyfs)
+    return pyfs.open(filepath, mode, **kwargs)
+
+
+def pyfs_openbin(filepath, mode, pyfs=None):
+    """Open binary file on either local filesystem or fs filesystem.
+
+    Arguments:
+        filepath: string of file path
+        mode: file mode
+        pyfs: fs filesystem to use, else None (default) will use the
+            local file
+    """
+    pyfs = _pyfs_or_local(pyfs)
+    return pyfs.openbin(filepath, mode)
+
+
+def pyfs_files_identical(pyfs, file1, file2):
+    """Compare files on one filesystem pyfs.
+
+    Arguments:
+        pyfs: fs filesytem to use
+        file1: string filepath for first file
+        file2: string filepath for second file
 
     Returns True if the files are identical, False otherwise.
 
     FIXME - Make this more efficient by comparing stat info first, then only
     comparing content in chunks if necessary.
     """
-    with pyfs.open(file1, 'r') as fh1:
+    with pyfs.open(file1, "r") as fh1:
         c1 = fh1.read()
-    with pyfs.open(file2, 'r') as fh2:
+    with pyfs.open(file2, "r") as fh2:
         c2 = fh2.read()
     return c1 == c2
diff --git a/ocfl/storage_root.py b/ocfl/storage_root.py
index d2f6892..23e64ab 100644
--- a/ocfl/storage_root.py
+++ b/ocfl/storage_root.py
@@ -12,7 +12,7 @@
 
 from .namaste import find_namastes, Namaste
 from .object import Object
-from .pyfs import open_fs, ocfl_walk, ocfl_opendir
+from .pyfs import pyfs_openfs, pyfs_walk, pyfs_opendir
 from .validator import Validator
 from .validation_logger import ValidationLogger
 
@@ -30,15 +30,15 @@ def get_layout(layout_name=None):
     Returns a layout object for the appropriate layour if the layour_name
     is recognized, otherwise throws a StorageRootException.
     """
-    if layout_name in ('0002-flat-direct-storage-layout', '0002', 'flat-direct'):
+    if layout_name in ("0002-flat-direct-storage-layout", "0002", "flat-direct"):
         return Layout_0002_Flat_Direct()
-    if layout_name in ('0003-hash-and-id-n-tuple-storage-layout', '0003'):
+    if layout_name in ("0003-hash-and-id-n-tuple-storage-layout", "0003"):
         return Layout_0003_Hash_And_Id_N_Tuple()
-    if layout_name in ('nnnn-flat-quoted-storage-layout', 'flat-quoted'):
+    if layout_name in ("nnnn-flat-quoted-storage-layout", "flat-quoted"):
         return Layout_NNNN_Flat_Quoted()
-    if layout_name == 'nnnn-tuple-tree':
+    if layout_name == "nnnn-tuple-tree":
         return Layout_NNNN_Tuple_Tree()
-    if layout_name == 'nnnn-uuid-quadtree':
+    if layout_name == "nnnn-uuid-quadtree":
         return Layout_NNNN_UUID_Quadtree()
     raise StorageRootException("Unsupported layout_name %s, aborting!" % (layout_name))
 
@@ -61,17 +61,17 @@ def __init__(self, root=None, layout_name=None, lax_digests=False,
         self.spec_version = None
         if spec_version is not None:
             self.check_spec_version(spec_version)
-        self.layout_file = 'ocfl_layout.json'
+        self.layout_file = "ocfl_layout.json"
         self.registered_extensions = [
-            '0002-flat-direct-storage-layout',
-            '0003-hash-and-id-n-tuple-storage-layout'
+            "0002-flat-direct-storage-layout",
+            "0003-hash-and-id-n-tuple-storage-layout"
         ]
         self.supported_layouts = [
-            '0002-flat-direct-storage-layout',
-            '0003-hash-and-id-n-tuple-storage-layout',
-            'nnnn-flat-quoted-storage-layout',
-            'nnnn-tuple-tree',
-            'nnnn-uuid-quadtree'
+            "0002-flat-direct-storage-layout",
+            "0003-hash-and-id-n-tuple-storage-layout",
+            "nnnn-flat-quoted-storage-layout",
+            "nnnn-tuple-tree",
+            "nnnn-uuid-quadtree"
         ]
         #
         self.root_fs = None
@@ -84,24 +84,24 @@ def __init__(self, root=None, layout_name=None, lax_digests=False,
         self.structure_error = None
         self.traversal_errors = None
 
-    def check_spec_version(self, spec_version, default='1.1'):
+    def check_spec_version(self, spec_version, default="1.1"):
         """Check the OCFL specification version is supported."""
         if spec_version is None and self.spec_version is None:
             spec_version = default
-        if spec_version not in ('1.0', '1.1'):
+        if spec_version not in ("1.0", "1.1"):
             raise StorageRootException("Unsupported OCFL specification version %s requested" % (spec_version))
         self.spec_version = spec_version
 
     def open_root_fs(self, create=False):
         """Open pyfs filesystem for this OCFL storage root."""
         try:
-            self.root_fs = open_fs(self.root, create=create)
+            self.root_fs = pyfs_openfs(self.root, create=create)
         except (fs.opener.errors.OpenerError, fs.errors.CreateFailed) as e:
             raise StorageRootException("Failed to open OCFL storage root filesystem '%s' (%s)" % (self.root, str(e)))
 
     def root_declaration_object(self):
         """NAMASTE object declaration Namaste object."""
-        return Namaste(0, 'ocfl_' + self.spec_version)
+        return Namaste(0, "ocfl_" + self.spec_version)
 
     def write_root_declaration(self, root_fs):
         """Write NAMASTE object declaration.
@@ -125,7 +125,7 @@ def traversal_error(self, code, **kwargs):
         """Record error traversing OCFL storage root."""
         self.num_traversal_errors += 1
         if self.log is None:  # FIXME - What to do in non-validator context?
-            args = ', '.join('{0}={1!r}'.format(k, v) for k, v in kwargs.items())
+            args = ", ".join("{0}={1!r}".format(k, v) for k, v in kwargs.items())
             logging.error("Traversal error %s - %s", code, args)
         else:
             self.log.error(code, **kwargs)
@@ -147,7 +147,7 @@ def initialize(self, spec_version=None, layout_params=None):
         self.check_spec_version(spec_version=spec_version)
         # Now create the storage root
         (parent, root_dir) = fs.path.split(self.root)
-        parent_fs = open_fs(parent)
+        parent_fs = pyfs_openfs(parent)
         if parent_fs.exists(root_dir):
             raise StorageRootException("OCFL storage root %s already exists, aborting!" % (self.root))
         self.root_fs = parent_fs.makedir(root_dir)
@@ -157,9 +157,9 @@ def initialize(self, spec_version=None, layout_params=None):
         # Create a layout declaration if the layout ise set, it is valid to have
         # a storage root with no layout information
         if self.layout is not None:
-            with self.root_fs.open(self.layout_file, 'w') as fh:
-                layout = {'extension': self.layout.NAME,
-                          'description': self.layout.DESCRIPTION}
+            with self.root_fs.open(self.layout_file, "w") as fh:
+                layout = {"extension": self.layout.NAME,
+                          "description": self.layout.DESCRIPTION}
                 json.dump(layout, fh, sort_keys=True, indent=2)
             # Do we need to qrite a extension description?
             self.layout.write_layout_params(root_fs=self.root_fs)
@@ -179,8 +179,8 @@ def check_root_structure(self):
         if len(namastes) > 1:
             raise StorageRootException("E069b Storage root %s has more than one 0= style declaration file" % (self.root))
         spec_version = None
-        for version in ('1.1', '1.0'):
-            if namastes[0].filename == '0=ocfl_' + version:
+        for version in ("1.1", "1.0"):
+            if namastes[0].filename == "0=ocfl_" + version:
                 spec_version = version
                 break
         else:
@@ -215,13 +215,13 @@ def parse_layout_file(self):
             with self.root_fs.open(self.layout_file) as fh:
                 layout = json.load(fh)
         except Exception as e:
-            raise StorageRootException("OCFL storage root %s has layout file that can't be read/parsed (%s)" % (self.root, str(e)))
+            raise StorageRootException("OCFL storage root %s has layout file that cant be read/parsed (%s)" % (self.root, str(e)))
         if not isinstance(layout, dict):
             raise StorageRootException("Storage root %s has layout file that isn't a JSON object" % (self.root))
-        if ('extension' not in layout or not isinstance(layout['extension'], str)
-                or 'description' not in layout or not isinstance(layout['description'], str)):
+        if ("extension" not in layout or not isinstance(layout["extension"], str)
+                or "description" not in layout or not isinstance(layout["description"], str)):
             raise StorageRootException("Storage root %s has layout file doesn't have required extension and description string entries" % (self.root))
-        return layout['extension'], layout['description']
+        return layout["extension"], layout["description"]
 
     def object_paths(self):
         """Generate object paths for every obect in the OCFL storage root.
@@ -232,11 +232,11 @@ def object_paths(self):
         Will log any errors seen while traversing the directory tree under the
         storage root.
         """
-        for (dirpath, dirs, files) in ocfl_walk(self.root_fs, is_storage_root=True):
-            if dirpath == '/':
-                if 'extensions' in dirs:
+        for (dirpath, dirs, files) in pyfs_walk(self.root_fs, is_storage_root=True):
+            if dirpath == "/":
+                if "extensions" in dirs:
                     self.validate_extensions_dir()
-                    dirs.remove('extensions')
+                    dirs.remove("extensions")
                 # Ignore any other files in storage root
             elif (len(dirs) + len(files)) == 0:
                 self.traversal_error("E073", path=dirpath)
@@ -244,14 +244,14 @@ def object_paths(self):
                 pass  # Just an intermediate directory
             else:
                 # Is this directory an OCFL object? Look for any 0= file.
-                zero_eqs = [file for file in files if file.startswith('0=')]
+                zero_eqs = [file for file in files if file.startswith("0=")]
                 if len(zero_eqs) > 1:
                     self.traversal_error("E003d", path=dirpath)
                 elif len(zero_eqs) == 1:
                     declaration = zero_eqs[0]
-                    match = re.match(r'''0=ocfl_object_(\d+\.\d+)''', declaration)
-                    if match and match.group(1) in ('1.0', '1.1'):  # FIXME - look up supported versions
-                        yield dirpath.lstrip('/')
+                    match = re.match(r"""0=ocfl_object_(\d+\.\d+)""", declaration)
+                    if match and match.group(1) in ("1.0", "1.1"):  # FIXME - look up supported versions
+                        yield dirpath.lstrip("/")
                     elif match:
                         self.traversal_error("E004a", path=dirpath, version=match.group(1))
                     else:
@@ -268,12 +268,12 @@ def validate_extensions_dir(self):
         this code relies up the registered_extensions property to list known
         storage root extensions.
         """
-        for entry in self.root_fs.scandir('extensions'):
+        for entry in self.root_fs.scandir("extensions"):
             if entry.is_dir:
                 if entry.name not in self.registered_extensions:
-                    self.log.warning('W901', entry=entry.name)  # FIXME - No good warning code in spec
+                    self.log.warning("W901", entry=entry.name)  # FIXME - No good warning code in spec
             else:
-                self.traversal_error('E086', entry=entry.name)
+                self.traversal_error("E086", entry=entry.name)
 
     def list_objects(self):
         """List contents of this OCFL storage root.
@@ -288,7 +288,7 @@ def list_objects(self):
         self.check_root_structure()
         self.num_objects = 0
         for dirpath in self.object_paths():
-            with ocfl_opendir(self.root_fs, dirpath) as obj_fs:
+            with pyfs_opendir(pyfs=self.root_fs, dir=dirpath) as obj_fs:
                 # Parse inventory to extract id
                 identifier = Object(obj_fs=obj_fs).id_from_inventory()
                 self.num_objects += 1
@@ -314,21 +314,21 @@ def validate_hierarchy(self, validate_objects=True, check_digests=True,
                                       log_warnings=log_warnings)
                 # FIXME - Should check that all objest are not higher spec
                 # version that storage root https://ocfl.io/1.1/spec/#E081
-                if validator.validate_object(ocfl_opendir(self.root_fs, dirpath)):
+                if validator.validate_object(pyfs_opendir(pyfs=self.root_fs, dir=dirpath)):
                     good_objects += 1
                 else:
                     logging.debug("Object at %s in INVALID", dirpath)
                 if len(errors) < max_errors:
                     # Record detail of errors (and warnings if log_warnings)
-                    messages = validator.status_str(prefix='[[' + dirpath + ']]')
-                    if messages != '':
+                    messages = validator.status_str(prefix="[[" + dirpath + "]]")
+                    if messages != "":
                         errors.append([dirpath, messages])
                 num_objects += 1
         return num_objects, good_objects, errors
 
     def validate(self, *, validate_objects=True, check_digests=True,
                  log_warnings=False, log_errors=True, max_errors=100,
-                 lang='en'):
+                 lang="en"):
         """Validate OCFL storage root, structure, and optionally all objects.
 
         Arguments (keyword only):
@@ -380,17 +380,16 @@ def add(self, object_path):
         self.open_root_fs()
         self.check_root_structure()
         # Sanity check
-        o = Object()
-        o.open_fs(object_path)
+        o = Object(path=object_path)
         inventory = o.parse_inventory()
-        identifier = inventory['id']
+        identifier = inventory.id
         # Now copy
         path = self.object_path(identifier)
         if self.root_fs.exists(path):
             raise StorageRootException("Add object failed because path %s exists" % (path))
         logging.debug("Copying from %s to %s", object_path, fs.path.join(self.root, path))
         try:
-            copy_dir(o.obj_fs, '/', self.root_fs, path)
+            copy_dir(o.obj_fs, "/", self.root_fs, path)
         except Exception as e:
             raise StorageRootException("Add object at path %s failed! (%s)" % (path, str(e)))
         return (identifier, path)
diff --git a/ocfl/validation_logger.py b/ocfl/validation_logger.py
index afcb8b5..5643c5e 100644
--- a/ocfl/validation_logger.py
+++ b/ocfl/validation_logger.py
@@ -66,7 +66,7 @@ def __init__(self, *, log_warnings=False, log_errors=True,
         if validation_codes is not None:
             self.validation_codes = validation_codes
         elif self.validation_codes is None:
-            with open(os.path.join(os.path.dirname(__file__), 'data/validation-errors.json'), 'r', encoding="utf-8") as fh:
+            with open(os.path.join(os.path.dirname(__file__), "data/validation-errors.json"), "r", encoding="utf-8") as fh:
                 self.validation_codes = json.load(fh)
         # Instance variables to accumulate log data
         self.codes = {}
@@ -96,34 +96,34 @@ def log(self, code, is_error, **args):
         the `log_warnings` and `log_errors` attributes.
         """
         severity = "error" if is_error else "warning"
-        if code in self.validation_codes and 'description' in self.validation_codes[code]:
-            desc = self.validation_codes[code]['description']
+        if code in self.validation_codes and "description" in self.validation_codes[code]:
+            desc = self.validation_codes[code]["description"]
             lang_desc = None
             if self.lang in desc:
                 lang_desc = desc[self.lang]
-            elif 'en' in desc:
-                lang_desc = desc['en']
+            elif "en" in desc:
+                lang_desc = desc["en"]
             elif len(desc) > 0:
                 # first key alphabetically
                 lang_desc = desc[sorted(list(desc.keys()))[0]]
             else:
                 lang_desc = "Unknown " + severity + " without a description"
             # Add in any parameters
-            if 'params' in self.validation_codes[code]:
+            if "params" in self.validation_codes[code]:
                 params = []
-                for param in self.validation_codes[code]['params']:
-                    params.append(str(args[param]) if param in args else '???')
+                for param in self.validation_codes[code]["params"]:
+                    params.append(str(args[param]) if param in args else "???")
                 try:
                     lang_desc = lang_desc % tuple(params)
                 except TypeError:
-                    lang_desc += ' ' + str(args)
-            message = '[' + code + '] ' + lang_desc
+                    lang_desc += " " + str(args)
+            message = "[" + code + "] " + lang_desc
         else:
             message = "Unknown " + severity + ": %s - params (%s)" % (code, str(args))
         # Add link to spec
-        m = re.match(r'''([EW](\d\d\d))''', code)
+        m = re.match(r"""([EW](\d\d\d))""", code)
         if m and int(m.group(2)) < 200:
-            message += ' (see ' + self.spec_url + '#' + m.group(1) + ')'
+            message += " (see " + self.spec_url + "#" + m.group(1) + ")"
         # Store set of codes with last message for that code, and _full_ list of messages
         self.codes[code] = message
         if (is_error and self.log_errors) or (not is_error and self.log_warnings):
@@ -139,11 +139,11 @@ def warning(self, code, **args):
         self.log(code, is_error=False, **args)
         self.num_warnings += 1
 
-    def status_str(self, prefix=''):
+    def status_str(self, prefix=""):
         """Return string of validator status, with optional prefix."""
-        s = ''
+        s = ""
         for message in sorted(self.messages):
-            s += prefix + message + '\n'
+            s += prefix + message + "\n"
         return s[:-1]
 
     def __str__(self):
diff --git a/ocfl/validator.py b/ocfl/validator.py
index 8ad6a92..3724f3c 100644
--- a/ocfl/validator.py
+++ b/ocfl/validator.py
@@ -16,7 +16,7 @@
 from .digest import file_digest, normalized_digest
 from .inventory_validator import InventoryValidator
 from .namaste import find_namastes
-from .pyfs import open_fs, ocfl_walk, ocfl_files_identical
+from .pyfs import pyfs_openfs, pyfs_walk, pyfs_files_identical
 from .validation_logger import ValidationLogger
 
 
@@ -29,8 +29,8 @@ class Validator():
 
     def __init__(self, *, log_warnings=False, log_errors=True,
                  check_digests=True, lax_digests=False,
-                 force_spec_version=None, default_spec_version='1.1',
-                 log=None, lang='en'):
+                 force_spec_version=None, default_spec_version="1.1",
+                 log=None, lang="en"):
         """Initialize OCFL Object validator object.
 
         Arguments:
@@ -48,11 +48,11 @@ def __init__(self, *, log_warnings=False, log_errors=True,
                 version. If force_spec_version is set then a declaration within
                 the object that doesn't match will be reported as an error.
             default_spec_version: string of default specification version to
-                assume where not specified (default '1.1')
+                assume where not specified (default "1.1")
             log: None (default) to create new ValidationLogger instance, or
                 else use the specified instance which is the appropriate case
                 for validation of multiple objects within a storage root.
-            lang: language string (default 'en') to pass to the validation
+            lang: language string (default "en") to pass to the validation
                 logger.
         """
         self.check_digests = check_digests
@@ -65,9 +65,9 @@ def __init__(self, *, log_warnings=False, log_errors=True,
                                         log_errors=log_errors,
                                         lang=lang)
         self.registered_extensions = [
-            '0001-digest-algorithms', '0002-flat-direct-storage-layout',
-            '0003-hash-and-id-n-tuple-storage-layout', '0004-hashed-n-tuple-storage-layout',
-            '0005-mutable-head'
+            "0001-digest-algorithms", "0002-flat-direct-storage-layout",
+            "0003-hash-and-id-n-tuple-storage-layout", "0004-hashed-n-tuple-storage-layout",
+            "0005-mutable-head"
         ]
         # The following actually initialized in initialize() method
         self.id = None
@@ -88,13 +88,13 @@ def initialize(self):
         self.id = None
         self.spec_version = self.default_spec_version
         self.log.spec_version = self.spec_version
-        self.digest_algorithm = 'sha512'
-        self.content_directory = 'content'
+        self.digest_algorithm = "sha512"
+        self.content_directory = "content"
         self.inventory_digest_files = {}  # index by version_dir, algorithms may differ
         self.root_inv_validator = None
         self.obj_fs = None
 
-    def status_str(self, prefix=''):
+    def status_str(self, prefix=""):
         """Return string representation of validation log, with optional prefix."""
         return self.log.status_str(prefix=prefix)
 
@@ -105,6 +105,9 @@ def __str__(self):
     def validate_object(self, path):
         """Validate OCFL object at path or pyfs root.
 
+        Arguments:
+            path: either a filepath or else an open fs filesystem
+
         Designed to be called multiple times if used to validate many objects
         when validating a storage root, for example.
 
@@ -113,44 +116,44 @@ def validate_object(self, path):
         self.initialize()
         try:
             if isinstance(path, str):
-                self.obj_fs = open_fs(path)
+                self.obj_fs = pyfs_openfs(path)
             else:
                 self.obj_fs = path
-                path = self.obj_fs.desc('')
+                path = self.obj_fs.desc("")
         except fs.errors.CreateFailed:
-            self.log.error('E003e', path=path)
+            self.log.error("E003e", path=path)
             return False
         # Object declaration, set spec version number. If there are multiple declarations,
         # look for the lastest object version then report any others as errors
         namastes = find_namastes(0, pyfs=self.obj_fs)
         if len(namastes) == 0:
-            self.log.error('E003a', assumed_version=self.spec_version)
+            self.log.error("E003a", assumed_version=self.spec_version)
         else:
             spec_version = None
             for namaste in namastes:
                 # Extract and check spec version number
                 this_file_version = None
-                for version in ('1.1', '1.0'):
-                    if namaste.filename == '0=ocfl_object_' + version:
+                for version in ("1.1", "1.0"):
+                    if namaste.filename == "0=ocfl_object_" + version:
                         this_file_version = version
                         break
                 if this_file_version is None:
-                    self.log.error('E006', filename=namaste.filename)
+                    self.log.error("E006", filename=namaste.filename)
                 elif spec_version is None or this_file_version > spec_version:
                     spec_version = this_file_version
                     if not namaste.content_ok(pyfs=self.obj_fs):
-                        self.log.error('E007', filename=namaste.filename)
+                        self.log.error("E007", filename=namaste.filename)
             if spec_version is None:
-                self.log.error('E003c', assumed_version=self.spec_version)
+                self.log.error("E003c", assumed_version=self.spec_version)
             else:
                 self.spec_version = spec_version
                 self.log.spec_version = self.spec_version
                 if len(namastes) > 1:
-                    self.log.error('E003b', files=len(namastes), using_version=self.spec_version)
+                    self.log.error("E003b", files=len(namastes), using_version=self.spec_version)
         # Object root inventory file
-        inv_file = 'inventory.json'
+        inv_file = "inventory.json"
         if not self.obj_fs.exists(inv_file):
-            self.log.error('E063')
+            self.log.error("E063")
             return False
         try:
             inventory, inv_validator = self.validate_inventory(inv_file)
@@ -173,7 +176,7 @@ def validate_object(self, path):
             pass
         return self.log.num_errors == 0
 
-    def validate_inventory(self, inv_file, where='root', force_spec_version=None):
+    def validate_inventory(self, inv_file, where="root", force_spec_version=None):
         """Validate a given inventory file, record errors with self.log.error().
 
         Returns inventory object for use in later validation
@@ -182,18 +185,18 @@ def validate_inventory(self, inv_file, where='root', force_spec_version=None):
 
         Arguments:
             inv_file: file name of inventory within self.obj_fs
-            where: string (default 'root') used for reporting messages of
-                where inventory is in object, will be either 'root' or the
+            where: string (default "root") used for reporting messages of
+                where inventory is in object, will be either "root" or the
                 version directory
             force_spec_version: if None (default) will attempt to take
                 spec_version from the inventory itself instead of using the
                 spec version provided
         """
         try:
-            with self.obj_fs.openbin(inv_file, 'r') as fh:
+            with self.obj_fs.openbin(inv_file, "r") as fh:
                 inventory = json.load(fh)
         except json.decoder.JSONDecodeError as e:
-            self.log.error('E033', where=where, explanation=str(e))
+            self.log.error("E033", where=where, explanation=str(e))
             raise ValidatorAbortException
         inv_validator = InventoryValidator(log=self.log, where=where,
                                            lax_digests=self.lax_digests,
@@ -203,9 +206,9 @@ def validate_inventory(self, inv_file, where='root', force_spec_version=None):
 
     def validate_inventory_digest(self, inv_file, digest_algorithm, where="root"):
         """Validate the appropriate inventory digest file in path."""
-        inv_digest_file = inv_file + '.' + digest_algorithm
+        inv_digest_file = inv_file + "." + digest_algorithm
         if not self.obj_fs.exists(inv_digest_file):
-            self.log.error('E058a', where=where, path=inv_digest_file)
+            self.log.error("E058a", where=where, path=inv_digest_file)
         else:
             self.validate_inventory_digest_match(inv_file, inv_digest_file)
 
@@ -217,7 +220,7 @@ def validate_inventory_digest_match(self, inv_file, inv_digest_file):
         """
         if not self.check_digests:
             return
-        m = re.match(r'''.*\.(\w+)$''', inv_digest_file)
+        m = re.match(r""".*\.(\w+)$""", inv_digest_file)
         if m:
             digest_algorithm = m.group(1)
             try:
@@ -236,31 +239,31 @@ def validate_object_root(self, version_dirs, already_checked):
         All expected_files must be present and no other files.
         All expected_dirs must be present and no other dirs.
         """
-        expected_files = ['0=ocfl_object_' + self.spec_version, 'inventory.json',
-                          'inventory.json.' + self.digest_algorithm]
-        for entry in self.obj_fs.scandir(''):
+        expected_files = ["0=ocfl_object_" + self.spec_version, "inventory.json",
+                          "inventory.json." + self.digest_algorithm]
+        for entry in self.obj_fs.scandir(""):
             if entry.is_file:
                 if entry.name not in expected_files and entry.name not in already_checked:
-                    self.log.error('E001a', file=entry.name)
+                    self.log.error("E001a", file=entry.name)
             elif entry.is_dir:
                 if entry.name in version_dirs:
                     pass
-                elif entry.name == 'logs':
+                elif entry.name == "logs":
                     # We simply ignore any logs directory from a validation point
                     # of view. The directory MAY be present but its contents are
                     # locally defined and it MAY also be empty. See:
                     # https://ocfl.io/1.1/spec/#logs-directory
                     pass
-                elif entry.name == 'extensions':
+                elif entry.name == "extensions":
                     self.validate_extensions_dir()
-                elif re.match(r'''v\d+$''', entry.name):
+                elif re.match(r"""v\d+$""", entry.name):
                     # Looks like a version directory so give more specific error
-                    self.log.error('E046b', dir=entry.name)
+                    self.log.error("E046b", dir=entry.name)
                 else:
                     # Simply an unexpected directory
-                    self.log.error('E001b', dir=entry.name)
+                    self.log.error("E001b", dir=entry.name)
             else:
-                self.log.error('E001c', entry=entry.name)
+                self.log.error("E001c", entry=entry.name)
 
     def validate_extensions_dir(self):
         """Validate content of extensions directory inside object root.
@@ -271,12 +274,12 @@ def validate_extensions_dir(self):
         this code relies up the registered_extensions property to list known
         extensions.
         """
-        for entry in self.obj_fs.scandir('extensions'):
+        for entry in self.obj_fs.scandir("extensions"):
             if entry.is_dir:
                 if entry.name not in self.registered_extensions:
-                    self.log.warning('W013', entry=entry.name)
+                    self.log.warning("W013", entry=entry.name)
             else:
-                self.log.error('E067', entry=entry.name)
+                self.log.error("E067", entry=entry.name)
 
     def validate_version_inventories(self, version_dirs):
         """Each version SHOULD have an inventory up to that point.
@@ -295,24 +298,24 @@ def validate_version_inventories(self, version_dirs):
         first_version_content_directory_set = False
         last_version = version_dirs[-1]
         prev_version_dir = "NONE"  # will be set for first directory with inventory
-        prev_spec_version = '1.0'  # lowest version
+        prev_spec_version = "1.0"  # lowest version
         for version_dir in version_dirs:
-            inv_file = fs.path.join(version_dir, 'inventory.json')
+            inv_file = fs.path.join(version_dir, "inventory.json")
             if not self.obj_fs.exists(inv_file):
-                self.log.warning('W010', where=version_dir)
+                self.log.warning("W010", where=version_dir)
                 continue
             # There is an inventory file for this version directory, check it
             if version_dir == last_version:
                 # Don't validate in this case. Per the spec the inventory in the last version
                 # MUST be identical to the copy in the object root, just check that
-                root_inv_file = 'inventory.json'
-                if not ocfl_files_identical(self.obj_fs, inv_file, root_inv_file):
-                    self.log.error('E064', root_inv_file=root_inv_file, inv_file=inv_file)
+                root_inv_file = "inventory.json"
+                if not pyfs_files_identical(self.obj_fs, inv_file, root_inv_file):
+                    self.log.error("E064", root_inv_file=root_inv_file, inv_file=inv_file)
                 else:
                     # We could also just compare digest files but this gives a more helpful error for
                     # which file has the incorrect digest if they don't match
                     self.validate_inventory_digest(inv_file, self.digest_algorithm, where=version_dir)
-                self.inventory_digest_files[version_dir] = 'inventory.json.' + self.digest_algorithm
+                self.inventory_digest_files[version_dir] = "inventory.json." + self.digest_algorithm
                 this_spec_version = self.spec_version
             else:
                 # Note that inventories in prior versions may use different digest
@@ -322,14 +325,14 @@ def validate_version_inventories(self, version_dirs):
                 this_spec_version = inv_validator.spec_version
                 digest_algorithm = inv_validator.digest_algorithm
                 self.validate_inventory_digest(inv_file, digest_algorithm, where=version_dir)
-                self.inventory_digest_files[version_dir] = 'inventory.json.' + digest_algorithm
-                if self.id and 'id' in version_inventory:
-                    if version_inventory['id'] != self.id:
-                        self.log.error('E037b', where=version_dir, root_id=self.id, version_id=version_inventory['id'])
+                self.inventory_digest_files[version_dir] = "inventory.json." + digest_algorithm
+                if self.id and "id" in version_inventory:
+                    if version_inventory["id"] != self.id:
+                        self.log.error("E037b", where=version_dir, root_id=self.id, version_id=version_inventory["id"])
                 if first_version:
                     first_version_content_directory_set = inv_validator.content_directory_set
                     if not first_version_content_directory_set and self.content_directory_set:
-                        self.log.error('E019', where='root')
+                        self.log.error("E019", where="root")
                         first_version_content_directory_set = False  # report E019 just once
                 elif not first_version_content_directory_set and inv_validator.content_directory_set:
                     # E019 is a rather odd and specific error condition: that if
@@ -337,24 +340,24 @@ def validate_version_inventories(self, version_dirs):
                     # the first version. It isn't clear that this is very usefully
                     # different from E020 that picks up inconsistency between ant
                     # two versions.
-                    self.log.error('E019', where=version_dir)
+                    self.log.error("E019", where=version_dir)
                     first_version_content_directory_set = False  # report E019 just once
                 if self.content_directory != inv_validator.content_directory:
-                    self.log.error('E020', where=version_dir, root_content_directory=self.content_directory, version_content_directory=inv_validator.content_directory)
-                if 'manifest' in version_inventory:
+                    self.log.error("E020", where=version_dir, root_content_directory=self.content_directory, version_content_directory=inv_validator.content_directory)
+                if "manifest" in version_inventory:
                     # Check that all files listed in prior inventories are in manifest
                     not_seen = set(prior_manifest_digests.keys())
-                    for digest in version_inventory['manifest']:
-                        for filepath in version_inventory['manifest'][digest]:
+                    for digest in version_inventory["manifest"]:
+                        for filepath in version_inventory["manifest"][digest]:
                             # We rely on the validation to check that anything present is OK
                             if filepath in not_seen:
                                 not_seen.remove(filepath)
                     if len(not_seen) > 0:
-                        self.log.error('E023b', where=version_dir, missing_filepaths=', '.join(sorted(not_seen)))
+                        self.log.error("E023b", where=version_dir, missing_filepaths=", ".join(sorted(not_seen)))
                     # Record all prior digests
-                    for unnormalized_digest in version_inventory['manifest']:
+                    for unnormalized_digest in version_inventory["manifest"]:
                         digest = normalized_digest(unnormalized_digest, digest_type=digest_algorithm)
-                        for filepath in version_inventory['manifest'][unnormalized_digest]:
+                        for filepath in version_inventory["manifest"][unnormalized_digest]:
                             if filepath not in prior_manifest_digests:
                                 prior_manifest_digests[filepath] = {}
                             if digest_algorithm not in prior_manifest_digests[filepath]:
@@ -367,11 +370,11 @@ def validate_version_inventories(self, version_dirs):
                     self.root_inv_validator.validate_as_prior_version(inv_validator)
                 # Fixity blocks are independent in each version. Record all values and the versions
                 # they occur in for later checks against content
-                if 'fixity' in version_inventory:
-                    for digest_algorithm in version_inventory['fixity']:
-                        for unnormalized_digest in version_inventory['fixity'][digest_algorithm]:
+                if "fixity" in version_inventory:
+                    for digest_algorithm in version_inventory["fixity"]:
+                        for unnormalized_digest in version_inventory["fixity"][digest_algorithm]:
                             digest = normalized_digest(unnormalized_digest, digest_type=digest_algorithm)
-                            for filepath in version_inventory['fixity'][digest_algorithm][unnormalized_digest]:
+                            for filepath in version_inventory["fixity"][digest_algorithm][unnormalized_digest]:
                                 if filepath not in prior_fixity_digests:
                                     prior_fixity_digests[filepath] = {}
                                 if digest_algorithm not in prior_fixity_digests[filepath]:
@@ -382,7 +385,7 @@ def validate_version_inventories(self, version_dirs):
             # We are validating the inventories in sequence and each new version must
             # follow the same or later spec version to previous inventories
             if prev_spec_version > this_spec_version:
-                self.log.error('E103', where=version_dir, this_spec_version=this_spec_version,
+                self.log.error("E103", where=version_dir, this_spec_version=this_spec_version,
                                prev_version_dir=prev_version_dir, prev_spec_version=prev_spec_version)
             prev_version_dir = version_dir
             prev_spec_version = this_spec_version
@@ -400,18 +403,18 @@ def validate_content(self, inventory, version_dirs, prior_manifest_digests, prio
             try:
                 # Check contents of version directory except content_directory
                 for entry in self.obj_fs.listdir(version_dir):
-                    if ((entry == 'inventory.json')
+                    if ((entry == "inventory.json")
                             or (version_dir in self.inventory_digest_files and entry == self.inventory_digest_files[version_dir])):
                         pass
                     elif entry == self.content_directory:
                         # Check content_directory
                         content_path = fs.path.join(version_dir, self.content_directory)
                         num_content_files_in_version = 0
-                        for dirpath, dirs, files in ocfl_walk(self.obj_fs, content_path):
-                            if dirpath != '/' + content_path and (len(dirs) + len(files)) == 0:
+                        for dirpath, dirs, files in pyfs_walk(self.obj_fs, content_path):
+                            if dirpath != "/" + content_path and (len(dirs) + len(files)) == 0:
                                 self.log.error("E024", where=version_dir, path=dirpath)
                             for file in files:
-                                files_seen.add(fs.path.join(dirpath, file).lstrip('/'))
+                                files_seen.add(fs.path.join(dirpath, file).lstrip("/"))
                                 num_content_files_in_version += 1
                         if num_content_files_in_version == 0:
                             self.log.warning("W003", where=version_dir)
@@ -420,43 +423,43 @@ def validate_content(self, inventory, version_dirs, prior_manifest_digests, prio
                     else:
                         self.log.error("E015", where=version_dir, entry=entry)
             except (fs.errors.ResourceNotFound, fs.errors.DirectoryExpected):
-                self.log.error('E046a', version_dir=version_dir)
+                self.log.error("E046a", version_dir=version_dir)
         # Extract any digests in fixity and organize by filepath
         fixity_digests = {}
-        if 'fixity' in inventory:
-            for digest_algorithm in inventory['fixity']:
-                for digest in inventory['fixity'][digest_algorithm]:
-                    for filepath in inventory['fixity'][digest_algorithm][digest]:
+        if "fixity" in inventory:
+            for digest_algorithm in inventory["fixity"]:
+                for digest in inventory["fixity"][digest_algorithm]:
+                    for filepath in inventory["fixity"][digest_algorithm][digest]:
                         if filepath in files_seen:
                             if filepath not in fixity_digests:
                                 fixity_digests[filepath] = {}
                             if digest_algorithm not in fixity_digests[filepath]:
                                 fixity_digests[filepath][digest_algorithm] = {}
                             if digest not in fixity_digests[filepath][digest_algorithm]:
-                                fixity_digests[filepath][digest_algorithm][digest] = ['root']
+                                fixity_digests[filepath][digest_algorithm][digest] = ["root"]
                         else:
-                            self.log.error('E093b', where='root', digest_algorithm=digest_algorithm, digest=digest, content_path=filepath)
+                            self.log.error("E093b", where="root", digest_algorithm=digest_algorithm, digest=digest, content_path=filepath)
         # Check all files in root manifest
-        if 'manifest' in inventory:
-            for digest in inventory['manifest']:
-                for filepath in inventory['manifest'][digest]:
+        if "manifest" in inventory:
+            for digest in inventory["manifest"]:
+                for filepath in inventory["manifest"][digest]:
                     if filepath not in files_seen:
-                        self.log.error('E092b', where='root', content_path=filepath)
+                        self.log.error("E092b", where="root", content_path=filepath)
                     else:
                         if self.check_digests:
                             content_digest = file_digest(filepath, digest_type=self.digest_algorithm, pyfs=self.obj_fs)
                             if content_digest != normalized_digest(digest, digest_type=self.digest_algorithm):
-                                self.log.error('E092a', where='root', digest_algorithm=self.digest_algorithm, digest=digest, content_path=filepath, content_digest=content_digest)
+                                self.log.error("E092a", where="root", digest_algorithm=self.digest_algorithm, digest=digest, content_path=filepath, content_digest=content_digest)
                             known_digests = {self.digest_algorithm: content_digest}
                             # Are there digest values in the fixity block?
-                            self.check_additional_digests(filepath, known_digests, fixity_digests, 'E093a')
+                            self.check_additional_digests(filepath, known_digests, fixity_digests, "E093a")
                             # Are there other digests for this same file from other inventories?
-                            self.check_additional_digests(filepath, known_digests, prior_manifest_digests, 'E092a')
-                            self.check_additional_digests(filepath, known_digests, prior_fixity_digests, 'E093a')
+                            self.check_additional_digests(filepath, known_digests, prior_manifest_digests, "E092a")
+                            self.check_additional_digests(filepath, known_digests, prior_fixity_digests, "E093a")
                         files_seen.discard(filepath)
         # Anything left in files_seen is not mentioned in the inventory
         if len(files_seen) > 0:
-            self.log.error('E023a', where='root', extra_files=', '.join(sorted(files_seen)))
+            self.log.error("E023a", where="root", extra_files=", ".join(sorted(files_seen)))
 
     def check_additional_digests(self, filepath, known_digests, additional_digests, error_code):
         """Check all the additional digests for filepath.
@@ -482,7 +485,7 @@ def check_additional_digests(self, filepath, known_digests, additional_digests,
                     known_digests[digest_algorithm] = content_digest
                 for digest in additional_digests[filepath][digest_algorithm]:
                     if content_digest != normalized_digest(digest, digest_type=digest_algorithm):
-                        where = ','.join(additional_digests[filepath][digest_algorithm][digest])
+                        where = ",".join(additional_digests[filepath][digest_algorithm][digest])
                         self.log.error(error_code, where=where, digest_algorithm=digest_algorithm, digest=digest, content_path=filepath, content_digest=content_digest)
 
     def read_inventory_digest(self, inv_digest_file):
@@ -490,12 +493,12 @@ def read_inventory_digest(self, inv_digest_file):
 
         Raise exception if there is an error, else return digest.
         """
-        with self.obj_fs.open(inv_digest_file, 'r') as fh:
+        with self.obj_fs.open(inv_digest_file, "r") as fh:
             line = fh.readline()
             # we ignore any following lines, could raise exception
-        m = re.match(r'''(\w+)\s+(\S+)\s*$''', line)
+        m = re.match(r"""(\w+)\s+(\S+)\s*$""", line)
         if not m:
             raise Exception("Bad inventory digest file %s, wrong format" % (inv_digest_file))
-        if m.group(2) != 'inventory.json':
+        if m.group(2) != "inventory.json":
             raise Exception("Bad inventory name in inventory digest file %s" % (inv_digest_file))
         return m.group(1)
diff --git a/ocfl/version_metadata.py b/ocfl/version_metadata.py
index 2a480ff..39fe8c3 100644
--- a/ocfl/version_metadata.py
+++ b/ocfl/version_metadata.py
@@ -1,77 +1,101 @@
 """Metadata for a specific version of OCFL Object's content."""
 from .w3c_datetime import datetime_to_str
 
+from .inventory import Inventory
+
 
 class VersionMetadataException(Exception):
     """Exception class for OCFL Object."""
 
 
 class VersionMetadata():
-    """Class for metadata for a specific version of an OCFL Object."""
+    """Class for metadata for a specific version of an OCFL Object.
+
+    Instance variables:
+        id: identifier of object
+        created: string of creation time
+        message: string explaining version creation
+        name: name string for author or agent creating version
+        address: URI for author or agent creating version
+    """
 
-    def __init__(self, *, args=None, inventory=None, version=None,
-                 identifier=None, created=None, message=None, name=None, address=None):
-        """Initialize by various means, including command line arguments from argparse."""
-        self.id = identifier
+    def __init__(self, *,
+                 created=None, message=None, name=None, address=None,
+                 inventory=None, version=None):
+        """Initialize with direct arguments or via an inventory.
+
+        Arguments:
+            created: string of creation time
+            message: string explaining version creation
+            name: name string for author or agent creating version
+            address: URI for author or agent creating version
+            inventory: an Inventory object to initialize from, default None
+            version: the version directory name from which to extract the
+                appropropriate version metadata from the inventory, else None.
+                If not specified then the latest/head version metadata will
+                be take from the inventory
+        """
+        self.id = None
         self.created = created
         self.message = message
         self.name = name
         self.address = address
-        if args is not None:
-            self.created = args.created
-            self.message = args.message
-            self.name = args.name
-            self.address = args.address
-        elif inventory is not None:
+        if inventory is not None:
             self.from_inventory(inventory, version)
 
     def from_inventory(self, inventory, version=None):
-        """Initialize from an inventory object.
-
-        Look for specific version directory version if specified, else
-        return the head version.
+        """Initialize from an inventory dict or Inventory object.
 
         Arguments:
-            inventory - inventory object (dict)
-            version - explicit version name, else taken from inventory head
+            inventory: Inventory object or data dict
+            version: explicit version name to extract metadata from, else
+                extract from the inventory head version
+
+        Look for specific version directory version if specified, else
+        return the head version if none specified. Will extract object id,
+        created, message, and user data. Will not extract the any state
+        information.
         """
-        self.id = inventory.get('id', None)
-        if 'versions' not in inventory:
+        inv = inventory
+        if isinstance(inventory, Inventory):
+            inv = inventory.data
+        self.id = inv.get("id", None)
+        if "versions" not in inv:
             raise VersionMetadataException("No versions object in inventory")
         if version is None:
-            if 'head' not in inventory:
+            if "head" not in inv:
                 raise VersionMetadataException("No head version specified in inventory")
-            version = inventory['head']
+            version = inv["head"]
         # Now find version metadata
-        if version not in inventory['versions']:
+        if version not in inv["versions"]:
             raise VersionMetadataException("No version block for %s in inventory")
-        inv_version = inventory['versions'][version]
+        inv_version = inv["versions"][version]
         self.version = version
-        if 'created' in inv_version:
-            self.created = inv_version['created']
-        if 'message' in inv_version:
-            self.message = inv_version['message']
-        if 'user' in inv_version:
-            if 'name' in inv_version['user']:
-                self.name = inv_version['user']['name']
-            if 'address' in inv_version['user']:
-                self.address = inv_version['user']['address']
+        if "created" in inv_version:
+            self.created = inv_version["created"]
+        if "message" in inv_version:
+            self.message = inv_version["message"]
+        if "user" in inv_version:
+            if "name" in inv_version["user"]:
+                self.name = inv_version["user"]["name"]
+            if "address" in inv_version["user"]:
+                self.address = inv_version["user"]["address"]
 
-    def as_dict(self, **kwargs):
-        """Return dictionary object with version metedata."""
-        m = {}
-        self.add_to_dict(m, **kwargs)
-        return m
+    def as_dict(self):
+        """Return dictionary object with version metedata.
 
-    def add_to_dict(self, m, **kwargs):
-        """Add metadata to dictionary m."""
-        m['created'] = self.created if self.created else datetime_to_str()
+        Returns dict() created according to the inventory structure for
+        information about a single version. If created is not set then will
+        add the current datatime string.
+        """
+        m = {}
+        m["created"] = self.created if self.created else datetime_to_str()
         if self.message is not None:
-            m['message'] = self.message
+            m["message"] = self.message
         if self.name is not None or self.address is not None:
-            m['user'] = {'name': self.name}
+            m["user"] = {}
+            if self.name is not None:
+                m["user"]["name"] = self.name
             if self.address is not None:
-                m['user']['address'] = self.address
-        # Add any extra values, and they will override instance variables
-        for (key, value) in kwargs.items():
-            m[key] = value
+                m["user"]["address"] = self.address
+        return m
diff --git a/ocfl/w3c_datetime.py b/ocfl/w3c_datetime.py
index e9cf5f0..93b20d6 100644
--- a/ocfl/w3c_datetime.py
+++ b/ocfl/w3c_datetime.py
@@ -14,7 +14,7 @@
 from dateutil import parser as dateutil_parser
 
 
-def datetime_to_str(dt='now', no_fractions=False):
+def datetime_to_str(dt="now", no_fractions=False):
     """Return Last-Modified data in ISO8601 syntax, Z notation.
 
     The lastmod is stored as unix timestamp which is already
@@ -29,16 +29,16 @@ def datetime_to_str(dt='now', no_fractions=False):
     """
     if dt is None:
         return None
-    if dt == 'now':
+    if dt == "now":
         dt = time.time()
     if no_fractions:
         dt = int(dt)
     else:
         dt += 0.0000001  # improve rounding to microseconds
-    return datetime.utcfromtimestamp(dt).isoformat() + 'Z'
+    return datetime.utcfromtimestamp(dt).isoformat() + "Z"
 
 
-def str_to_datetime(s, context='datetime'):
+def str_to_datetime(s, context="datetime"):
     """Set timestamp from an W3C Datetime Last-Modified value.
 
     The sitemaps.org specification says that <lastmod> values
@@ -77,16 +77,16 @@ def str_to_datetime(s, context='datetime'):
     t = None
     if s is None:
         return t
-    if s == '':
-        raise ValueError('Attempt to set empty %s' % (context))
+    if s == "":
+        raise ValueError("Attempt to set empty %s" % (context))
     # Make a date into a full datetime
     m = re.match(r"\d\d\d\d(\-\d\d(\-\d\d)?)?$", s)
     if m is not None:
         if m.group(1) is None:
-            s += '-01-01'
+            s += "-01-01"
         elif m.group(2) is None:
-            s += '-01'
-        s += 'T00:00:00Z'
+            s += "-01"
+        s += "T00:00:00Z"
     # Now have datetime with timezone info
     m = re.match(r"(.*\d{2}:\d{2}:\d{2})(\.\d+)([^\d].*)?$", s)
     # Chop out fractional seconds if present
@@ -107,15 +107,15 @@ def str_to_datetime(s, context='datetime'):
                  r"(\d\d):(\d\d))?$", s)
     if m is None:
         raise ValueError("Bad datetime format (%s)" % s)
-    dt = dateutil_parser.parse(m.group(1) + 'Z')
+    dt = dateutil_parser.parse(m.group(1) + "Z")
     offset_seconds = 0
-    if m.group(3) and m.group(3) != 'Z':
+    if m.group(3) and m.group(3) != "Z":
         hh = int(m.group(5))
         mm = int(m.group(6))
         if hh > 23 or mm > 59:
             raise ValueError("Bad timezone offset (%s)" % s)
         offset_seconds = hh * 3600 + mm * 60
-        if m.group(4) == '-':
+        if m.group(4) == "-":
             offset_seconds = -offset_seconds
     # timetuple() ignores timezone information so we have to add in
     # the offset here, and any fractional component of the seconds
diff --git a/setup.py b/setup.py
index 5f261f6..e896d5a 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@
 
 # Extract version number
 verfile = Path("ocfl/_version.py").read_text(encoding="utf-8")
-match = re.search(r"^__version__ = '(\d\.\d.\d+(\.\d+)?)'",
+match = re.search(r'''^__version__ = "(\d\.\d.\d+(\.\d+)?)"''',
                   verfile, re.MULTILINE)
 if match:
     version = match.group(1)


Test patch:
diff --git a/tests/test_bagger.py b/tests/test_bagger.py
index 2b8a643..d6bcee8 100644
--- a/tests/test_bagger.py
+++ b/tests/test_bagger.py
@@ -15,19 +15,19 @@ class TestAll(unittest.TestCase):
     def test_bag_as_source(self):
         """Test bag_as_source method."""
         metadata = VersionMetadata()
-        self.assertRaises(bagit.BagError, bag_as_source, 'tests/testdata/files/hello_out_there.txt', metadata)
-        self.assertRaises(BaggerError, bag_as_source, 'tests/testdata/bags/invalid_bag', metadata)
+        self.assertRaises(bagit.BagError, bag_as_source, "tests/testdata/files/hello_out_there.txt", metadata)
+        self.assertRaises(BaggerError, bag_as_source, "tests/testdata/bags/invalid_bag", metadata)
         # Check metadata update
         metadata = VersionMetadata()
-        bag_as_source('tests/testdata/bags/uaa_v1', metadata)
-        self.assertEqual(metadata.created, '2020-01-01T00:00:00Z')
-        self.assertEqual(metadata.address, 'mailto:all_seeing_spheres@miskatonic.edu')
-        self.assertEqual(metadata.name, 'Yog-Sothoth')
-        self.assertEqual(metadata.message, 'First version')
-        self.assertEqual(metadata.id, 'info:bb123cd4567')
+        bag_as_source("tests/testdata/bags/uaa_v1", metadata)
+        self.assertEqual(metadata.created, "2020-01-01T00:00:00Z")
+        self.assertEqual(metadata.address, "mailto:all_seeing_spheres@miskatonic.edu")
+        self.assertEqual(metadata.name, "Yog-Sothoth")
+        self.assertEqual(metadata.message, "First version")
+        self.assertEqual(metadata.id, "info:bb123cd4567")
         # Check metedata still None when not in bag
         metadata = VersionMetadata()
-        bag_as_source('tests/testdata/bags/bag_no_metadata', metadata)
+        bag_as_source("tests/testdata/bags/bag_no_metadata", metadata)
         self.assertEqual(metadata.created, None)
         self.assertEqual(metadata.address, None)
         self.assertEqual(metadata.name, None)
@@ -37,32 +37,32 @@ def test_bag_as_source(self):
     def test_bag_extracted_version(self):
         """Test bag_extracted_version method."""
         # Write bag with no metadata
-        tempdir = tempfile.mkdtemp(prefix='test_bag1')
-        with open(os.path.join(tempdir, 'my_file'), 'w', encoding="utf-8") as fh:
+        tempdir = tempfile.mkdtemp(prefix="test_bag1")
+        with open(os.path.join(tempdir, "my_file"), "w", encoding="utf-8") as fh:
             fh.write("Something\n")
         metadata = VersionMetadata()
         bag_extracted_version(tempdir, metadata)
-        with open(os.path.join(tempdir, 'bag-info.txt'), 'r', encoding="utf-8") as fh:
+        with open(os.path.join(tempdir, "bag-info.txt"), "r", encoding="utf-8") as fh:
             info = fh.read()
-        self.assertNotIn('Contact-Email', info)
-        self.assertNotIn('Contact-Name', info)
-        self.assertNotIn('External-Description', info)
-        self.assertNotIn('External-Identifier', info)
-        self.assertIn('Payload-Oxum', info)
+        self.assertNotIn("Contact-Email", info)
+        self.assertNotIn("Contact-Name", info)
+        self.assertNotIn("External-Description", info)
+        self.assertNotIn("External-Identifier", info)
+        self.assertIn("Payload-Oxum", info)
         # Write bag with all metadata
-        tempdir = tempfile.mkdtemp(prefix='test_bag2')
-        with open(os.path.join(tempdir, 'my_file2'), 'w', encoding="utf-8") as fh:
+        tempdir = tempfile.mkdtemp(prefix="test_bag2")
+        with open(os.path.join(tempdir, "my_file2"), "w", encoding="utf-8") as fh:
             fh.write("Something else\n")
         metadata = VersionMetadata()
         metadata.message = "hello"
         metadata.name = "A Person"
         metadata.address = "mailto:a.person@example.org"
-        metadata.id = 'info:a-bag-2'
+        metadata.id = "info:a-bag-2"
         bag_extracted_version(tempdir, metadata)
-        with open(os.path.join(tempdir, 'bag-info.txt'), 'r', encoding="utf-8") as fh:
+        with open(os.path.join(tempdir, "bag-info.txt"), "r", encoding="utf-8") as fh:
             info = fh.read()
-        self.assertIn('Contact-Email: a.person@example.org', info)
-        self.assertIn('Contact-Name: A Person', info)
-        self.assertIn('External-Description: hello', info)
-        self.assertIn('External-Identifier: info:a-bag-2', info)
-        self.assertIn('Payload-Oxum', info)
+        self.assertIn("Contact-Email: a.person@example.org", info)
+        self.assertIn("Contact-Name: A Person", info)
+        self.assertIn("External-Description: hello", info)
+        self.assertIn("External-Identifier: info:a-bag-2", info)
+        self.assertIn("Payload-Oxum", info)
diff --git a/tests/test_command_line_utils.py b/tests/test_command_line_utils.py
index 364fda7..900a7f6 100644
--- a/tests/test_command_line_utils.py
+++ b/tests/test_command_line_utils.py
@@ -11,39 +11,39 @@ class TestAll(unittest.TestCase):
 
     def test_add_version_metadata_args(self):
         """Test addition of argparse args for version metadata."""
-        p = argparse.ArgumentParser(description='Hello!')
+        p = argparse.ArgumentParser(description="Hello!")
         add_version_metadata_args(p)
-        args = p.parse_args(['--created=a', '--message=b', '--name=c', '--address=d'])
-        self.assertEqual(args.created, 'a')
-        self.assertEqual(args.message, 'b')
-        self.assertEqual(args.name, 'c')
-        self.assertEqual(args.address, 'd')
+        args = p.parse_args(["--created=a", "--message=b", "--name=c", "--address=d"])
+        self.assertEqual(args.created, "a")
+        self.assertEqual(args.message, "b")
+        self.assertEqual(args.name, "c")
+        self.assertEqual(args.address, "d")
 
     def test_add_object_args(self):
         """Test (kinda) adding object args."""
         parser = argparse.ArgumentParser()
         add_object_args(parser)
-        args = parser.parse_args(['--skip', 'aa'])
-        self.assertIn('aa', args.skip)
+        args = parser.parse_args(["--skip", "aa"])
+        self.assertIn("aa", args.skip)
 
     def test_add_version_arg(self):
         """Test (kinda) adding version arg."""
         parser = argparse.ArgumentParser()
         add_version_arg(parser)
-        args = parser.parse_args(['--version'])
+        args = parser.parse_args(["--version"])
         self.assertTrue(args.version)
 
     def test_check_version_arg(self):
         """Test check of version arg which prints version and exits."""
         parser = argparse.ArgumentParser()
         add_version_arg(parser)
-        self.assertRaises(SystemExit, check_version_arg, parser.parse_args(['--version']))
+        self.assertRaises(SystemExit, check_version_arg, parser.parse_args(["--version"]))
 
     def test_add_verbosity_args(self):
         """Test (kinda) adding shared args."""
         parser = argparse.ArgumentParser()
         add_verbosity_args(parser)
-        args = parser.parse_args(['--debug'])
+        args = parser.parse_args(["--debug"])
         self.assertTrue(args.debug)
         self.assertFalse(args.verbose)
         self.assertFalse(args.quiet)
@@ -52,5 +52,5 @@ def test_check_verbosity_args(self):
         """Test check of shared args."""
         parser = argparse.ArgumentParser()
         add_verbosity_args(parser)
-        check_verbosity_args(parser.parse_args(['-v']))
+        check_verbosity_args(parser.parse_args(["-v"]))
         self.assertEqual(logging.getLogger().getEffectiveLevel(), logging.WARN)
diff --git a/tests/test_demo_build_spec_v1_0_examples.py b/tests/test_demo_build_spec_v1_0_examples.py
index f765107..1335564 100644
--- a/tests/test_demo_build_spec_v1_0_examples.py
+++ b/tests/test_demo_build_spec_v1_0_examples.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-"""Tests/demo building spec examples."""
+"""Demonstrate building 1.0 specification examples."""
 from testlib import DemoTestCase
 
 
@@ -12,7 +12,7 @@ def test00_minimal_example(self):
                "stripped out and replaced with an ellipsis. This is inventory should " \
                "match the example in <https://ocfl.io/1.0/spec/#example-minimal-object>."
         out = self.run_script("Minimal example",
-                              ["python", "ocfl-object.py", "build",
+                              ["python", "ocfl-object.py", "create",
                                "--src", "fixtures/1.0/content/spec-ex-minimal",
                                "--id", "http://example.org/minimal",
                                "--spec-version", "1.0",
@@ -37,6 +37,7 @@ def test01_versioned_example(self):
                                "--fixity", "md5",
                                "--fixity", "sha1",
                                "--digest", "sha512-spec-ex",
+                               "--metadata", "extra_fixtures/1.0/content/spec-ex-full-metadata.json",
                                "-v"],
                               text=text)
         self.assertIn("4d27c86b026ff70...b53", out)
@@ -47,8 +48,8 @@ def test02_different_paths_example(self):
                "paths may differ from logical paths in " \
                "<https://ocfl.io/1.0/spec/#example-object-diff-paths>."
         out = self.run_script("Versioned example",
-                              ["python", "ocfl-object.py", "build",
-                               "--src", "fixtures/1.0/content/spec-ex-diff-paths",
+                              ["python", "ocfl-object.py", "create",
+                               "--src", "fixtures/1.0/content/spec-ex-diff-paths/v1",
                                "--id", "http://example.org/diff-paths",
                                "--spec-version", "1.0",
                                "--digest", "sha512-spec-ex",
@@ -59,6 +60,7 @@ def test02_different_paths_example(self):
         # Content paths
         self.assertIn("v1/content/3bacb119a98a15c5", out)
         self.assertIn("v1/content/9f2bab8ef869947d", out)
+        self.assertIn('"created": "2019-03-14T20:31:00Z"', out)
 
 
 if __name__ == "__main__":
diff --git a/tests/test_demo_build_spec_v1_1_examples.py b/tests/test_demo_build_spec_v1_1_examples.py
index 9aa77f0..86d8834 100644
--- a/tests/test_demo_build_spec_v1_1_examples.py
+++ b/tests/test_demo_build_spec_v1_1_examples.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-"""Tests/demo building spec examples."""
+"""Demonstrate building 1.1 specification examples."""
 from testlib import DemoTestCase
 
 
@@ -12,7 +12,7 @@ def test00_minimal_example(self):
                "stripped out and replaced with an ellipsis. This is inventory should " \
                "match the example in <https://ocfl.io/1.1/spec/#example-minimal-object>."
         out = self.run_script("Minimal example",
-                              ["python", "ocfl-object.py", "build",
+                              ["python", "ocfl-object.py", "create",
                                "--src", "fixtures/1.1/content/spec-ex-minimal",
                                "--id", "http://example.org/minimal",
                                "--spec-version", "1.1",
@@ -37,6 +37,7 @@ def test01_versioned_example(self):
                                "--fixity", "md5",
                                "--fixity", "sha1",
                                "--digest", "sha512-spec-ex",
+                               "--metadata", "extra_fixtures/1.1/content/spec-ex-full-metadata.json",
                                "-v"],
                               text=text)
         self.assertIn("4d27c86b026ff70...b53", out)
@@ -47,9 +48,9 @@ def test02_different_paths_example(self):
                "paths may differ from logical paths in " \
                "<https://ocfl.io/1.1/spec/#example-object-diff-paths>."
         out = self.run_script("Versioned example",
-                              ["python", "ocfl-object.py", "build",
-                               "--src", "fixtures/1.1/content/spec-ex-diff-paths",
-                               "--id", "http://example.org/diff-paths",
+                              ["python", "ocfl-object.py", "create",
+                               "--src", "fixtures/1.1/content/spec-ex-diff-paths/v1",
+                               "--id", "http://example.org/diff-paths/v1",
                                "--spec-version", "1.1",
                                "--digest", "sha512-spec-ex",
                                "--normalization", "md5",
@@ -59,6 +60,7 @@ def test02_different_paths_example(self):
         # Content paths
         self.assertIn("v1/content/3bacb119a98a15c5", out)
         self.assertIn("v1/content/9f2bab8ef869947d", out)
+        self.assertIn('"created": "2019-03-14T20:31:00Z"', out)
 
 
 if __name__ == "__main__":
diff --git a/tests/test_demo_ocfl_object_script.py b/tests/test_demo_ocfl_object_script.py
index aecf9f6..380815a 100644
--- a/tests/test_demo_ocfl_object_script.py
+++ b/tests/test_demo_ocfl_object_script.py
@@ -20,14 +20,16 @@ def test01_create_inventory_dryrun(self):
         out = self.run_script("Inventory for new object with just v1",
                               ["python", "ocfl-object.py", "create",
                                "--id", "http://example.org/obj1",
-                               "--src", "fixtures/1.0/content/cf1/v1"],
+                               "--src", "fixtures/1.0/content/cf1/v1",
+                               "--created", "2024-10-24T18:30:01Z"],
                               text="Without an `--objdir` argument the script just writes out the inventory for the object that would have been created.")
         self.assertIn('"id": "http://example.org/obj1"', out)
         self.assertIn("### Inventory for v1", out)
         out = self.run_script("Inventory for new object with three versions",
                               ["python", "ocfl-object.py", "build",
                                "--id", "http://example.org/obj2",
-                               "--src", "fixtures/1.0/content/cf3"],
+                               "--src", "fixtures/1.0/content/cf3",
+                               "--metadata", "extra_fixtures/1.0/content/spec-ex-full-metadata.json"],
                               text="Without an `--objdir` argument the script just writes out the inventory for each version in the object that would have been created.")
         self.assertIn('"id": "http://example.org/obj2"', out)
         self.assertIn("### Inventory for v3", out)
@@ -39,6 +41,7 @@ def test02_create_v1(self):
                                "--id", "http://example.org/obj1",
                                "--src", "fixtures/1.0/content/cf1/v1",
                                "--objdir", "TMPDIR/obj1",
+                               "--created", "2024-10-24T18:30:03Z",
                                "-v"])
         self.assertIn("Created OCFL object http://example.org/obj1", out)
 
@@ -62,9 +65,9 @@ def test04_extract(self):
                                "--dstdir", "TMPDIR/v1",
                                "-v"],
                               text="Version 1 object with location specified in `--objdir` and the first version specified in `--objver`, extract into TMPDIR/v1:")
-        self.assertIn('Extracted content for v1 in', out)
+        self.assertIn("Extracted content for v1 in", out)
         out = self.run_script(None,
-                              ["find", "TMPDIR/v1", "-print"],
+                              ["find", "-s", "TMPDIR/v1", "-print"],
                               text="and the extracted files are:")
         self.assertEqual(os.path.getsize(os.path.join(self.tmpdir, "v1/empty.txt")), 0)
         self.assertFalse(os.path.exists(os.path.join(self.tmpdir, "v1/empty2.txt")))
@@ -78,25 +81,25 @@ def test04_extract(self):
                                "--objdir", "fixtures/1.1/good-objects/spec-ex-full",
                                "--dstdir", "TMPDIR/v2",
                                "-v"])
-        self.assertIn('Extracted content for v2 in', out)
+        self.assertIn("Extracted content for v2 in", out)
         out = self.run_script(None,
-                              ["find", "TMPDIR/v2", "-print"],
+                              ["find", "-s", "TMPDIR/v2", "-print"],
                               text="and the extracted files are:")
         self.assertEqual(os.path.getsize(os.path.join(self.tmpdir, "v2/empty.txt")), 0)
         self.assertEqual(os.path.getsize(os.path.join(self.tmpdir, "v2/empty2.txt")), 0)
         self.assertEqual(os.path.getsize(os.path.join(self.tmpdir, "v2/foo/bar.xml")), 272)
         self.assertFalse(os.path.exists(os.path.join(self.tmpdir, "v2/image.tiff")))
         #
-        # And 'head' should extract v3
+        # And "head" should extract v3
         out = self.run_script("Extract head version (v3) of content in the same OCFL v1.1 object",
                               ["python", "ocfl-object.py", "extract",
                                "--objver", "head",
                                "--objdir", "fixtures/1.1/good-objects/spec-ex-full",
                                "--dstdir", "TMPDIR/head",
                                "-v"])
-        self.assertIn('Extracted content for v3 in', out)
+        self.assertIn("Extracted content for v3 in", out)
         out = self.run_script(None,
-                              ["find", "TMPDIR/v3", "-print"],
+                              ["find", "-s", "TMPDIR/v3", "-print"],
                               text="and the extracted files are:")
         self.assertEqual(os.path.getsize(os.path.join(self.tmpdir, "v2/empty.txt")), 0)
         self.assertEqual(os.path.getsize(os.path.join(self.tmpdir, "v2/empty2.txt")), 0)
@@ -111,9 +114,9 @@ def test04_extract(self):
                                "--logical-path", "foo/bar.xml",
                                "--dstdir", "TMPDIR/files",
                                "-v"])
-        self.assertIn('Extracted foo/bar.xml in v3', out)
+        self.assertIn("Extracted foo/bar.xml in v3", out)
         out = self.run_script(None,
-                              ["find", "TMPDIR/files", "-print"],
+                              ["find", "-s", "TMPDIR/files", "-print"],
                               text="and the extracted file is:")
         self.assertEqual(os.path.getsize(os.path.join(self.tmpdir, "files/bar.xml")), 272)
         # Extract individual file into dir that exists
@@ -123,9 +126,9 @@ def test04_extract(self):
                                "--logical-path", "image.tiff",
                                "--dstdir", "TMPDIR/files",
                                "-v"])
-        self.assertIn('Extracted image.tiff in v3', out)
+        self.assertIn("Extracted image.tiff in v3", out)
         out = self.run_script(None,
-                              ["find", "TMPDIR/files", "-print"],
+                              ["find", "-s", "TMPDIR/files", "-print"],
                               text="and the directory now contains two extracted files:")
         self.assertEqual(os.path.getsize(os.path.join(self.tmpdir, "files/image.tiff")), 2021)
 
diff --git a/tests/test_demo_using_bagit_bags.py b/tests/test_demo_using_bagit_bags.py
index 39b0529..2e8a358 100644
--- a/tests/test_demo_using_bagit_bags.py
+++ b/tests/test_demo_using_bagit_bags.py
@@ -21,7 +21,7 @@ def test00_build_from_bags(self):
                               text=text)
         self.assertIn("Created OCFL object info:bb123cd4567 in", out)
         out = self.run_script("Check new object is valid",
-                              ["python", 'ocfl-validate.py',
+                              ["python", "ocfl-validate.py",
                                "TMPDIR/obj"],
                               text="Now that we have the object it is of course valid.")
         self.assertIn("/obj is VALID", out)
diff --git a/tests/test_digest.py b/tests/test_digest.py
index fa871b0..d6fb9a2 100644
--- a/tests/test_digest.py
+++ b/tests/test_digest.py
@@ -12,64 +12,64 @@ class TestAll(unittest.TestCase):
 
     def test_file_digest__empty(self):
         """Test file_digest method with empty file."""
-        self.assertEqual(file_digest(filename='tests/testdata/files/empty', digest_type='md5'),
-                         'd41d8cd98f00b204e9800998ecf8427e')
-        self.assertEqual(file_digest('tests/testdata/files/empty', 'sha1'),
-                         'da39a3ee5e6b4b0d3255bfef95601890afd80709')
-        self.assertEqual(file_digest('tests/testdata/files/empty', 'sha256'),
-                         'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
-        self.assertEqual(file_digest('tests/testdata/files/empty', 'sha512'),
-                         'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e')
-        self.assertEqual(file_digest('tests/testdata/files/empty'),
-                         'cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e')
-        if sys.version_info > (3, 4):  # Old hashlib doesn't have blake2b
-            self.assertEqual(file_digest('tests/testdata/files/empty', 'blake2b-512'),
-                             '786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce')
-            self.assertEqual(file_digest('tests/testdata/files/empty', 'blake2b-384'),
-                             'b32811423377f52d7862286ee1a72ee540524380fda1724a6f25d7978c6fd3244a6caf0498812673c5e05ef583825100')
-            self.assertEqual(file_digest('tests/testdata/files/empty', 'blake2b-256'),
-                             '0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8')
-            self.assertEqual(file_digest('tests/testdata/files/empty', 'blake2b-160'),
-                             '3345524abf6bbe1809449224b5972c41790b6cf2')
-        self.assertEqual(file_digest('tests/testdata/files/empty', 'sha512-spec-ex'),
-                         'cf83e1357eefb8b...a3e')
-        self.assertEqual(file_digest('tests/testdata/files/empty', 'sha256-spec-ex'),
-                         'e3b0c4...855')
-        self.assertRaises(Exception, file_digest, 'tests/testdata/files/empty', 'bad-digest-type')
+        self.assertEqual(file_digest(filename="tests/testdata/files/empty", digest_type="md5"),
+                         "d41d8cd98f00b204e9800998ecf8427e")
+        self.assertEqual(file_digest("tests/testdata/files/empty", "sha1"),
+                         "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+        self.assertEqual(file_digest("tests/testdata/files/empty", "sha256"),
+                         "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
+        self.assertEqual(file_digest("tests/testdata/files/empty", "sha512"),
+                         "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")
+        self.assertEqual(file_digest("tests/testdata/files/empty"),
+                         "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")
+        if sys.version_info > (3, 4):  # Old hashlib doesn"t have blake2b
+            self.assertEqual(file_digest("tests/testdata/files/empty", "blake2b-512"),
+                             "786a02f742015903c6c6fd852552d272912f4740e15847618a86e217f71f5419d25e1031afee585313896444934eb04b903a685b1448b755d56f701afe9be2ce")
+            self.assertEqual(file_digest("tests/testdata/files/empty", "blake2b-384"),
+                             "b32811423377f52d7862286ee1a72ee540524380fda1724a6f25d7978c6fd3244a6caf0498812673c5e05ef583825100")
+            self.assertEqual(file_digest("tests/testdata/files/empty", "blake2b-256"),
+                             "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8")
+            self.assertEqual(file_digest("tests/testdata/files/empty", "blake2b-160"),
+                             "3345524abf6bbe1809449224b5972c41790b6cf2")
+        self.assertEqual(file_digest("tests/testdata/files/empty", "sha512-spec-ex"),
+                         "cf83e1357eefb8b...a3e")
+        self.assertEqual(file_digest("tests/testdata/files/empty", "sha256-spec-ex"),
+                         "e3b0c4...855")
+        self.assertRaises(Exception, file_digest, "tests/testdata/files/empty", "bad-digest-type")
 
     def test_file_digest__long(self):
         """Test file_digest method with content."""
-        self.assertEqual(file_digest('tests/testdata/files/hello_out_there.txt', 'md5'),
-                         '9c7ec1389a61f1e15185bd976672bc63')
+        self.assertEqual(file_digest("tests/testdata/files/hello_out_there.txt", "md5"),
+                         "9c7ec1389a61f1e15185bd976672bc63")
 
     def test_file_digest__pyfs(self):
         """Test file_digest method with content."""
-        td_fs = fs.open_fs('tests/testdata')
-        self.assertEqual(file_digest('files/hello_out_there.txt', 'md5', pyfs=td_fs),
-                         '9c7ec1389a61f1e15185bd976672bc63')
+        td_fs = fs.open_fs("tests/testdata")
+        self.assertEqual(file_digest("files/hello_out_there.txt", "md5", pyfs=td_fs),
+                         "9c7ec1389a61f1e15185bd976672bc63")
 
     def test_string_digest(self):
         """Test string_digest method."""
-        self.assertEqual(string_digest(txt='Sunny San Rafael\n', digest_type='md5'),
-                         '0fa187d02e87902418af02e9a91c7603')
-        self.assertEqual(string_digest('Sunny San Rafael\n', 'sha1'),
-                         '14ca9011850fe349fb29e056a1fcfe018e035b44')
-        self.assertEqual(string_digest('Sunny San Rafael\n', 'sha256'),
-                         '50e8300695614f1298ceeac4a6a456ef875c4fac12dfd873736d8247dd4354b4')
-        self.assertEqual(string_digest('Sunny San Rafael\n', 'sha512'),
-                         '660473b7045af0ede6955236567893aef0dc5907252cc1c2d93b2a419545ea2ca599847995af50162f5f66f1b5231552b64b0a976b3b7d9433153539605093db')
-        self.assertEqual(string_digest('Sunny San Rafael\n'),
-                         '660473b7045af0ede6955236567893aef0dc5907252cc1c2d93b2a419545ea2ca599847995af50162f5f66f1b5231552b64b0a976b3b7d9433153539605093db')
-        self.assertRaises(ValueError, string_digest, 'Any string', 'unknown_digest')
+        self.assertEqual(string_digest(txt="Sunny San Rafael\n", digest_type="md5"),
+                         "0fa187d02e87902418af02e9a91c7603")
+        self.assertEqual(string_digest("Sunny San Rafael\n", "sha1"),
+                         "14ca9011850fe349fb29e056a1fcfe018e035b44")
+        self.assertEqual(string_digest("Sunny San Rafael\n", "sha256"),
+                         "50e8300695614f1298ceeac4a6a456ef875c4fac12dfd873736d8247dd4354b4")
+        self.assertEqual(string_digest("Sunny San Rafael\n", "sha512"),
+                         "660473b7045af0ede6955236567893aef0dc5907252cc1c2d93b2a419545ea2ca599847995af50162f5f66f1b5231552b64b0a976b3b7d9433153539605093db")
+        self.assertEqual(string_digest("Sunny San Rafael\n"),
+                         "660473b7045af0ede6955236567893aef0dc5907252cc1c2d93b2a419545ea2ca599847995af50162f5f66f1b5231552b64b0a976b3b7d9433153539605093db")
+        self.assertRaises(ValueError, string_digest, "Any string", "unknown_digest")
 
     def test_digest_regex(self):
         """Test digest regex."""
-        self.assertEqual(digest_regex('md5'), r'''^[0-9a-fA-F]{32}$''')
-        self.assertRaises(ValueError, digest_regex, 'unknown_digest')
+        self.assertEqual(digest_regex("md5"), r"""^[0-9a-fA-F]{32}$""")
+        self.assertRaises(ValueError, digest_regex, "unknown_digest")
 
     def test_normalized_digest(self):
         """Test normalized_digest."""
-        self.assertEqual(normalized_digest('DA39a3ee5e6b4b0d3255BFEf95601890afd80709'), 'da39a3ee5e6b4b0d3255bfef95601890afd80709')
-        self.assertEqual(normalized_digest('DA39a3ee5e6b4b0d3255BFEf95601890afd80709', 'sha1'), 'da39a3ee5e6b4b0d3255bfef95601890afd80709')
+        self.assertEqual(normalized_digest("DA39a3ee5e6b4b0d3255BFEf95601890afd80709"), "da39a3ee5e6b4b0d3255bfef95601890afd80709")
+        self.assertEqual(normalized_digest("DA39a3ee5e6b4b0d3255BFEf95601890afd80709", "sha1"), "da39a3ee5e6b4b0d3255bfef95601890afd80709")
         # The following not changed
-        self.assertEqual(normalized_digest('E3b0c4...855', 'sha256-spec-ex'), 'E3b0c4...855')
+        self.assertEqual(normalized_digest("E3b0c4...855", "sha256-spec-ex"), "E3b0c4...855")
diff --git a/tests/test_inventory.py b/tests/test_inventory.py
new file mode 100644
index 0000000..6df1f8b
--- /dev/null
+++ b/tests/test_inventory.py
@@ -0,0 +1,252 @@
+"""Inventory and Version tests."""
+import unittest
+
+from ocfl.inventory import InventoryException, Inventory, Version
+from ocfl.pyfs import pyfs_openfs
+
+
+class TestInventory(unittest.TestCase):
+    """TestInventory class to run test for the Invenory class."""
+
+    def test_init(self):
+        """Test initialization."""
+        inv = Inventory()
+        self.assertEqual(inv.data, {})
+        inv = Inventory(data={"id": "uri:a"})
+        self.assertEqual(inv.data, {"id": "uri:a"})
+        inv = Inventory(filepath="fixtures/1.0/good-objects/spec-ex-full/inventory.json")
+        self.assertEqual(inv.id, "ark:/12345/bcd987")
+        self.assertEqual(inv.spec_version, "1.0")
+        inv = Inventory(filepath="fixtures/1.0/good-objects/spec-ex-full/inventory.json")
+        self.assertEqual(inv.id, "ark:/12345/bcd987")
+        self.assertEqual(inv.spec_version, "1.0")
+        inv = Inventory(filepath="fixtures/1.0/good-objects/spec-ex-full/inventory.json")
+        self.assertEqual(inv.id, "ark:/12345/bcd987")
+        self.assertEqual(inv.spec_version, "1.0")
+        pyfs = pyfs_openfs("zip://extra_fixtures/1.0/good-objects/ten_level_deep_directories.zip")
+        inv = Inventory(pyfs=pyfs, filepath="v1/inventory.json")
+        self.assertEqual(inv.id, "http://example.org/ten_level_deep_directories")
+        # Check deep copy of inventory
+        inv1 = Inventory(filepath="fixtures/1.0/good-objects/spec-ex-full/inventory.json")
+        inv2 = Inventory(inv1)
+        inv1.id = "zap"
+        self.assertEqual(inv1.id, "zap")
+        self.assertEqual(inv2.id, "ark:/12345/bcd987")
+        inv1.version("v3").message = "no message"
+        self.assertEqual(inv1.version("v3").message, "no message")
+        self.assertEqual(inv2.version("v3").message, "Reinstate image.tiff, delete empty.txt")
+        # Init from data
+        inv = Inventory({"b": "bad"})
+        self.assertEqual(inv.data, {"b": "bad"})
+        inv = Inventory(data={"a": "still bad"})
+        self.assertEqual(inv.data, {"a": "still bad"})
+        # Exception
+        self.assertRaises(InventoryException, Inventory, data=[])
+
+    def test_spec_version_getter(self):
+        """Test spec_version getter."""
+        inv = Inventory()
+        self.assertEqual(inv.spec_version, None)
+        inv = Inventory({"type": "https://ocfl.io/1.1/spec/#inventory"})
+        self.assertEqual(inv.spec_version, "1.1")
+        inv = Inventory({"type": "https://ocfl.io/BAD/spec/#inventory"})
+        self.assertEqual(inv.spec_version, None)
+
+    def test_fixity(self):
+        """Test fixity get and set."""
+        inv = Inventory()
+        self.assertEqual(inv.fixity, {})
+        inv.fixity = {'md5': {"184f84e28cbe75e050e9c25ea7f2e939":
+                              ["v1/content/foo/bar.xml"]}}
+        self.assertEqual(list(inv.fixity.keys()), ["md5"])
+
+    def test_content(self):
+        """Test content property."""
+        inv = Inventory(filepath="fixtures/1.1/good-objects/spec-ex-full/inventory.json")
+        self.assertEqual(set(inv.content.keys()),
+                         set(["v2/content/foo/bar.xml",
+                              "v1/content/image.tiff",
+                              "v1/content/empty.txt",
+                              "v1/content/foo/bar.xml"]))
+        self.assertEqual(inv.content["v1/content/empty.txt"][0:16], "cf83e1357eefb8bd")
+
+    def test_content_path_for_digest(self):
+        """Test content_path_for_digest."""
+        inv = Inventory()
+        inv.manifest = {"qwerty": ["f1"],
+                        "asdfgh": ["f2", "f3"]}
+        self.assertEqual(inv.content_path_for_digest("qwerty"), "f1")
+        self.assertEqual(inv.content_path_for_digest("asdfgh"), "f2")
+        self.assertEqual(inv.content_path_for_digest("not-there"), None)
+
+    def test_next_version_directory(self):
+        """Test _next_version_directory method."""
+        inv = Inventory()
+        self.assertEqual(inv._next_version_directory(), "v1")  # pylint: disable=protected-access
+        self.assertEqual(inv._next_version_directory(5), "v00001")  # pylint: disable=protected-access
+        inv.data["versions"] = {"v1": {}}
+        self.assertEqual(inv._next_version_directory(), "v2")  # pylint: disable=protected-access
+        inv.data["versions"] = {"v0009": {}}
+        self.assertEqual(inv._next_version_directory(), "v0010")  # pylint: disable=protected-access
+
+    def test_add_version(self):
+        """Test add_version method."""
+        # Simple sequence
+        inv = Inventory()
+        v = inv.add_version(metadata={"message": "hello"})
+        self.assertEqual(v.vdir, "v1")
+        self.assertEqual(v.message, "hello")
+        self.assertEqual(inv.head, "v1")
+        v = inv.add_version(state={"digest": ["path"]})
+        self.assertEqual(v.vdir, "v2")
+        self.assertEqual(v.state["digest"], ["path"])
+        self.assertEqual(inv.head, "v2")
+        v = inv.add_version(zero_padded_width=5)  # has no effect
+        self.assertEqual(v.vdir, "v3")
+        self.assertEqual(v.message, None)
+        self.assertEqual(inv.head, "v3")
+        # Simple sequence with zero
+        inv = Inventory()
+        v = inv.add_version(metadata={"message": "hello again"},
+                            zero_padded_width=4)
+        self.assertEqual(v.vdir, "v0001")
+        self.assertEqual(v.message, "hello again")
+        self.assertEqual(inv.head, "v0001")
+        v = inv.add_version(metadata={"message": "and again"})
+        self.assertEqual(v.vdir, "v0002")
+        self.assertEqual(v.message, "and again")
+        self.assertEqual(inv.head, "v0002")
+
+    def test_add_file(self):
+        """Test add_file method."""
+        inv = Inventory()
+        inv.add_file(digest="abc123", content_path="file1")
+        self.assertEqual(inv.manifest["abc123"], ["file1"])
+        inv.add_file(digest="abc123", content_path="file2")
+        self.assertEqual(inv.manifest["abc123"], ["file1", "file2"])
+        inv.add_file(digest="def456", content_path="file3")
+        self.assertEqual(inv.manifest["abc123"], ["file1", "file2"])
+        self.assertEqual(inv.manifest["def456"], ["file3"])
+        # Errors
+        self.assertRaises(InventoryException, inv.add_file,
+                          digest="anything", content_path="file1")
+
+    def test_getter_properties_minimal_example(self):
+        """Test read of fixture and extract properties."""
+        inv = Inventory(filepath="fixtures/1.1/good-objects/minimal_one_version_one_file/inventory.json")
+        self.assertEqual(inv.spec_version, "1.1")
+        self.assertEqual(inv.digest_algorithm, "sha512")
+        self.assertEqual(inv.id, "ark:123/abc")
+        self.assertEqual(inv.head, "v1")
+        self.assertEqual(inv.content_paths, ["v1/content/a_file.txt"])
+        self.assertEqual(inv.digest_for_content_path("v1/content/a_file.txt"),
+                         "43a43fe8a8a082d3b5343dfaf2fd0c8b8e370675b1f376e92e9994612c33ea255b11298269d72f797399ebb94edeefe53df243643676548f584fb8603ca53a0f")
+        self.assertEqual(inv.version_directories, ["v1"])
+        self.assertEqual(inv.version_numbers, [1])
+        self.assertEqual(inv.version("v1").created,
+                         "2019-01-01T02:03:04Z")
+        self.assertEqual(inv.version("v1").message,
+                         "An version with one file")
+        self.assertEqual(inv.version("v1").user_name,
+                         "A Person")
+        self.assertEqual(inv.version("v1").user_address,
+                         "mailto:a_person@example.org")
+        self.assertEqual(inv.version("v1").logical_paths, ["a_file.txt"])
+        self.assertEqual(inv.version("v1").digest_for_logical_path("a_file.txt"),
+                         "43a43fe8a8a082d3b5343dfaf2fd0c8b8e370675b1f376e92e9994612c33ea255b11298269d72f797399ebb94edeefe53df243643676548f584fb8603ca53a0f")
+        self.assertEqual(inv.version("v1").content_path_for_logical_path("a_file.txt"),
+                         "v1/content/a_file.txt")
+        self.assertEqual(inv.fixity, {})
+
+    def test_creation_of_minimal_example(self):
+        """Test creation of minimal example."""
+        inv = Inventory()
+        inv.spec_version = "1.1"
+        inv.digest_algorithm = "sha512"
+        inv.id = "ark:123/abc"
+        v = inv.add_version()
+        v.created = "2019-01-01T02:03:04Z"
+        v.message = "An version with one file"
+        v.user_name = "A Person"
+        v.user_address = "mailto:a_person@example.org"
+        v.add_file(digest="43a43fe8a8a082d3b5343dfaf2fd0c8b8e370675b1f376e92e9994612c33ea255b11298269d72f797399ebb94edeefe53df243643676548f584fb8603ca53a0f",
+                   logical_path="a_file.txt")
+        json1 = inv.as_json()
+        json2 = Inventory(filepath="fixtures/1.1/good-objects/minimal_one_version_one_file/inventory.json").as_json()
+        self.assertEqual(json1, json2)
+
+
+class TestVersion(unittest.TestCase):
+    """TestVersion class to run tests for the Version class."""
+
+    def test_init(self):
+        """Test initialization."""
+        inv = Inventory()
+        v = Version(inv=inv, vdir="v99")
+        self.assertEqual(v.inv, inv)
+        self.assertEqual(v.vdir, "v99")
+
+    def test_number(self):
+        """Test version number property."""
+        v = Version(inv=Inventory(), vdir="v98")
+        self.assertEqual(v.number, 98)
+        v = Version(inv=Inventory(), vdir="v00023")
+        self.assertEqual(v.number, 23)
+
+    def test_digest_for_content_path(self):
+        """Test digest_for_content_path mathod."""
+        inv = Inventory()
+        inv.manifest = {"abc123": ["v1/content/file1", "v2/content/file2"],
+                        "def456": ["v2/content/file3"]}
+        self.assertEqual(inv.digest_for_content_path("does_not_exist"), None)
+        self.assertEqual(inv.digest_for_content_path("v2/content/file2"), "abc123")
+        self.assertEqual(inv.digest_for_content_path("v2/content/file3"), "def456")
+
+    def test_digest_for_logical_path(self):
+        """Test digest_for_logical_path method."""
+        v = Inventory().add_version(state={"digest1": ["file1", "file2"],
+                                           "digest2": ["file3"]})
+        self.assertEqual(v.digest_for_logical_path("file1"), "digest1")
+        self.assertEqual(v.digest_for_logical_path("file2"), "digest1")
+        self.assertEqual(v.digest_for_logical_path(path="file3"), "digest2")
+        self.assertEqual(v.digest_for_logical_path("file4"), None)
+
+    def test_content_path_for_logical_path(self):
+        """Test content_path_for_logical_path method."""
+        inv = Inventory()
+        inv.manifest = {"abc123": ["v1/content/file1", "v2/content/file2"],
+                        "def456": ["v2/content/file3"]}
+        v1 = inv.add_version(state={"abc123": ["file1_added_v1"]})
+        self.assertEqual(v1.content_path_for_logical_path("file1_added_v1"),
+                         "v1/content/file1")
+        self.assertEqual(v1.content_path_for_logical_path("not-there!"), None)
+        v2 = inv.add_version(state={"abc123": ["file1_added_v1_moved", "file2_added_v2"],
+                                    "def456": ["file3_added_v2"]})
+        self.assertEqual(v2.content_path_for_logical_path("file1_added_v1"), None)
+        self.assertEqual(v2.content_path_for_logical_path("file1_added_v1_moved"),
+                         "v1/content/file1")
+        self.assertEqual(v2.content_path_for_logical_path("file3_added_v2"),
+                         "v2/content/file3")
+
+    def test_add_file(self):
+        """Test add_file method."""
+        inv = Inventory()
+        v1 = inv.add_version()
+        self.assertEqual(v1.add_file(digest="digest1", logical_path="file1"),
+                         "v1/content/file1")
+        self.assertEqual(v1.add_file(digest="digest1", logical_path="file2_same_as_1"),
+                         None)
+        self.assertEqual(v1.add_file(digest="digest2", logical_path="file3", content_path="file4"),
+                         "v1/content/file4")
+        self.assertEqual(v1.add_file(digest="digest3", logical_path="file4"),
+                         "v1/content/file4__2")
+        self.assertEqual(v1.add_file(digest="digest4", logical_path="file4__2"),
+                         "v1/content/file4__2__2")
+        v2 = inv.add_version()
+        self.assertEqual(v2.add_file(digest="digest1", logical_path="file1_deduped"),
+                         None)
+        self.assertEqual(v2.add_file(digest="digest1", logical_path="file1_duped", dedupe=False),
+                         "v2/content/file1_duped")
+        self.assertEqual(inv.manifest["digest1"], ["v1/content/file1", "v2/content/file1_duped"])
+        # Error
+        self.assertRaises(InventoryException, v2.add_file, digest="digest5", logical_path="file1_deduped")
diff --git a/tests/test_inventory_validator.py b/tests/test_inventory_validator.py
index b1595cd..753cc5d 100644
--- a/tests/test_inventory_validator.py
+++ b/tests/test_inventory_validator.py
@@ -31,79 +31,79 @@ class TestAll(unittest.TestCase):
     def test_init(self):
         """Test object creation."""
         iv = InventoryValidator()
-        self.assertEqual(iv.where, '???')
-        iv = InventoryValidator(log='LOGGER', where="HERE", lax_digests=True)
-        self.assertEqual(iv.log, 'LOGGER')
-        self.assertEqual(iv.where, 'HERE')
+        self.assertEqual(iv.where, "???")
+        iv = InventoryValidator(log="LOGGER", where="HERE", lax_digests=True)
+        self.assertEqual(iv.log, "LOGGER")
+        self.assertEqual(iv.where, "HERE")
         self.assertTrue(iv.lax_digests)
 
     def test_validate(self):
         """Test validate method."""
         log = TLogger()
-        iv = InventoryValidator(log=log, default_spec_version='1.1')
+        iv = InventoryValidator(log=log, default_spec_version="1.1")
         iv.validate({})
-        self.assertIn('E036a', log.errors)
-        self.assertIn('E036b', log.errors)
-        self.assertIn('E036c', log.errors)
-        self.assertIn('E036d', log.errors)
-        self.assertIn('E041a', log.errors)
-        self.assertIn('E041b', log.errors)
+        self.assertIn("E036a", log.errors)
+        self.assertIn("E036b", log.errors)
+        self.assertIn("E036c", log.errors)
+        self.assertIn("E036d", log.errors)
+        self.assertIn("E041a", log.errors)
+        self.assertIn("E041b", log.errors)
         log.clear()
         iv.validate({"id": []})
-        self.assertIn('E037a', log.errors)
+        self.assertIn("E037a", log.errors)
         log.clear()
         # Valid and invalid URIs
         iv.validate({"id": "scheme:rest", "digestAlgorithm": "sha512"})
-        self.assertNotIn('W005', log.warns)
+        self.assertNotIn("W005", log.warns)
         log.clear()
         iv.validate({"id": "URN-3:rest", "digestAlgorithm": "sha512"})
-        self.assertNotIn('W005', log.warns)
+        self.assertNotIn("W005", log.warns)
         log.clear()
         iv.validate({"id": "a1+2-3z.:rest", "digestAlgorithm": "sha512"})
-        self.assertNotIn('W005', log.warns)
-        self.assertNotIn('W004', log.warns)
+        self.assertNotIn("W005", log.warns)
+        self.assertNotIn("W004", log.warns)
         log.clear()
         iv.validate({"id": "not_a_uri", "digestAlgorithm": "sha256"})
-        self.assertIn('W005', log.warns)
-        self.assertIn('W004', log.warns)
+        self.assertIn("W005", log.warns)
+        self.assertIn("W004", log.warns)
         log.clear()
         iv.validate({"id": "like:uri", "type": "wrong type", "digestAlgorithm": "my_digest"})
-        self.assertIn('E038b', log.errors)
-        self.assertIn('E025a', log.errors)
+        self.assertIn("E038b", log.errors)
+        self.assertIn("E025a", log.errors)
         log.clear()
         iv.validate({"id": "like:uri", "type": "wrong type", "digestAlgorithm": "my_digest"})
-        self.assertIn('E038b', log.errors)
+        self.assertIn("E038b", log.errors)
         log.clear()
         iv.validate({"id": "like:uri", "type": "https://ocfl.io/100.9/spec/#inventory", "digestAlgorithm": "my_digest"})
-        self.assertIn('E038c', log.errors)
+        self.assertIn("E038c", log.errors)
         iv = InventoryValidator(log=log, lax_digests=True)
         log.clear()
         iv.validate({"id": "like:uri", "type": "wrong type", "digestAlgorithm": "my_digest"})
-        self.assertNotIn('E025a', log.errors)
+        self.assertNotIn("E025a", log.errors)
         self.assertEqual(iv.digest_algorithm, "my_digest")
         iv = InventoryValidator(log=log)
         log.clear()
         iv.validate({"id": "like:uri", "contentDirectory": "not/allowed"})
-        self.assertIn('E017', log.errors)
+        self.assertIn("E017", log.errors)
         log.clear()
         iv.validate({"id": "like:uri", "contentDirectory": ["s"]})
-        self.assertIn('E017', log.errors)
+        self.assertIn("E017", log.errors)
         log.clear()
         iv.validate({"id": "like:uri", "contentDirectory": ".."})
-        self.assertIn('E018', log.errors)
+        self.assertIn("E018", log.errors)
 
     def test_validate_manifest(self):
         """Test validate_manifest method."""
         log = TLogger()
         iv = InventoryValidator(log=log)
         self.assertEqual(iv.validate_manifest("not a manifest"), ({}, [], set()))
-        self.assertIn('E041c', log.errors)
+        self.assertIn("E041c", log.errors)
         log.clear()
         self.assertEqual(iv.validate_manifest({"xxx": []}), ({}, [], set()))
-        self.assertIn('E025b', log.errors)
+        self.assertIn("E025b", log.errors)
         log.clear()
         self.assertEqual(iv.validate_manifest({"067eca3f5b024afa00aeac03a3c42dc0042bf43cba56104037abea8b365c0cf672f0e0c14c91b82bbce6b1464e231ac285d630a82cd4d4a7b194bea04d4b2eb7": "not an array"}), ({}, [], set()))
-        self.assertIn('E092', log.errors)
+        self.assertIn("E092", log.errors)
         log.clear()
         iv.lax_digests = True
         self.assertEqual(iv.validate_manifest(
@@ -114,50 +114,50 @@ def test_validate_manifest(self):
                 "067ECA3f5b024afa00aeac03a3c42dc0042bf43cba56104037abea8b365c0cf672f0e0c14c91b82bbce6b1464e231ac285d630a82cd4d4a7b194bea04d4b2eb7",
                 "067eca3f5b024afa00aeac03a3c42dc0042bf43cba56104037abea8b365c0cf672f0e0c14c91b82bbce6b1464e231ac285d630a82cd4d4a7b194bea04d4b2eb7"
             ])))
-        self.assertIn('E096', log.errors)
+        self.assertIn("E096", log.errors)
         log.clear()
         # Conflicting content paths
-        iv.validate_manifest({"067eca3f5b024afa00aeac03a3c42dc0042bf43cba56104037abea8b365c0cf672f0e0c14c91b82bbce6b1464e231ac285d630a82cd4d4a7b194bea04d4b2eb7": ['v1/content/a', 'v1/content/a/b']})
-        self.assertEqual(log.errors, ['E101b'])
+        iv.validate_manifest({"067eca3f5b024afa00aeac03a3c42dc0042bf43cba56104037abea8b365c0cf672f0e0c14c91b82bbce6b1464e231ac285d630a82cd4d4a7b194bea04d4b2eb7": ["v1/content/a", "v1/content/a/b"]})
+        self.assertEqual(log.errors, ["E101b"])
 
     def test_validate_fixity(self):
         """Test validate_fixity method."""
         log = TLogger()
-        iv = InventoryValidator(log=log, default_spec_version='1.0')
+        iv = InventoryValidator(log=log, default_spec_version="1.0")
         iv.validate_fixity("not a fixity block", [])
-        self.assertIn('E056a', log.errors)
+        self.assertIn("E056a", log.errors)
         log.clear()
-        iv.validate_fixity({'a': 'b'}, [])
-        self.assertIn('E056b', log.errors)
+        iv.validate_fixity({"a": "b"}, [])
+        self.assertIn("E056b", log.errors)
         log.clear()
-        iv.validate_fixity({'md5': 'f1'}, [])
-        self.assertIn('E057a', log.errors)
+        iv.validate_fixity({"md5": "f1"}, [])
+        self.assertIn("E057a", log.errors)
         log.clear()
-        iv.validate_fixity({'md5': {'d1': 'f1'}}, [])
-        self.assertIn('E057b', log.errors)
+        iv.validate_fixity({"md5": {"d1": "f1"}}, [])
+        self.assertIn("E057b", log.errors)
         log.clear()
-        iv.validate_fixity({'md5': {'68b329da9893e34099c7d8ad5cb9c940': 'f1'}}, [])
-        self.assertIn('E057c', log.errors)
+        iv.validate_fixity({"md5": {"68b329da9893e34099c7d8ad5cb9c940": "f1"}}, [])
+        self.assertIn("E057c", log.errors)
         log.clear()
-        iv.validate_fixity({'md5': {'68b329da9893e34099c7d8ad5cb9c940': ['f1']}}, [])
-        self.assertIn('E057d', log.errors)
+        iv.validate_fixity({"md5": {"68b329da9893e34099c7d8ad5cb9c940": ["f1"]}}, [])
+        self.assertIn("E057d", log.errors)
         log.clear()
-        iv.validate_fixity({'md5': {'68b329da9893e34099c7d8ad5cb9c940': ['f1'],
-                                    '68B329DA9893e34099c7d8ad5cb9c940': ['f2']}}, [])
-        self.assertIn('E097', log.errors)
+        iv.validate_fixity({"md5": {"68b329da9893e34099c7d8ad5cb9c940": ["f1"],
+                                    "68B329DA9893e34099c7d8ad5cb9c940": ["f2"]}}, [])
+        self.assertIn("E097", log.errors)
         log.clear()
         # Good case
-        iv.validate_fixity({'md5': {'68b329da9893e34099c7d8ad5cb9c940': ['f1a', 'f1b'],
-                                    '06c7aa0ab7739f5fde7cb8504af3e851': ['f2']},
-                            'sha1': {'adc83b19e793491b1c6ea0fd8b46cd9f32e592fc': ['f1a', 'f1b'],
-                                     '00be977f5f719e87c17704954341f50d929bc070': ['f2']}},
-                           ['f1a', 'f1b', 'f2'])
+        iv.validate_fixity({"md5": {"68b329da9893e34099c7d8ad5cb9c940": ["f1a", "f1b"],
+                                    "06c7aa0ab7739f5fde7cb8504af3e851": ["f2"]},
+                            "sha1": {"adc83b19e793491b1c6ea0fd8b46cd9f32e592fc": ["f1a", "f1b"],
+                                     "00be977f5f719e87c17704954341f50d929bc070": ["f2"]}},
+                           ["f1a", "f1b", "f2"])
         self.assertEqual(len(log.errors), 0)
         # Good case when lax_digests
         iv.lax_digests = True
-        iv.validate_fixity({'XXX': {'digest1': ['f1a', 'f1b'],
-                                    'digest2': ['f2']}},
-                           ['f1a', 'f1b', 'f2'])
+        iv.validate_fixity({"XXX": {"digest1": ["f1a", "f1b"],
+                                    "digest2": ["f2"]}},
+                           ["f1a", "f1b", "f2"])
         self.assertEqual(len(log.errors), 0)
 
     def test_validate_version_sequence(self):
@@ -165,33 +165,33 @@ def test_validate_version_sequence(self):
         log = TLogger()
         iv = InventoryValidator(log=log)
         self.assertEqual(iv.validate_version_sequence("not an object"), [])
-        self.assertIn('E044', log.errors)
+        self.assertIn("E044", log.errors)
         log.clear()
         self.assertEqual(iv.validate_version_sequence({"v2": {}}), [])
-        self.assertIn('E009', log.errors)
+        self.assertIn("E009", log.errors)
         log.clear()
         self.assertEqual(iv.validate_version_sequence({"v02": {}}), [])
-        self.assertIn('E009', log.errors)
+        self.assertIn("E009", log.errors)
         log.clear()
-        self.assertEqual(iv.validate_version_sequence({"v1": {}, 'v2': {}, 'v3': {}}), ['v1', 'v2', 'v3'])
+        self.assertEqual(iv.validate_version_sequence({"v1": {}, "v2": {}, "v3": {}}), ["v1", "v2", "v3"])
         log.clear()
-        self.assertEqual(iv.validate_version_sequence({"v0001": {}, 'v0002': {}}), ['v0001', 'v0002'])
-        self.assertIn('W001', log.warns)
+        self.assertEqual(iv.validate_version_sequence({"v0001": {}, "v0002": {}}), ["v0001", "v0002"])
+        self.assertIn("W001", log.warns)
         self.assertEqual(len(log.errors), 0)
         log.clear()
-        self.assertEqual(iv.validate_version_sequence({"v1": {}, 'v2': {}, 'v4': {}}), ['v1', 'v2'])
-        self.assertIn('E010', log.errors)
+        self.assertEqual(iv.validate_version_sequence({"v1": {}, "v2": {}, "v4": {}}), ["v1", "v2"])
+        self.assertIn("E010", log.errors)
         log.clear()
-        self.assertEqual(iv.validate_version_sequence({"v01": {}, 'v02': {}, 'v03': {}, "v04": {}, 'v05': {}, 'v06': {}, "v07": {}, 'v08': {}, 'v09': {}}), ['v01', 'v02', 'v03', 'v04', 'v05', 'v06', 'v07', 'v08', 'v09'])
+        self.assertEqual(iv.validate_version_sequence({"v01": {}, "v02": {}, "v03": {}, "v04": {}, "v05": {}, "v06": {}, "v07": {}, "v08": {}, "v09": {}}), ["v01", "v02", "v03", "v04", "v05", "v06", "v07", "v08", "v09"])
         self.assertEqual(len(log.errors), 0)
         log.clear()
-        self.assertEqual(iv.validate_version_sequence({"v01": {}, 'v02': {}, 'v03': {}, "v04": {}, 'v05': {}, 'v06': {}, "v07": {}, 'v08': {}, 'v09': {}, 'v10': {}}), ['v01', 'v02', 'v03', 'v04', 'v05', 'v06', 'v07', 'v08', 'v09'])
-        self.assertIn('E011', log.errors)
+        self.assertEqual(iv.validate_version_sequence({"v01": {}, "v02": {}, "v03": {}, "v04": {}, "v05": {}, "v06": {}, "v07": {}, "v08": {}, "v09": {}, "v10": {}}), ["v01", "v02", "v03", "v04", "v05", "v06", "v07", "v08", "v09"])
+        self.assertIn("E011", log.errors)
 
     def test_validate_versions(self):
         """Test validate_versions method."""
         log = TLogger()
-        iv = InventoryValidator(log=log, default_spec_version='1.0')
+        iv = InventoryValidator(log=log, default_spec_version="1.0")
         self.assertEqual(iv.validate_versions({}, [], set()), [])
         self.assertEqual(len(log.errors), 0)
         log.clear()
@@ -199,78 +199,78 @@ def test_validate_versions(self):
         self.assertEqual(len(log.errors), 0)
         log.clear()
         # First, no useful data
-        self.assertEqual(iv.validate_versions({'v1': {}}, ['v1'], set()), [])
-        self.assertIn('E048', log.errors)
-        self.assertIn('E048c', log.errors)
-        self.assertIn('W007a', log.warns)
-        self.assertIn('W007b', log.warns)
+        self.assertEqual(iv.validate_versions({"v1": {}}, ["v1"], set()), [])
+        self.assertIn("E048", log.errors)
+        self.assertIn("E048c", log.errors)
+        self.assertIn("W007a", log.warns)
+        self.assertIn("W007b", log.warns)
         log.clear()
         # Second, good data
-        versions = {'v1': {"created": "2010-03-30T21:24:00Z",
+        versions = {"v1": {"created": "2010-03-30T21:24:00Z",
                            "message": "A useful message",
                            "state": {},
                            "user": {"name": "A Person", "address": "info:uri1"}}}
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
         self.assertEqual(log.errors, [])
         log.clear()
-        versions['v1']['created'] = {}  # not a string
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
-        self.assertIn('E049d', log.errors)
+        versions["v1"]["created"] = {}  # not a string
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
+        self.assertIn("E049d", log.errors)
         log.clear()
-        versions['v1']['created'] = "not a datetime"
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
-        self.assertIn('E049c', log.errors)
+        versions["v1"]["created"] = "not a datetime"
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
+        self.assertIn("E049c", log.errors)
         log.clear()
-        versions['v1']['created'] = "2010-03-30T21:24:00"  # no timezone
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
-        self.assertIn('E049a', log.errors)
+        versions["v1"]["created"] = "2010-03-30T21:24:00"  # no timezone
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
+        self.assertIn("E049a", log.errors)
         log.clear()
-        versions['v1']['created'] = "2010-03-30T21:24Z"  # no seconds
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
-        self.assertIn('E049b', log.errors)
+        versions["v1"]["created"] = "2010-03-30T21:24Z"  # no seconds
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
+        self.assertIn("E049b", log.errors)
         log.clear()
-        versions['v1']['created'] = "2010-03-30T21:24:00Z"
-        versions['v1']['message'] = {}  # not a string
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
-        self.assertIn('E094', log.errors)
+        versions["v1"]["created"] = "2010-03-30T21:24:00Z"
+        versions["v1"]["message"] = {}  # not a string
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
+        self.assertIn("E094", log.errors)
         log.clear()
-        versions['v1']['message'] = "A message"
-        versions['v1']['user'] = "A string"  # not a dict
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
-        self.assertIn('E054a', log.errors)
+        versions["v1"]["message"] = "A message"
+        versions["v1"]["user"] = "A string"  # not a dict
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
+        self.assertIn("E054a", log.errors)
         log.clear()
-        versions['v1']['user'] = {"name": {}, "address": {}}  # not strings
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
-        self.assertIn('E054b', log.errors)
-        self.assertIn('E054c', log.errors)
+        versions["v1"]["user"] = {"name": {}, "address": {}}  # not strings
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
+        self.assertIn("E054b", log.errors)
+        self.assertIn("E054c", log.errors)
         log.clear()
-        versions['v1']['user'] = {"name": "A Person"}  # no address
-        self.assertEqual(iv.validate_versions(versions, ['v1'], set()), [])
-        self.assertIn('W008', log.warns)
+        versions["v1"]["user"] = {"name": "A Person"}  # no address
+        self.assertEqual(iv.validate_versions(versions, ["v1"], set()), [])
+        self.assertIn("W008", log.warns)
 
     def test_validate_state_block(self):
         """Test validate_state_block."""
         log = TLogger()
         iv = InventoryValidator(log=log)
-        iv.digest_algorithm = 'sha512'
+        iv.digest_algorithm = "sha512"
         self.assertEqual(iv.validate_state_block({}, "v1", set()), [])
         self.assertEqual(len(log.errors), 0)
         log.clear()
         self.assertEqual(iv.validate_state_block("invalid", "v1", set()), [])
-        self.assertIn('E050c', log.errors)
+        self.assertIn("E050c", log.errors)
         log.clear()
         self.assertEqual(iv.validate_state_block({"not a digest": []}, "v1", set()), [])
-        self.assertIn('E050d', log.errors)
+        self.assertIn("E050d", log.errors)
         log.clear()
         d = "4a89417821564b1e1956130569c390dd6122b51296ec620cadd0555ff5aae21c2a17383a194290fc95c73c63261bd8cb77ac275c85e6300cd711fa132fe8706e"
         self.assertEqual(iv.validate_state_block({d: "not a list"}, "v1", set()), [])
-        self.assertIn('E050e', log.errors)
+        self.assertIn("E050e", log.errors)
         log.clear()
-        self.assertEqual(iv.validate_state_block({d: ["good path", 'a/./b']}, "v1", set()), [d])
-        self.assertIn('E052', log.errors)
+        self.assertEqual(iv.validate_state_block({d: ["good path", "a/./b"]}, "v1", set()), [d])
+        self.assertIn("E052", log.errors)
         log.clear()
-        self.assertEqual(iv.validate_state_block({d: ["good path", '/']}, "v1", set()), [d])
-        self.assertIn('E053', log.errors)
+        self.assertEqual(iv.validate_state_block({d: ["good path", "/"]}, "v1", set()), [d])
+        self.assertIn("E053", log.errors)
         log.clear()
         # Finally a good case
         d2 = "ae16b7632ee42fafd6b510e94a4951b2346ad90a1eff4baae2d7c0d5481515de61dcbc9a8d01f4824ab5215f033858189331859fb5b75fea5809230c63bad34a"
@@ -281,27 +281,27 @@ def test_check_digests_present_and_used(self):
         """Test check_digests_present_and_used."""
         log = TLogger()
         iv = InventoryValidator(log=log)
-        manifest = {'file_aaa1': 'aaa', 'file_aaa2': 'aaa', 'file_bbb': 'bbb'}
-        iv.check_digests_present_and_used(manifest, ['aaa', 'bbb'])
+        manifest = {"file_aaa1": "aaa", "file_aaa2": "aaa", "file_bbb": "bbb"}
+        iv.check_digests_present_and_used(manifest, ["aaa", "bbb"])
         self.assertEqual(len(log.errors), 0)
-        iv.check_digests_present_and_used(manifest, ['aaa'])
-        self.assertIn('E107', log.errors)
+        iv.check_digests_present_and_used(manifest, ["aaa"])
+        self.assertIn("E107", log.errors)
         log.clear()
-        iv.check_digests_present_and_used(manifest, ['aaa', 'bbb', 'ccc'])
-        self.assertIn('E050a', log.errors)
+        iv.check_digests_present_and_used(manifest, ["aaa", "bbb", "ccc"])
+        self.assertIn("E050a", log.errors)
 
     def test_digest_regex(self):
         """Test digest_regex."""
         log = TLogger()
         iv = InventoryValidator(log=log)
-        iv.digest_algorithm = 'md5'
-        self.assertEqual(iv.digest_regex(), '^[0-9a-fA-F]{32}$')
-        iv.digest_algorithm = 'not a digest'
-        self.assertEqual(iv.digest_regex(), '^.*$')
-        self.assertEqual(log.errors, ['E026a'])
+        iv.digest_algorithm = "md5"
+        self.assertEqual(iv.digest_regex(), "^[0-9a-fA-F]{32}$")
+        iv.digest_algorithm = "not a digest"
+        self.assertEqual(iv.digest_regex(), "^.*$")
+        self.assertEqual(log.errors, ["E026a"])
         log.clear()
         iv.lax_digests = True
-        self.assertEqual(iv.digest_regex(), '^.*$')
+        self.assertEqual(iv.digest_regex(), "^.*$")
         self.assertEqual(log.errors, [])
 
     def test_validate_as_prior_version(self):
@@ -309,28 +309,28 @@ def test_validate_as_prior_version(self):
         log = TLogger()
         iv = InventoryValidator(log=log)
         prior = InventoryValidator(log=TLogger())
-        # Same versions won't work...
-        iv.all_versions = ['v1']
-        prior.all_versions = ['v1']
+        # Same versions won"t work...
+        iv.all_versions = ["v1"]
+        prior.all_versions = ["v1"]
         iv.validate_as_prior_version(prior)
-        self.assertEqual(log.errors, ['E066a'])
+        self.assertEqual(log.errors, ["E066a"])
         log.clear()
         # Good inventory in spite of diferent digests
-        iv.all_versions = ['v1', 'v2']
-        iv.digest_algorithm = 'a1'
+        iv.all_versions = ["v1", "v2"]
+        iv.digest_algorithm = "a1"
         iv.inventory = {"manifest": {"a1d1": ["v1/content/f1"],
                                      "a1d2": ["v1/content/f2"],
                                      "a1d3": ["v2/content/f3"]},
                         "versions": {"v1": {"state": {"a1d1": ["f1"], "a1d2": ["f2"]}},
                                      "v2": {"state": {"a1d1": ["f1"], "a1d3": ["f3"]}}}}
-        prior.digest_algorithm = 'a2'
+        prior.digest_algorithm = "a2"
         prior.inventory = {"manifest": {"a2d1": ["v1/content/f1"],
                                         "a2d2": ["v1/content/f2"]},
                            "versions": {"v1": {"state": {"a2d1": ["f1"], "a2d2": ["f2"]}}}}
         iv.validate_as_prior_version(prior)
         self.assertEqual(log.errors, [])
         log.clear()
-        # Now let's add a copy file in the state in prior so as not to match
+        # Now let"s add a copy file in the state in prior so as not to match
         prior.inventory["versions"]["v1"]["state"]["a2d2"] = ["f2", "f2-copy"]
         iv.validate_as_prior_version(prior)
         self.assertEqual(log.errors, ["E066b"])
@@ -351,20 +351,20 @@ def test_compare_states_for_version(self):
             "versions": {"v99": {"state": {"a1d1": ["f1"], "a1d2": ["f2", "f3"]}}}}
         prior.inventory = {
             "versions": {"v99": {"state": {"a1d1": ["f1"], "a1d2": ["f2", "f3"]}}}}
-        iv.compare_states_for_version(prior, 'v99')
+        iv.compare_states_for_version(prior, "v99")
         self.assertEqual(log.errors, [])
         log.clear()
         # Extra in iv
         iv.inventory = {
             "versions": {"v99": {"state": {"a1d1": ["f1"], "a1d2": ["f2", "f3"], "a1d3": ["f4"]}}}}
-        iv.compare_states_for_version(prior, 'v99')
-        self.assertEqual(log.errors, ['E066d'])
+        iv.compare_states_for_version(prior, "v99")
+        self.assertEqual(log.errors, ["E066d"])
         log.clear()
         # Extra in prior
         iv.inventory = {
             "versions": {"v99": {"state": {"a1d2": ["f2", "f3"]}}}}
-        iv.compare_states_for_version(prior, 'v99')
-        self.assertEqual(log.errors, ['E066e'])
+        iv.compare_states_for_version(prior, "v99")
+        self.assertEqual(log.errors, ["E066e"])
 
     def test_check_content_path(self):
         """Test check_content_path method."""
@@ -372,64 +372,64 @@ def test_check_content_path(self):
         iv = InventoryValidator(log=log)
         cp = set()
         cd = set()
-        self.assertTrue(iv.check_content_path('v1/content/anything', cp, cd))
+        self.assertTrue(iv.check_content_path("v1/content/anything", cp, cd))
         self.assertEqual(log.errors, [])
-        self.assertEqual(cp, set(['v1/content/anything']))
-        self.assertEqual(cd, set(['v1/content']))
+        self.assertEqual(cp, set(["v1/content/anything"]))
+        self.assertEqual(cd, set(["v1/content"]))
         log.clear()
-        iv.content_directory = 'xyz'
-        self.assertFalse(iv.check_content_path('v1/content/anything', cp, cd))
-        self.assertEqual(log.errors, ['E042c'])
+        iv.content_directory = "xyz"
+        self.assertFalse(iv.check_content_path("v1/content/anything", cp, cd))
+        self.assertEqual(log.errors, ["E042c"])
         log.clear()
-        self.assertTrue(iv.check_content_path('v1/xyz/anything', cp, cd))
+        self.assertTrue(iv.check_content_path("v1/xyz/anything", cp, cd))
         self.assertEqual(log.errors, [])
         # Error cases
         log.clear()
-        self.assertFalse(iv.check_content_path('1/xyz/1', cp, cd))
-        self.assertEqual(log.errors, ['E042a'])
+        self.assertFalse(iv.check_content_path("1/xyz/1", cp, cd))
+        self.assertEqual(log.errors, ["E042a"])
         log.clear()
-        self.assertFalse(iv.check_content_path('vv1/xyz/1', cp, cd))
-        self.assertEqual(log.errors, ['E042a'])
+        self.assertFalse(iv.check_content_path("vv1/xyz/1", cp, cd))
+        self.assertEqual(log.errors, ["E042a"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/x/1', cp, cd))
-        self.assertEqual(log.errors, ['E042c'])
+        self.assertFalse(iv.check_content_path("v1/x/1", cp, cd))
+        self.assertEqual(log.errors, ["E042c"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/xyz/1/', cp, cd))
-        self.assertEqual(log.errors, ['E100'])
+        self.assertFalse(iv.check_content_path("v1/xyz/1/", cp, cd))
+        self.assertEqual(log.errors, ["E100"])
         log.clear()
-        self.assertFalse(iv.check_content_path('/v1/xyz/1', cp, cd))
-        self.assertEqual(log.errors, ['E100'])
+        self.assertFalse(iv.check_content_path("/v1/xyz/1", cp, cd))
+        self.assertEqual(log.errors, ["E100"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/xyz/', cp, cd))
-        self.assertEqual(log.errors, ['E100'])
+        self.assertFalse(iv.check_content_path("v1/xyz/", cp, cd))
+        self.assertEqual(log.errors, ["E100"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/xyz//1', cp, cd))
-        self.assertEqual(log.errors, ['E099'])
+        self.assertFalse(iv.check_content_path("v1/xyz//1", cp, cd))
+        self.assertEqual(log.errors, ["E099"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/xyz/abc//d', cp, cd))
-        self.assertEqual(log.errors, ['E099'])
+        self.assertFalse(iv.check_content_path("v1/xyz/abc//d", cp, cd))
+        self.assertEqual(log.errors, ["E099"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/xyz/abc/./d', cp, cd))
-        self.assertEqual(log.errors, ['E099'])
+        self.assertFalse(iv.check_content_path("v1/xyz/abc/./d", cp, cd))
+        self.assertEqual(log.errors, ["E099"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/xyz/abc/../d', cp, cd))
-        self.assertEqual(log.errors, ['E099'])
+        self.assertFalse(iv.check_content_path("v1/xyz/abc/../d", cp, cd))
+        self.assertEqual(log.errors, ["E099"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/xyz/.', cp, cd))
-        self.assertEqual(log.errors, ['E099'])
+        self.assertFalse(iv.check_content_path("v1/xyz/.", cp, cd))
+        self.assertEqual(log.errors, ["E099"])
         log.clear()
-        self.assertFalse(iv.check_content_path('v1/xyz/anything', cp, cd))
-        self.assertEqual(log.errors, ['E101a'])
+        self.assertFalse(iv.check_content_path("v1/xyz/anything", cp, cd))
+        self.assertEqual(log.errors, ["E101a"])
         # Good cases
         log.clear()
-        self.assertTrue(iv.check_content_path('v1/xyz/.secret/d', cp, cd))
+        self.assertTrue(iv.check_content_path("v1/xyz/.secret/d", cp, cd))
         self.assertEqual(log.errors, [])
         log.clear()
-        self.assertTrue(iv.check_content_path('v1/xyz/.a', cp, cd))
+        self.assertTrue(iv.check_content_path("v1/xyz/.a", cp, cd))
         self.assertEqual(log.errors, [])
         # Check good paths accumulated
-        self.assertEqual(cp, set(('v1/xyz/anything', 'v1/xyz/.a', 'v1/content/anything', 'v1/xyz/.secret/d')))
-        self.assertEqual(cd, set(('v1/xyz', 'v1/content', 'v1/xyz/.secret')))
+        self.assertEqual(cp, set(("v1/xyz/anything", "v1/xyz/.a", "v1/content/anything", "v1/xyz/.secret/d")))
+        self.assertEqual(cd, set(("v1/xyz", "v1/content", "v1/xyz/.secret")))
 
     def test_check_logical_path(self):
         """Test check_logical_path method."""
@@ -442,25 +442,25 @@ def test_check_logical_path(self):
         iv.check_logical_path("including/this/long/path", "root", lp, ld)
         self.assertEqual(log.errors, [])
         iv.check_logical_path("/but not this", "root", lp, ld)
-        self.assertEqual(log.errors, ['E053'])
+        self.assertEqual(log.errors, ["E053"])
         log.clear()
         iv.check_logical_path("./or this", "root", lp, ld)
-        self.assertEqual(log.errors, ['E052'])
+        self.assertEqual(log.errors, ["E052"])
         log.clear()
         iv.check_logical_path("or this/", "root", lp, ld)
-        self.assertEqual(log.errors, ['E053'])
+        self.assertEqual(log.errors, ["E053"])
         # And check only good paths recorded
-        self.assertEqual(lp, set(('almost anything goes', 'including/this/long/path')))
-        self.assertEqual(ld, set(('', 'including/this/long')))
+        self.assertEqual(lp, set(("almost anything goes", "including/this/long/path")))
+        self.assertEqual(ld, set(("", "including/this/long")))
 
     def test_bad_inventory_files(self):
         """Test bad inventory files."""
-        for bad, codes in {'inventory_E042a_bad_content_paths': ['E042a'],
-                           'inventory_E042c_bad_content_paths': ['E042c'],
-                           'inventory_E042b_unknown_version': ['E042b'],
-                           'inventory_E042b_zero_padding_mismatch': ['E042b']}.items():
-            filepath = 'extra_fixtures/bad-inventories/' + bad + '.json'
-            with open(filepath, 'r', encoding="utf-8") as fh:
+        for bad, codes in {"inventory_E042a_bad_content_paths": ["E042a"],
+                           "inventory_E042c_bad_content_paths": ["E042c"],
+                           "inventory_E042b_unknown_version": ["E042b"],
+                           "inventory_E042b_zero_padding_mismatch": ["E042b"]}.items():
+            filepath = "extra_fixtures/bad-inventories/" + bad + ".json"
+            with open(filepath, "r", encoding="utf-8") as fh:
                 inventory = json.load(fh)
             log = TLogger()
             iv = InventoryValidator(log=log)
diff --git a/tests/test_layout.py b/tests/test_layout.py
index 9758065..fe818d3 100644
--- a/tests/test_layout.py
+++ b/tests/test_layout.py
@@ -2,7 +2,7 @@
 import unittest
 import unittest.mock
 from ocfl.layout import Layout, LayoutException
-from ocfl.pyfs import open_fs
+from ocfl.pyfs import pyfs_openfs
 
 
 class TestAll(unittest.TestCase):
@@ -18,35 +18,35 @@ def test_init_and_config(self):
     def test_strip_root(self):
         """Test strip_root."""
         layout = Layout()
-        self.assertEqual(layout.strip_root('a/b', 'a'), 'b')
-        self.assertEqual(layout.strip_root('a/b', ''), 'a/b')
-        self.assertEqual(layout.strip_root('a/b/c', 'a/b/c'), '.')
-        self.assertEqual(layout.strip_root('a/b/c', 'a/b/'), 'c')
-        self.assertEqual(layout.strip_root('a/b/c/', 'a/b'), 'c')
-        self.assertRaises(LayoutException, layout.strip_root, 'a', 'b')
-        self.assertRaises(LayoutException, layout.strip_root, 'a', 'a/b')
-        self.assertRaises(LayoutException, layout.strip_root, '', 'a/b')
+        self.assertEqual(layout.strip_root("a/b", "a"), "b")
+        self.assertEqual(layout.strip_root("a/b", ""), "a/b")
+        self.assertEqual(layout.strip_root("a/b/c", "a/b/c"), ".")
+        self.assertEqual(layout.strip_root("a/b/c", "a/b/"), "c")
+        self.assertEqual(layout.strip_root("a/b/c/", "a/b"), "c")
+        self.assertRaises(LayoutException, layout.strip_root, "a", "b")
+        self.assertRaises(LayoutException, layout.strip_root, "a", "a/b")
+        self.assertRaises(LayoutException, layout.strip_root, "", "a/b")
 
     def test_is_valid(self):
         """Test is_valid method."""
         layout = Layout()
-        self.assertTrue(layout.is_valid(''))
-        self.assertTrue(layout.is_valid('anything'))
+        self.assertTrue(layout.is_valid(""))
+        self.assertTrue(layout.is_valid("anything"))
 
     def test_encodea_and_decode(self):
         """Test encode and decode methods."""
         layout = Layout()
-        self.assertEqual(layout.encode(''), '')
-        self.assertEqual(layout.encode('something'), 'something')
-        self.assertEqual(layout.encode('http://a.b.c'), 'http%3A%2F%2Fa.b.c')
-        self.assertEqual(layout.decode(''), '')
-        self.assertEqual(layout.decode('something-else'), 'something-else')
-        self.assertEqual(layout.decode('http%3a%2f%2Fa.b.c'), 'http://a.b.c')
-        self.assertRaises(LayoutException, layout.identifier_to_path, 'id')
+        self.assertEqual(layout.encode(""), "")
+        self.assertEqual(layout.encode("something"), "something")
+        self.assertEqual(layout.encode("http://a.b.c"), "http%3A%2F%2Fa.b.c")
+        self.assertEqual(layout.decode(""), "")
+        self.assertEqual(layout.decode("something-else"), "something-else")
+        self.assertEqual(layout.decode("http%3a%2f%2Fa.b.c"), "http://a.b.c")
+        self.assertRaises(LayoutException, layout.identifier_to_path, "id")
 
     def test_read_layout_params(self):
         """Test read_layout_params."""
-        root_fs = open_fs("extra_fixtures/extension_configs")
+        root_fs = pyfs_openfs("extra_fixtures/extension_configs")
         layout = Layout()
         layout.NAME = "good_param"
         layout.param = None
@@ -57,7 +57,7 @@ def parse_param(value):
         layout.read_layout_params(root_fs=root_fs, params_required=True)
         self.assertEqual(layout.param, "yay!")
         # No config file but none required
-        # Idoesn't do anything, nothing to check)
+        # (doesn't do anything, nothing to check)
         layout.NAME = "no_config"
         layout.read_layout_params(root_fs=root_fs, params_required=False)
         # Error cases
@@ -92,7 +92,7 @@ def parse_param(value):
 
     def test_write_layout_params(self):
         """Test write_layout_params."""
-        root_fs = open_fs("mem://")
+        root_fs = pyfs_openfs("mem://")
         layout = Layout()
         # No config_file will return none, so simply exits
         self.assertEqual(layout.write_layout_params(root_fs=root_fs), None)
diff --git a/tests/test_layout_0002_flat_direct.py b/tests/test_layout_0002_flat_direct.py
index db95a0a..701604c 100644
--- a/tests/test_layout_0002_flat_direct.py
+++ b/tests/test_layout_0002_flat_direct.py
@@ -9,16 +9,16 @@ class TestAll(unittest.TestCase):
 
     def test_name(self):
         """Test canonical name."""
-        self.assertEqual(Layout_0002_Flat_Direct().NAME, '0002-flat-direct-storage-layout')
+        self.assertEqual(Layout_0002_Flat_Direct().NAME, "0002-flat-direct-storage-layout")
 
     def test_identifier_to_path(self):
         """Test identifier_to_path."""
         d = Layout_0002_Flat_Direct()
-        self.assertEqual(d.identifier_to_path('abc'), 'abc')
-        self.assertEqual(d.identifier_to_path('this n that'), 'this n that')
+        self.assertEqual(d.identifier_to_path("abc"), "abc")
+        self.assertEqual(d.identifier_to_path("this n that"), "this n that")
         # From the extension
-        self.assertEqual(d.identifier_to_path('object-01'), 'object-01')
-        self.assertEqual(d.identifier_to_path('..hor_rib:lé-$id'), '..hor_rib:lé-$id')
+        self.assertEqual(d.identifier_to_path("object-01"), "object-01")
+        self.assertEqual(d.identifier_to_path("..hor_rib:lé-$id"), "..hor_rib:lé-$id")
         # Exception cases
-        for bad_id in ('', '.', os.path.join('a', 'b')):
+        for bad_id in ("", ".", os.path.join("a", "b")):
             self.assertRaises(Exception, d.identifier_to_path, bad_id)
diff --git a/tests/test_layout_0003-hash-and-id-n-tuple.py b/tests/test_layout_0003-hash-and-id-n-tuple.py
index c059301..12d5466 100644
--- a/tests/test_layout_0003-hash-and-id-n-tuple.py
+++ b/tests/test_layout_0003-hash-and-id-n-tuple.py
@@ -8,43 +8,43 @@ class TestAll(unittest.TestCase):
 
     def test__percent_encode(self):
         """Test _percent_encode function."""
-        self.assertEqual(_percent_encode('.'), '%2e')
-        self.assertEqual(_percent_encode('ç'), '%c3%a7')
+        self.assertEqual(_percent_encode("."), "%2e")
+        self.assertEqual(_percent_encode("ç"), "%c3%a7")
 
     def test__id_to_path(self):
         """Test _id_to_path function."""
-        self.assertEqual(_id_to_path(identifier='object-01', digest_algorithm='sha256', tuple_size=3, number_of_tuples=3),
-                         '3c0/ff4/240/object-01')
-        self.assertEqual(_id_to_path(identifier='object-01', digest_algorithm='md5', tuple_size=3, number_of_tuples=3),
-                         'ff7/553/449/object-01')
-        self.assertEqual(_id_to_path(identifier='object-01', digest_algorithm='md5', tuple_size=5, number_of_tuples=2),
-                         'ff755/34492/object-01')
-        self.assertEqual(_id_to_path(identifier='object-01', digest_algorithm='md5', tuple_size=0, number_of_tuples=0),
-                         'object-01')
-        self.assertEqual(_id_to_path(identifier='object-01', digest_algorithm='md5', tuple_size=2, number_of_tuples=15),
-                         'ff/75/53/44/92/48/5e/ab/b3/9f/86/35/67/28/88/object-01')
-        self.assertEqual(_id_to_path(identifier='..hor/rib:le-$id', digest_algorithm='sha256', tuple_size=3, number_of_tuples=3),
-                         '487/326/d8c/%2e%2ehor%2frib%3ale-%24id')
-        self.assertEqual(_id_to_path(identifier='..hor/rib:le-$id', digest_algorithm='md5', tuple_size=3, number_of_tuples=3),
-                         '083/197/66f/%2e%2ehor%2frib%3ale-%24id')  # digest: 08319766fb6c2935dd175b94267717e0
-        self.assertEqual(_id_to_path(identifier='..Hor/rib:lè-$id', digest_algorithm='sha256', tuple_size=3, number_of_tuples=3),
-                         '373/529/21a/%2e%2eHor%2frib%3al%c3%a8-%24id')
-        long_object_id = 'abcdefghij' * 26
-        long_object_id_digest = '55b432806f4e270da0cf23815ed338742179002153cd8d896f23b3e2d8a14359'
-        self.assertEqual(_id_to_path(identifier=long_object_id, digest_algorithm='sha256', tuple_size=3, number_of_tuples=3),
-                         f'55b/432/806/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij-{long_object_id_digest}')
-        long_object_id_101 = 'abcdefghij' * 10 + 'a'
-        long_object_id_101_digest = '5cc73e648fbcff136510e330871180922ddacf193b68fdeff855683a01464220'
-        self.assertEqual(_id_to_path(identifier=long_object_id_101, digest_algorithm='sha256', tuple_size=3, number_of_tuples=3),
-                         f'5cc/73e/648/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij-{long_object_id_101_digest}')
+        self.assertEqual(_id_to_path(identifier="object-01", digest_algorithm="sha256", tuple_size=3, number_of_tuples=3),
+                         "3c0/ff4/240/object-01")
+        self.assertEqual(_id_to_path(identifier="object-01", digest_algorithm="md5", tuple_size=3, number_of_tuples=3),
+                         "ff7/553/449/object-01")
+        self.assertEqual(_id_to_path(identifier="object-01", digest_algorithm="md5", tuple_size=5, number_of_tuples=2),
+                         "ff755/34492/object-01")
+        self.assertEqual(_id_to_path(identifier="object-01", digest_algorithm="md5", tuple_size=0, number_of_tuples=0),
+                         "object-01")
+        self.assertEqual(_id_to_path(identifier="object-01", digest_algorithm="md5", tuple_size=2, number_of_tuples=15),
+                         "ff/75/53/44/92/48/5e/ab/b3/9f/86/35/67/28/88/object-01")
+        self.assertEqual(_id_to_path(identifier="..hor/rib:le-$id", digest_algorithm="sha256", tuple_size=3, number_of_tuples=3),
+                         "487/326/d8c/%2e%2ehor%2frib%3ale-%24id")
+        self.assertEqual(_id_to_path(identifier="..hor/rib:le-$id", digest_algorithm="md5", tuple_size=3, number_of_tuples=3),
+                         "083/197/66f/%2e%2ehor%2frib%3ale-%24id")  # digest: 08319766fb6c2935dd175b94267717e0
+        self.assertEqual(_id_to_path(identifier="..Hor/rib:lè-$id", digest_algorithm="sha256", tuple_size=3, number_of_tuples=3),
+                         "373/529/21a/%2e%2eHor%2frib%3al%c3%a8-%24id")
+        long_object_id = "abcdefghij" * 26
+        long_object_id_digest = "55b432806f4e270da0cf23815ed338742179002153cd8d896f23b3e2d8a14359"
+        self.assertEqual(_id_to_path(identifier=long_object_id, digest_algorithm="sha256", tuple_size=3, number_of_tuples=3),
+                         f"55b/432/806/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij-{long_object_id_digest}")
+        long_object_id_101 = "abcdefghij" * 10 + "a"
+        long_object_id_101_digest = "5cc73e648fbcff136510e330871180922ddacf193b68fdeff855683a01464220"
+        self.assertEqual(_id_to_path(identifier=long_object_id_101, digest_algorithm="sha256", tuple_size=3, number_of_tuples=3),
+                         f"5cc/73e/648/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij-{long_object_id_101_digest}")
 
     def test_name(self):
         """Test canonical name."""
-        self.assertEqual(Layout_0003_Hash_And_Id_N_Tuple().NAME, '0003-hash-and-id-n-tuple-storage-layout')
+        self.assertEqual(Layout_0003_Hash_And_Id_N_Tuple().NAME, "0003-hash-and-id-n-tuple-storage-layout")
 
     def test_identifier_to_path(self):
         """Test identifier_to_path."""
         d = Layout_0003_Hash_And_Id_N_Tuple()
         # From the extension
-        self.assertEqual(d.identifier_to_path('object-01'), '3c0/ff4/240/object-01')
-        self.assertEqual(d.identifier_to_path('..hor/rib:le-$id'), '487/326/d8c/%2e%2ehor%2frib%3ale-%24id')
+        self.assertEqual(d.identifier_to_path("object-01"), "3c0/ff4/240/object-01")
+        self.assertEqual(d.identifier_to_path("..hor/rib:le-$id"), "487/326/d8c/%2e%2ehor%2frib%3ale-%24id")
diff --git a/tests/test_layout_nnnn_flat_quoted.py b/tests/test_layout_nnnn_flat_quoted.py
index 7cc52ce..3a31009 100644
--- a/tests/test_layout_nnnn_flat_quoted.py
+++ b/tests/test_layout_nnnn_flat_quoted.py
@@ -9,6 +9,6 @@ class TestAll(unittest.TestCase):
     def test_identifier_to_path(self):
         """Test identifier_to_path."""
         d = Layout_NNNN_Flat_Quoted()
-        self.assertEqual(d.identifier_to_path(''), '')
-        self.assertEqual(d.identifier_to_path('abc'), 'abc')
-        self.assertEqual(d.identifier_to_path('this n that'), 'this+n+that')
+        self.assertEqual(d.identifier_to_path(""), "")
+        self.assertEqual(d.identifier_to_path("abc"), "abc")
+        self.assertEqual(d.identifier_to_path("this n that"), "this+n+that")
diff --git a/tests/test_layout_nnnn_tuple_tree.py b/tests/test_layout_nnnn_tuple_tree.py
index 7650761..74c1d91 100644
--- a/tests/test_layout_nnnn_tuple_tree.py
+++ b/tests/test_layout_nnnn_tuple_tree.py
@@ -9,26 +9,26 @@ class TestAll(unittest.TestCase):
     def test01_encode(self):
         """Test encode."""
         tt = Layout_NNNN_Tuple_Tree()
-        self.assertEqual(tt.encode(''), '')
-        self.assertEqual(tt.encode('a'), 'a')
-        self.assertEqual(tt.encode('a/b:?'), 'a=b+^3f')
+        self.assertEqual(tt.encode(""), "")
+        self.assertEqual(tt.encode("a"), "a")
+        self.assertEqual(tt.encode("a/b:?"), "a=b+^3f")
 
     def test02_decode(self):
         """Test decode."""
         tt = Layout_NNNN_Tuple_Tree()
-        self.assertEqual(tt.decode(''), '')
-        self.assertEqual(tt.decode('a'), 'a')
-        self.assertEqual(tt.decode('a=b+^3f'), 'a/b:?')
+        self.assertEqual(tt.decode(""), "")
+        self.assertEqual(tt.decode("a"), "a")
+        self.assertEqual(tt.decode("a=b+^3f"), "a/b:?")
 
     def test03_identifier_to_path(self):
         """Test path creation."""
         tt = Layout_NNNN_Tuple_Tree(tuple_size=2)
-        self.assertEqual(tt.identifier_to_path(''), '')
-        self.assertEqual(tt.identifier_to_path('a'), 'a/a')
-        self.assertEqual(tt.identifier_to_path('ab'), 'ab/ab')
-        self.assertEqual(tt.identifier_to_path('abc'), 'ab/c/abc')
-        self.assertEqual(tt.identifier_to_path('abcde'), 'ab/cd/e/abcde')
+        self.assertEqual(tt.identifier_to_path(""), "")
+        self.assertEqual(tt.identifier_to_path("a"), "a/a")
+        self.assertEqual(tt.identifier_to_path("ab"), "ab/ab")
+        self.assertEqual(tt.identifier_to_path("abc"), "ab/c/abc")
+        self.assertEqual(tt.identifier_to_path("abcde"), "ab/cd/e/abcde")
         tt = Layout_NNNN_Tuple_Tree(tuple_size=3)
-        self.assertEqual(tt.identifier_to_path('abcdefg'), 'abc/def/g/abcdefg')
-        self.assertEqual(tt.identifier_to_path('abcdefgh'), 'abc/def/gh/abcdefgh')
-        self.assertEqual(tt.identifier_to_path('abcdefghi'), 'abc/def/ghi/abcdefghi')
+        self.assertEqual(tt.identifier_to_path("abcdefg"), "abc/def/g/abcdefg")
+        self.assertEqual(tt.identifier_to_path("abcdefgh"), "abc/def/gh/abcdefgh")
+        self.assertEqual(tt.identifier_to_path("abcdefghi"), "abc/def/ghi/abcdefghi")
diff --git a/tests/test_layout_nnnn_uuid_quadtree.py b/tests/test_layout_nnnn_uuid_quadtree.py
index 47cdf7b..9d98a39 100644
--- a/tests/test_layout_nnnn_uuid_quadtree.py
+++ b/tests/test_layout_nnnn_uuid_quadtree.py
@@ -9,24 +9,24 @@ class TestAll(unittest.TestCase):
     def test01_encode(self):
         """Test NOOP encode."""
         uuqt = Layout_NNNN_UUID_Quadtree()
-        self.assertEqual(uuqt.encode(''), '')
-        self.assertEqual(uuqt.encode('a'), 'a')
-        self.assertEqual(uuqt.encode('a/b:?'), 'a/b:?')
+        self.assertEqual(uuqt.encode(""), "")
+        self.assertEqual(uuqt.encode("a"), "a")
+        self.assertEqual(uuqt.encode("a/b:?"), "a/b:?")
 
     def test02_decode(self):
         """Test NOOP decode."""
         uuqt = Layout_NNNN_UUID_Quadtree()
-        self.assertEqual(uuqt.decode(''), '')
-        self.assertEqual(uuqt.decode('a'), 'a')
-        self.assertEqual(uuqt.decode('a/b:?'), 'a/b:?')
+        self.assertEqual(uuqt.decode(""), "")
+        self.assertEqual(uuqt.decode("a"), "a")
+        self.assertEqual(uuqt.decode("a/b:?"), "a/b:?")
 
     def test03_identifier_to_path(self):
         """Test path creation."""
         uuqt = Layout_NNNN_UUID_Quadtree()
-        self.assertEqual(uuqt.identifier_to_path('urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8'),
-                         '6ba7/b810/9dad/11d1/80b4/00c0/4fd4/30c8')
+        self.assertEqual(uuqt.identifier_to_path("urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8"),
+                         "6ba7/b810/9dad/11d1/80b4/00c0/4fd4/30c8")
         # Bad ones
-        self.assertRaises(Exception, uuqt.identifier_to_path, '')
-        self.assertRaises(Exception, uuqt.identifier_to_path, '6ba7b810-9dad-11d1-80b4-00c04fd430c8')
-        self.assertRaises(Exception, uuqt.identifier_to_path, 'uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8')
-        self.assertRaises(Exception, uuqt.identifier_to_path, 'urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430cX')
+        self.assertRaises(Exception, uuqt.identifier_to_path, "")
+        self.assertRaises(Exception, uuqt.identifier_to_path, "6ba7b810-9dad-11d1-80b4-00c04fd430c8")
+        self.assertRaises(Exception, uuqt.identifier_to_path, "uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8")
+        self.assertRaises(Exception, uuqt.identifier_to_path, "urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430cX")
diff --git a/tests/test_namaste.py b/tests/test_namaste.py
index ace3699..ffbaf49 100644
--- a/tests/test_namaste.py
+++ b/tests/test_namaste.py
@@ -14,96 +14,96 @@ class TestAll(unittest.TestCase):
 
     def test01_content_to_tvalue(self):
         """Test content_to_tvalue function."""
-        self.assertEqual(content_to_tvalue(''), '')
-        self.assertEqual(content_to_tvalue('something'), 'something')
-        self.assertEqual(content_to_tvalue('a_b-c.d.EEE123'), 'a_b-c.d.EEE123')
-        self.assertEqual(content_to_tvalue('  \t hello   \n'), 'hello')
-        self.assertEqual(content_to_tvalue('x' * 100), 'x' * 40)
-        self.assertEqual(content_to_tvalue('0!@#$%^&*()\\/1'), '0____________1')
-        self.assertEqual(content_to_tvalue('ocfl_object_1.0'), 'ocfl_object_1.0')
+        self.assertEqual(content_to_tvalue(""), "")
+        self.assertEqual(content_to_tvalue("something"), "something")
+        self.assertEqual(content_to_tvalue("a_b-c.d.EEE123"), "a_b-c.d.EEE123")
+        self.assertEqual(content_to_tvalue("  \t hello   \n"), "hello")
+        self.assertEqual(content_to_tvalue("x" * 100), "x" * 40)
+        self.assertEqual(content_to_tvalue("0!@#$%^&*()\\/1"), "0____________1")
+        self.assertEqual(content_to_tvalue("ocfl_object_1.0"), "ocfl_object_1.0")
 
     def test02_find_namastes(self):
         """Test find_namastes."""
         # With plain filesystem
-        namastes1 = find_namastes(0, 'tests/testdata/namaste')
-        self.assertEqual({x.tvalue for x in namastes1}, {'frog', 'bison', 'snake'})
-        self.assertRaises(NamasteException, find_namastes, 0, 'tests/testdata/namaste', limit=2)
-        # With pysf filesystem
-        tdfs = fs.open_fs('tests/testdata')
-        namastes2 = find_namastes(0, 'namaste', pyfs=tdfs)
-        self.assertEqual({x.tvalue for x in namastes2}, {'frog', 'bison', 'snake'})
-        self.assertRaises(NamasteException, find_namastes, 0, 'namaste', pyfs=tdfs, limit=1)
+        namastes1 = find_namastes(0, "tests/testdata/namaste")
+        self.assertEqual({x.tvalue for x in namastes1}, {"frog", "bison", "snake"})
+        self.assertRaises(NamasteException, find_namastes, 0, "tests/testdata/namaste", limit=2)
+        # With pyfs filesystem
+        tdfs = fs.open_fs("tests/testdata")
+        namastes2 = find_namastes(0, "namaste", pyfs=tdfs)
+        self.assertEqual({x.tvalue for x in namastes2}, {"frog", "bison", "snake"})
+        self.assertRaises(NamasteException, find_namastes, 0, "namaste", pyfs=tdfs, limit=1)
 
     def test03_get_namaste(self):
         """Test get_namaste."""
-        n = get_namaste(1, 'tests/testdata/namaste')
-        self.assertEqual(n.tvalue, 'red')
+        n = get_namaste(1, "tests/testdata/namaste")
+        self.assertEqual(n.tvalue, "red")
         # More than one
-        self.assertRaises(NamasteException, get_namaste, 0, 'tests/testdata/namaste')
+        self.assertRaises(NamasteException, get_namaste, 0, "tests/testdata/namaste")
         # None
-        self.assertRaises(NamasteException, get_namaste, 4, 'tests/testdata/namaste')
+        self.assertRaises(NamasteException, get_namaste, 4, "tests/testdata/namaste")
 
     def test11_init(self):
         """Test initialization."""
         n = Namaste()
         self.assertEqual(n.d, 0)
         self.assertEqual(n._tr_func, content_to_tvalue)  # pylint: disable=protected-access
-        n = Namaste(0, 'myspec')
+        n = Namaste(0, "myspec")
         self.assertEqual(n.d, 0)
-        self.assertEqual(n.content, 'myspec')
-        n = Namaste(d=0, content='whatevs', tvalue='myspec_1', tr_func=lambda x: x[:3])
-        self.assertEqual(n.content, 'whatevs')
+        self.assertEqual(n.content, "myspec")
+        n = Namaste(d=0, content="whatevs", tvalue="myspec_1", tr_func=lambda x: x[:3])
+        self.assertEqual(n.content, "whatevs")
 
     def test12_filename(self):
         """Test filename property."""
         n = Namaste()
-        self.assertEqual(n.filename, '0=')
-        n = Namaste(2, 'wibble')
-        self.assertEqual(n.filename, '2=wibble')
+        self.assertEqual(n.filename, "0=")
+        n = Namaste(2, "wibble")
+        self.assertEqual(n.filename, "2=wibble")
 
     def test13_tvalue(self):
         """Test tvalue property."""
         # Explicitly set
-        n = Namaste(tvalue='xyz')
-        self.assertEqual(n.tvalue, 'xyz')
+        n = Namaste(tvalue="xyz")
+        self.assertEqual(n.tvalue, "xyz")
         # Derived from content
-        n = Namaste(content='x y z\n')
-        self.assertEqual(n.tvalue, 'x_y_z')
+        n = Namaste(content="x y z\n")
+        self.assertEqual(n.tvalue, "x_y_z")
 
     def test14_write(self):
         """Test write method."""
-        tempdir = tempfile.mkdtemp(prefix='test_namaste')
+        tempdir = tempfile.mkdtemp(prefix="test_namaste")
         # Plain OS method
-        n = Namaste(0, 'balloon')
+        n = Namaste(0, "balloon")
         n.write(tempdir)
-        filepath = os.path.join(tempdir, '0=balloon')
+        filepath = os.path.join(tempdir, "0=balloon")
         self.assertTrue(os.path.isfile(filepath))
-        with open(filepath, 'r', encoding="utf-8") as fh:
-            self.assertEqual(fh.read(), 'balloon\n')
+        with open(filepath, "r", encoding="utf-8") as fh:
+            self.assertEqual(fh.read(), "balloon\n")
         # With fs filesystem
         tmpfs = fs.tempfs.TempFS()
-        n = Namaste(1, 'jelly')
+        n = Namaste(1, "jelly")
         n.write(pyfs=tmpfs)
-        self.assertTrue(tmpfs.isfile('1=jelly'))
-        self.assertEqual(tmpfs.readtext('1=jelly'), 'jelly\n')
+        self.assertTrue(tmpfs.isfile("1=jelly"))
+        self.assertEqual(tmpfs.readtext("1=jelly"), "jelly\n")
 
     def test15_check_content(self):
         """Test check_content method."""
-        Namaste(0, 'frog').check_content('tests/testdata/namaste')
-        self.assertRaises(NamasteException, Namaste().check_content, 'tests/testdata/namaste')
-        self.assertRaises(NamasteException, Namaste(0, 'a').check_content, 'tests/testdata/namaste/does_not_exist')
-        self.assertRaises(NamasteException, Namaste(0, 'bison').check_content, 'tests/testdata/namaste')
+        Namaste(0, "frog").check_content("tests/testdata/namaste")
+        self.assertRaises(NamasteException, Namaste().check_content, "tests/testdata/namaste")
+        self.assertRaises(NamasteException, Namaste(0, "a").check_content, "tests/testdata/namaste/does_not_exist")
+        self.assertRaises(NamasteException, Namaste(0, "bison").check_content, "tests/testdata/namaste")
         # Using pyfs...
         tmpfs = fs.tempfs.TempFS()
-        tmpfs.writetext('9=niner', 'niner\n')
-        tmpfs.writetext('8=smiley', 'FROWNY\n')
-        Namaste(9, 'niner').check_content(pyfs=tmpfs)
-        Namaste(9, 'niner').check_content(dir='', pyfs=tmpfs)
-        self.assertRaises(NamasteException, Namaste(8, 'smiley').check_content, pyfs=tmpfs)
-        self.assertRaises(NamasteException, Namaste(7, 'not-there').check_content, pyfs=tmpfs)
+        tmpfs.writetext("9=niner", "niner\n")
+        tmpfs.writetext("8=smiley", "FROWNY\n")
+        Namaste(9, "niner").check_content(pyfs=tmpfs)
+        Namaste(9, "niner").check_content(dir="", pyfs=tmpfs)
+        self.assertRaises(NamasteException, Namaste(8, "smiley").check_content, pyfs=tmpfs)
+        self.assertRaises(NamasteException, Namaste(7, "not-there").check_content, pyfs=tmpfs)
 
     def test16_content_ok(self):
         """Test content_ok method."""
-        self.assertTrue(Namaste(0, 'frog').content_ok('tests/testdata/namaste'))
-        self.assertFalse(Namaste().content_ok('tests/testdata/namaste'))
-        self.assertFalse(Namaste(0, 'bison').content_ok('tests/testdata/namaste'))
+        self.assertTrue(Namaste(0, "frog").content_ok("tests/testdata/namaste"))
+        self.assertFalse(Namaste().content_ok("tests/testdata/namaste"))
+        self.assertFalse(Namaste(0, "bison").content_ok("tests/testdata/namaste"))
diff --git a/tests/test_object.py b/tests/test_object.py
index 0fd2b29..a8d96e8 100644
--- a/tests/test_object.py
+++ b/tests/test_object.py
@@ -7,28 +7,14 @@
 import fs
 import fs.tempfs
 
-from ocfl.object import Object, ObjectException, parse_version_directory
+from ocfl.inventory import Inventory
+from ocfl.object import Object, ObjectException
 from ocfl.version_metadata import VersionMetadata
 
 
 class TestAll(unittest.TestCase):
     """TestAll class to run tests."""
 
-    def test02_parse_version_directory(self):
-        """Test parse_version_directory function."""
-        self.assertEqual(parse_version_directory('v1'), 1)
-        self.assertEqual(parse_version_directory('v00001'), 1)
-        self.assertEqual(parse_version_directory('v99999'), 99999)
-        # Bad
-        self.assertRaises(Exception, parse_version_directory, None)
-        self.assertRaises(Exception, parse_version_directory, '')
-        self.assertRaises(Exception, parse_version_directory, '1')
-        self.assertRaises(Exception, parse_version_directory, 'v0')
-        self.assertRaises(Exception, parse_version_directory, 'v-1')
-        self.assertRaises(Exception, parse_version_directory, 'v0000')
-        self.assertRaises(Exception, parse_version_directory, 'vv')
-        self.assertRaises(Exception, parse_version_directory, 'v000001')
-
     def test00_init(self):
         """Test Object init."""
         oo = Object()
@@ -41,13 +27,13 @@ def test00_init(self):
         self.assertEqual(oo.digest_algorithm, 'sha1')
         self.assertEqual(oo.fixity, ['md5', 'crc16'])
 
-    def test01_open_fs(self):
-        """Test open_fs."""
+    def test01_open_obj_fs(self):
+        """Test open_obj_fs."""
         oo = Object()
         self.assertEqual(oo.obj_fs, None)
-        oo.open_fs('tests')
+        oo.open_obj_fs('tests')
         self.assertNotEqual(oo.obj_fs, None)
-        self.assertRaises(ObjectException, oo.open_fs, 'tests/testdata/i_do_not_exist')
+        self.assertRaises(ObjectException, oo.open_obj_fs, 'tests/testdata/i_do_not_exist')
 
     def test03_digest(self):
         """Test digest wrapper mathod."""
@@ -62,49 +48,49 @@ def test04_start_inventory(self):
         """Test start_inventory mehthod stub."""
         oo = Object(identifier="info:a", digest_algorithm="sha256")
         inventory = oo.start_inventory()
-        self.assertEqual(inventory['id'], "info:a")
-        self.assertEqual(inventory['digestAlgorithm'], "sha256")
-        self.assertEqual(inventory['versions'], {})
-        self.assertEqual(inventory['manifest'], {})
-        self.assertNotIn('contentDirectory', inventory)
-        self.assertNotIn('fixity', inventory)
+        self.assertEqual(inventory.id, "info:a")
+        self.assertEqual(inventory.digest_algorithm, "sha256")
+        self.assertEqual(inventory.versions_block, {})
+        self.assertEqual(inventory.manifest, {})
+        self.assertNotIn('contentDirectory', inventory.data)
+        self.assertNotIn('fixity', inventory.data)
+        #
         oo = Object(identifier="info:b", digest_algorithm="sha256",
                     fixity=['md5', 'sha1'])
         inventory = oo.start_inventory()
-        self.assertEqual(inventory['fixity'], {'md5': {}, 'sha1': {}})
+        self.assertEqual(inventory.fixity, {'md5': {}, 'sha1': {}})
         #
         oo = Object(identifier="info:b", content_directory="stuff")
         inventory = oo.start_inventory()
-        self.assertEqual(inventory['id'], "info:b")
-        self.assertEqual(inventory['contentDirectory'], "stuff")
-        self.assertEqual(inventory['digestAlgorithm'], "sha512")
+        self.assertEqual(inventory.id, "info:b")
+        self.assertEqual(inventory.content_directory, "stuff")
+        self.assertEqual(inventory.digest_algorithm, "sha512")
 
     def test05_add_version(self):
         """Test add_version method."""
         self.maxDiff = None
         oo = Object(digest_algorithm="md5")
-        inventory = {'manifest': {}, 'versions': {}}
+        inventory = Inventory({'manifest': {}, 'versions': {}})
         with open('fixtures/1.0/content/spec-ex-full/v1_inventory.json', 'r', encoding="utf-8") as fh:
             v_inventory = json.load(fh)
         metadata = VersionMetadata(inventory=v_inventory, version='v1')
         src_fs = fs.open_fs('fixtures/1.0/content/spec-ex-full')
         oo.add_version(inventory=inventory, src_fs=src_fs,
                        src_dir='v1', vdir='v1', metadata=metadata)
-        self.assertEqual(inventory['head'], 'v1')
-        self.assertEqual(inventory['manifest'],
+        self.assertEqual(inventory.head, 'v1')
+        self.assertEqual(inventory.manifest,
                          {'184f84e28cbe75e050e9c25ea7f2e939': ['v1/content/foo/bar.xml'],
                           'c289c8ccd4bab6e385f5afdd89b5bda2': ['v1/content/image.tiff'],
                           'd41d8cd98f00b204e9800998ecf8427e': ['v1/content/empty.txt']})
-        self.assertEqual(inventory['versions'],
-                         {"v1":
-                          {'created': '2018-01-01T01:01:01Z',
-                           'message': 'Initial import',
-                           'state': {
-                               '184f84e28cbe75e050e9c25ea7f2e939': ['foo/bar.xml'],
-                               'c289c8ccd4bab6e385f5afdd89b5bda2': ['image.tiff'],
-                               'd41d8cd98f00b204e9800998ecf8427e': ['empty.txt']},
-                           'user': {'address': 'alice@example.com', 'name': 'Alice'}}})
-        self.assertNotIn('fixity', inventory)
+        self.assertEqual(inventory.versiondata("v1"),
+                         {'created': '2018-01-01T01:01:01Z',
+                          'message': 'Initial import',
+                          'state': {
+                              '184f84e28cbe75e050e9c25ea7f2e939': ['foo/bar.xml'],
+                              'c289c8ccd4bab6e385f5afdd89b5bda2': ['image.tiff'],
+                              'd41d8cd98f00b204e9800998ecf8427e': ['empty.txt']},
+                          'user': {'address': 'alice@example.com', 'name': 'Alice'}})
+        self.assertNotIn('fixity', inventory.data)
         # Now add second version to check forward delta
         with open('fixtures/1.0/content/spec-ex-full/v2_inventory.json', 'r', encoding="utf-8") as fh:
             v_inventory = json.load(fh)
@@ -112,13 +98,13 @@ def test05_add_version(self):
         src_fs = fs.open_fs('fixtures/1.0/content/spec-ex-full/v2')
         oo.add_version(inventory=inventory, src_fs=src_fs,
                        src_dir='', vdir='v2', metadata=metadata)
-        self.assertEqual(inventory['head'], 'v2')
-        self.assertEqual(inventory['manifest'],
+        self.assertEqual(inventory.head, 'v2')
+        self.assertEqual(inventory.manifest,
                          {'184f84e28cbe75e050e9c25ea7f2e939': ['v1/content/foo/bar.xml'],
                           '2673a7b11a70bc7ff960ad8127b4adeb': ['v2/content/foo/bar.xml'],
                           'c289c8ccd4bab6e385f5afdd89b5bda2': ['v1/content/image.tiff'],
                           'd41d8cd98f00b204e9800998ecf8427e': ['v1/content/empty.txt']})
-        self.assertEqual(inventory['versions']['v2'],
+        self.assertEqual(inventory.versiondata('v2'),
                          {'created': '2018-02-02T02:02:02Z',
                           'message': 'Fix bar.xml, remove image.tiff, add empty2.txt',
                           'state': {
@@ -127,7 +113,7 @@ def test05_add_version(self):
                           'user': {'address': 'bob@example.com', 'name': 'Bob'}})
         # Now with fixity
         oo = Object(digest_algorithm="md5", fixity=['sha1'])
-        inventory = {'manifest': {}, 'versions': {}, 'fixity': {'sha1': {}}}
+        inventory = Inventory({'manifest': {}, 'versions': {}, 'fixity': {'sha1': {}}})
         md1 = VersionMetadata()
         with open('fixtures/1.0/content/spec-ex-full/v1_inventory.json', 'r', encoding="utf-8") as fh:
             v_inventory = json.load(fh)
@@ -140,10 +126,10 @@ def test05_add_version(self):
             'v1/content/empty.txt': 'empty.txt',
             'v1/content/foo/bar.xml': 'foo/bar.xml'
         })
-        self.assertEqual(len(inventory['fixity']['sha1']), 3)
+        self.assertEqual(len(inventory.fixity['sha1']), 3)
         # Test dedupe=False and forward_delta=False settings
         oo = Object(dedupe=False, forward_delta=False, fixity=['md5'])
-        inventory = {'manifest': {}, 'versions': {}, 'fixity': {'md5': {}}}
+        inventory = Inventory({'manifest': {}, 'versions': {}, 'fixity': {'md5': {}}})
         md1 = VersionMetadata(inventory={
             "id": "http://example.org/dedupe_content",
             "versions": {
@@ -164,7 +150,7 @@ def test05_add_version(self):
             'v1/content/empty1.txt': 'v1/empty1.txt',
             'v1/content/empty2.txt': 'v1/empty2.txt',
             'v1/content/empty3.txt': 'v1/empty3.txt'})
-        self.assertEqual(inventory['fixity']['md5'], {"d41d8cd98f00b204e9800998ecf8427e": [
+        self.assertEqual(inventory.fixity['md5'], {"d41d8cd98f00b204e9800998ecf8427e": [
             "v1/content/empty1.txt", "v1/content/empty2.txt", "v1/content/empty3.txt"]})
         # Add a second version which will test for forward_delta=False
         md2 = VersionMetadata(inventory={
@@ -184,7 +170,7 @@ def test05_add_version(self):
         # Because of forward_delta=False we will have an additional copy of the empty file
         self.assertEqual(manifest_to_srcfile, {
             'v2/content/empty4.txt': 'v2/empty4.txt'})
-        self.assertEqual(inventory['fixity']['md5'], {"d41d8cd98f00b204e9800998ecf8427e": [
+        self.assertEqual(inventory.fixity['md5'], {"d41d8cd98f00b204e9800998ecf8427e": [
             "v1/content/empty1.txt", "v1/content/empty2.txt",
             "v1/content/empty3.txt", "v2/content/empty4.txt"]})
 
@@ -193,17 +179,16 @@ def test06_build_inventory(self):
         oo = Object(digest_algorithm="md5", spec_version='1.0')
         src_fs = fs.open_fs('fixtures/1.0/content/spec-ex-full')
         inventory = None
-        for (dummy_vdir, inventory, dummy_manifest_to_srcfile) in oo.build_inventory(src_fs,
-                                                                                     metadata=VersionMetadata()):
+        for (dummy_vdir, inventory, dummy_manifest_to_srcfile) in oo.build_inventory(src_fs):
             pass
-        self.assertEqual(inventory['type'], 'https://ocfl.io/1.0/spec/#inventory')
-        self.assertEqual(inventory['head'], 'v3')
-        self.assertEqual(inventory['manifest'],
+        self.assertEqual(inventory.data['type'], 'https://ocfl.io/1.0/spec/#inventory')
+        self.assertEqual(inventory.head, 'v3')
+        self.assertEqual(inventory.manifest,
                          {'184f84e28cbe75e050e9c25ea7f2e939': ['v1/content/foo/bar.xml'],
                           '2673a7b11a70bc7ff960ad8127b4adeb': ['v2/content/foo/bar.xml'],
                           'c289c8ccd4bab6e385f5afdd89b5bda2': ['v1/content/image.tiff'],
                           'd41d8cd98f00b204e9800998ecf8427e': ['v1/content/empty.txt']})
-        self.assertEqual(len(inventory['versions']), 3)
+        self.assertEqual(len(inventory.version_numbers), 3)
 
     def test07_write_object_declaration(self):
         """Test write_object_declaration."""
@@ -216,7 +201,7 @@ def test08_write_inventory_and_sidecar(self):
         """Test write_object_and_sidecar."""
         tmpfs = fs.tempfs.TempFS(identifier='test_write_inventory_and_sidecar')
         oo = Object(obj_fs=tmpfs)
-        oo.write_inventory_and_sidecar({'abc': 'def'})
+        oo.write_inventory_and_sidecar(Inventory({'abc': 'def'}))
         self.assertEqual(set(tmpfs.listdir('')),
                          set(['inventory.json', 'inventory.json.sha512']))
         with tmpfs.open('inventory.json') as fh:
@@ -227,7 +212,7 @@ def test08_write_inventory_and_sidecar(self):
         # and now making directory
         oo = Object(obj_fs=tmpfs)
         invdir = 'xxx'
-        oo.write_inventory_and_sidecar({'gh': 'ik'}, invdir)
+        oo.write_inventory_and_sidecar(Inventory({'gh': 'ik'}), invdir)
         self.assertEqual(set(tmpfs.listdir(invdir)),
                          set(['inventory.json', 'inventory.json.sha512']))
         with tmpfs.open(fs.path.join(invdir, 'inventory.json')) as fh:
@@ -241,19 +226,23 @@ def test09_build(self):
         self.assertRaises(ObjectException, oo.build, srcdir='fixtures/1.0/content/spec-ex-full')
         oo.id = 'uri:firkin'
         objdir = os.path.join(tempdir, '1')
-        oo.build(srcdir='fixtures/1.0/content/spec-ex-full',
-                 metadata=VersionMetadata(),
-                 objdir=objdir)
+        inv = oo.build(srcdir='fixtures/1.0/content/spec-ex-full',
+                       versions_metadata={1: VersionMetadata(message="Version 1"),
+                                          2: VersionMetadata(message="Version 2"),
+                                          3: VersionMetadata(message="Version 3")},
+                       objdir=objdir)
         self.assertEqual(set(os.listdir(objdir)),
                          set(['0=ocfl_object_1.0',
                               'inventory.json', 'inventory.json.sha512',
                               'v1', 'v2', 'v3']))
-        # If objdir is None, nothing is written but the inventory is returned
-        inventory = oo.build(srcdir='fixtures/1.0/content/spec-ex-full',
-                             metadata=VersionMetadata(),
-                             objdir=None)
-        self.assertEqual(inventory["head"], "v3")
-        self.assertEqual(inventory["id"], "uri:firkin")
+        self.assertEqual(set(os.listdir(objdir + "/v1/content")),
+                         set(['foo', 'image.tiff', 'empty.txt']))
+        self.assertEqual(inv.version("v1").message, "Version 1")
+        # If objdir is None, nothing is written but the inventory is still returned
+        inv = oo.build(srcdir='fixtures/1.0/content/spec-ex-full',
+                       objdir=None)
+        self.assertEqual(inv.head, "v3")
+        self.assertEqual(inv.id, "uri:firkin")
 
     def test10_create(self):
         """Test create method."""
@@ -262,13 +251,15 @@ def test10_create(self):
         self.assertRaises(ObjectException, oo.create, srcdir='fixtures/1.0/content/spec-ex-full/v1')
         oo.id = 'uri:kliderkin'
         objdir = os.path.join(tempdir, '1')
-        oo.create(srcdir='fixtures/1.0/content/spec-ex-full/v1',
-                  metadata=VersionMetadata(),
-                  objdir=objdir)
+        inv = oo.create(srcdir='fixtures/1.0/content/spec-ex-full/v1',
+                        metadata=VersionMetadata(),
+                        objdir=objdir)
         self.assertEqual(set(os.listdir(objdir)),
                          set(['0=ocfl_object_1.0',
                               'inventory.json', 'inventory.json.sha512',
                               'v1']))
+        self.assertEqual(inv.head, "v1")
+        self.assertEqual(len(inv.version("v1").state), 3)
 
     def test11_update(self):
         """Test update method."""
@@ -362,24 +353,24 @@ def test_validate_inventory(self):
     def test_parse_inventory(self):
         """Test parse_inventory method."""
         oo = Object()
-        oo.open_fs('fixtures/1.1/good-objects/minimal_one_version_one_file')
+        oo.open_obj_fs('fixtures/1.1/good-objects/minimal_one_version_one_file')
         inv = oo.parse_inventory()
-        self.assertEqual(inv['id'], "ark:123/abc")
+        self.assertEqual(inv.id, "ark:123/abc")
         digest = "43a43fe8a8a082d3b5343dfaf2fd0c8b8e370675b1f376e92e9994612c33ea255b11298269d72f797399ebb94edeefe53df243643676548f584fb8603ca53a0f"
-        self.assertEqual(inv['manifest'][digest],
+        self.assertEqual(inv.manifest[digest],
                          ["v1/content/a_file.txt"])
-        self.assertEqual(inv['versions']['v1']['state'][digest],
+        self.assertEqual(inv.version('v1').state[digest],
                          ["a_file.txt"])
         # Digest normalization on read -- file has mixed case but result should be same
-        oo.open_fs('fixtures/1.1/good-objects/minimal_mixed_digests')
+        oo.open_obj_fs('fixtures/1.1/good-objects/minimal_mixed_digests')
         inv = oo.parse_inventory()
-        self.assertEqual(inv['id'], "http://example.org/minimal_mixed_digests")
-        self.assertEqual(inv['manifest'][digest],
+        self.assertEqual(inv.id, "http://example.org/minimal_mixed_digests")
+        self.assertEqual(inv.manifest[digest],
                          ["v1/content/a_file.txt"])
-        self.assertEqual(inv['versions']['v1']['state'][digest],
+        self.assertEqual(inv.version('v1').state[digest],
                          ["a_file.txt"])
         # Error cases
-        oo.open_fs('fixtures/1.0/bad-objects/E036_no_id')
+        oo.open_obj_fs('fixtures/1.0/bad-objects/E036_no_id')
         self.assertRaises(ObjectException, oo.parse_inventory)
 
     def test_map_filepath(self):
diff --git a/tests/test_object_utils.py b/tests/test_object_utils.py
index fcf1716..ef39a57 100644
--- a/tests/test_object_utils.py
+++ b/tests/test_object_utils.py
@@ -1,7 +1,9 @@
 # -*- coding: utf-8 -*-
-"""Object tests."""
+"""Object Utilities tests."""
 import unittest
-from ocfl.object_utils import remove_first_directory, make_unused_filepath, next_version, find_path_type
+from ocfl.object_utils import ObjectException, remove_first_directory, \
+    make_unused_filepath, first_version_directory, next_version_directory, \
+    find_path_type, parse_version_directory
 
 
 class TestAll(unittest.TestCase):
@@ -9,36 +11,48 @@ class TestAll(unittest.TestCase):
 
     def test_remove_first_directory(self):
         """Test remove_first_directory function."""
-        self.assertEqual(remove_first_directory(''), '')
-        self.assertEqual(remove_first_directory('a'), '')
-        self.assertEqual(remove_first_directory('a/b'), 'b')
-        self.assertEqual(remove_first_directory('a/b/'), 'b')
-        self.assertEqual(remove_first_directory('a/b/c'), 'b/c')
+        self.assertEqual(remove_first_directory(""), "")
+        self.assertEqual(remove_first_directory("a"), "")
+        self.assertEqual(remove_first_directory("a/b"), "b")
+        self.assertEqual(remove_first_directory("a/b/"), "b")
+        self.assertEqual(remove_first_directory("a/b/c"), "b/c")
 
     def test_make_unused_filepath(self):
         """Test make_unused_filepath function."""
-        self.assertEqual(make_unused_filepath('x/y', []), 'x/y__2')
-        self.assertEqual(make_unused_filepath('x/y', {'x/y__2': 1}), 'x/y__3')
-        self.assertEqual(make_unused_filepath('x/y', {'x/y': 1}, ''), 'x/y2')
-        self.assertEqual(make_unused_filepath('x/y', ['x/y', 'x/y2', 'x/y3'], ''), 'x/y4')
-
-    def test_next_version(self):
-        """Test next_version function."""
-        self.assertRaises(Exception, next_version, '1')
-        self.assertRaises(Exception, next_version, 1)
-        self.assertRaises(Exception, next_version, 'v1v')
+        self.assertEqual(make_unused_filepath("x/y", []), "x/y")
+        self.assertEqual(make_unused_filepath("x/y", set(["x/y", "x/y__2"])), "x/y__3")
+        self.assertEqual(make_unused_filepath("x/y", {"x/y": 1}, ""), "x/y2")
+        self.assertEqual(make_unused_filepath("x/y", ["x/y", "x/y2", "x/y3"], ""), "x/y4")
+
+    def test_first_version_directory(self):
+        """Test first_version_directory function."""
+        # trivial non zero-padded
+        self.assertEqual(first_version_directory(), "v1")
+        # good zero-padded
+        self.assertEqual(first_version_directory(4), "v0001")
+        self.assertEqual(first_version_directory(zero_padded_width=5), "v00001")
+        # errors
+        self.assertRaises(ObjectException, first_version_directory, zero_padded_width=1)
+        self.assertRaises(ObjectException, first_version_directory, zero_padded_width=6)
+        self.assertRaises(Exception, first_version_directory, zero_padded_width="a")
+
+    def test_next_version_directory(self):
+        """Test next_version_directory function."""
+        self.assertRaises(ObjectException, next_version_directory, "1")
+        self.assertRaises(ObjectException, next_version_directory, "v1v")
+        self.assertRaises(Exception, next_version_directory, 1)
         # good non-zero padded
-        self.assertEqual(next_version('v1'), 'v2')
-        self.assertEqual(next_version('v99'), 'v100')
-        self.assertEqual(next_version('v1234'), 'v1235')
+        self.assertEqual(next_version_directory("v1"), "v2")
+        self.assertEqual(next_version_directory("v99"), "v100")
+        self.assertEqual(next_version_directory(vdir="v1234"), "v1235")
         # good zero-padded
-        self.assertEqual(next_version('v01'), 'v02')
-        self.assertEqual(next_version('v00001'), 'v00002')
-        self.assertEqual(next_version('v00999'), 'v01000')
-        self.assertEqual(next_version('v0998'), 'v0999')
+        self.assertEqual(next_version_directory("v01"), "v02")
+        self.assertEqual(next_version_directory("v00001"), "v00002")
+        self.assertEqual(next_version_directory("v00999"), "v01000")
+        self.assertEqual(next_version_directory("v0998"), "v0999")
         # overflow
-        self.assertRaises(Exception, next_version, 'v09')
-        self.assertRaises(Exception, next_version, 'v0999')
+        self.assertRaises(ObjectException, next_version_directory, "v09")
+        self.assertRaises(ObjectException, next_version_directory, "v0999")
 
     def test_find_path_type(self):
         """Test find_path_type function."""
@@ -48,5 +62,20 @@ def test_find_path_type(self):
         self.assertIn("does not exist", find_path_type("this_path_does_not_exist"))
         self.assertIn("nor can parent", find_path_type("still_nope/nope_doesnt_exist"))
         self.assertEqual(find_path_type("ocfl"), "no 0= declaration file")
-        self.assertEqual(find_path_type("extra_fixtures/misc/multiple_declarations"), 'root')
+        self.assertEqual(find_path_type("extra_fixtures/misc/multiple_declarations"), "root")
         self.assertIn("unrecognized", find_path_type("extra_fixtures/misc/unknown_declaration"))
+
+    def test_parse_version_directory(self):
+        """Test parse_version_directory function."""
+        self.assertEqual(parse_version_directory("v1"), 1)
+        self.assertEqual(parse_version_directory("v00001"), 1)
+        self.assertEqual(parse_version_directory("v99999"), 99999)
+        # Bad
+        self.assertRaises(Exception, parse_version_directory, None)
+        self.assertRaises(ObjectException, parse_version_directory, "")
+        self.assertRaises(ObjectException, parse_version_directory, "1")
+        self.assertRaises(ObjectException, parse_version_directory, "v0")
+        self.assertRaises(ObjectException, parse_version_directory, "v-1")
+        self.assertRaises(ObjectException, parse_version_directory, "v0000")
+        self.assertRaises(ObjectException, parse_version_directory, "vv")
+        self.assertRaises(ObjectException, parse_version_directory, "v000001")
diff --git a/tests/test_storage_root.py b/tests/test_storage_root.py
index ebcfe13..3df13f0 100644
--- a/tests/test_storage_root.py
+++ b/tests/test_storage_root.py
@@ -16,38 +16,38 @@ class TestAll(unittest.TestCase):
 
     def test_get_layout(self):
         """Test everything, just a little."""
-        d = get_layout(layout_name='0002-flat-direct-storage-layout')
-        self.assertEqual(d.identifier_to_path('ab cd'), 'ab cd')
-        d = get_layout(layout_name='nnnn-tuple-tree')
-        self.assertEqual(d.identifier_to_path('abcd'), 'ab/cd/abcd')
-        d = get_layout('nnnn-uuid-quadtree')
-        self.assertEqual(d.identifier_to_path('urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8'),
-                         '6ba7/b810/9dad/11d1/80b4/00c0/4fd4/30c8')
+        d = get_layout(layout_name="0002-flat-direct-storage-layout")
+        self.assertEqual(d.identifier_to_path("ab cd"), "ab cd")
+        d = get_layout(layout_name="nnnn-tuple-tree")
+        self.assertEqual(d.identifier_to_path("abcd"), "ab/cd/abcd")
+        d = get_layout("nnnn-uuid-quadtree")
+        self.assertEqual(d.identifier_to_path("urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8"),
+                         "6ba7/b810/9dad/11d1/80b4/00c0/4fd4/30c8")
         # Errors
         self.assertRaises(Exception, get_layout)
         self.assertRaises(Exception, get_layout, None)
-        self.assertRaises(Exception, get_layout, 'unknown')
+        self.assertRaises(Exception, get_layout, "unknown")
 
     def test_init(self):
         """Test StorageRoot init."""
         s = StorageRoot()
         self.assertEqual(s.root, None)
         self.assertEqual(s.layout_name, None)
-        s = StorageRoot(root='a', layout_name='b')
-        self.assertEqual(s.root, 'a')
-        self.assertEqual(s.layout_name, 'b')
+        s = StorageRoot(root="a", layout_name="b")
+        self.assertEqual(s.root, "a")
+        self.assertEqual(s.layout_name, "b")
 
     def test_open_root_fs(self):
         """Test open_root_fs method."""
         s = StorageRoot()
         self.assertIs(s.root_fs, None)
-        tempdir = tempfile.mkdtemp(prefix='test_open_root_fs')
+        tempdir = tempfile.mkdtemp(prefix="test_open_root_fs")
         print(tempdir)
         s.root = tempdir
         s.open_root_fs()
         self.assertIsNot(s.root_fs, None)
         # Error - open without create, then succeed with create
-        rootdir = os.path.join(tempdir, 'xyz')
+        rootdir = os.path.join(tempdir, "xyz")
         s = StorageRoot()
         s.root = rootdir
         self.assertRaises(StorageRootException, s.open_root_fs)
@@ -56,7 +56,7 @@ def test_open_root_fs(self):
 
     def test_layout(self):
         """Test layout property."""
-        s = StorageRoot(root='x', layout_name='0002-flat-direct-storage-layout')
+        s = StorageRoot(root="x", layout_name="0002-flat-direct-storage-layout")
         self.assertTrue(isinstance(s.layout, Layout_0002_Flat_Direct))
 
     def test_traversal_error(self):
@@ -68,41 +68,41 @@ def test_traversal_error(self):
 
     def test_object_path(self):
         """Test object_path method."""
-        s = StorageRoot(root='x/y', layout_name='0002-flat-direct-storage-layout')
-        self.assertEqual(s.object_path('id1'), 'id1')
-        s = StorageRoot(root='z/a', layout_name='nnnn-uuid-quadtree')
-        self.assertEqual(s.object_path('urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8'), '6ba7/b810/9dad/11d1/80b4/00c0/4fd4/30c8')
+        s = StorageRoot(root="x/y", layout_name="0002-flat-direct-storage-layout")
+        self.assertEqual(s.object_path("id1"), "id1")
+        s = StorageRoot(root="z/a", layout_name="nnnn-uuid-quadtree")
+        self.assertEqual(s.object_path("urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8"), "6ba7/b810/9dad/11d1/80b4/00c0/4fd4/30c8")
 
     def test_initialize(self):
         """Test initialize method."""
-        tempdir = tempfile.mkdtemp(prefix='test_init_1')
-        s = StorageRoot(root=tempdir, layout_name='0002-flat-direct-storage-layout')
+        tempdir = tempfile.mkdtemp(prefix="test_init_1")
+        s = StorageRoot(root=tempdir, layout_name="0002-flat-direct-storage-layout")
         self.assertRaises(StorageRootException, s.initialize)
-        tempdir2 = os.path.join(tempdir, 'aaa')
-        s = StorageRoot(root=tempdir2, layout_name='0002-flat-direct-storage-layout')
-        s.initialize(spec_version='1.0')
-        self.assertTrue(os.path.isfile(os.path.join(tempdir2, '0=ocfl_1.0')))
-        tempdir2 = os.path.join(tempdir, 'bbb')
-        s = StorageRoot(root=tempdir2, layout_name='0002-flat-direct-storage-layout')
+        tempdir2 = os.path.join(tempdir, "aaa")
+        s = StorageRoot(root=tempdir2, layout_name="0002-flat-direct-storage-layout")
+        s.initialize(spec_version="1.0")
+        self.assertTrue(os.path.isfile(os.path.join(tempdir2, "0=ocfl_1.0")))
+        tempdir2 = os.path.join(tempdir, "bbb")
+        s = StorageRoot(root=tempdir2, layout_name="0002-flat-direct-storage-layout")
         s.initialize()
-        self.assertTrue(os.path.isfile(os.path.join(tempdir2, '0=ocfl_1.1')))
+        self.assertTrue(os.path.isfile(os.path.join(tempdir2, "0=ocfl_1.1")))
 
     def test_check_root_structure(self):
         """Test check_root_structure method."""
-        tempdir = os.path.join(tempfile.mkdtemp(prefix='test_root'), 'rrr')
-        s = StorageRoot(root=tempdir, layout_name='0002-flat-direct-storage-layout')
+        tempdir = os.path.join(tempfile.mkdtemp(prefix="test_root"), "rrr")
+        s = StorageRoot(root=tempdir, layout_name="0002-flat-direct-storage-layout")
         # No declaration
         os.mkdir(tempdir)
         s.open_root_fs()
         self.assertRaises(StorageRootException, s.check_root_structure)
         # Wrong declaration
-        decl = os.path.join(tempdir, '0=something_else')
-        with open(decl, 'w', encoding="utf-8") as fh:
+        decl = os.path.join(tempdir, "0=something_else")
+        with open(decl, "w", encoding="utf-8") as fh:
             fh.close()
         self.assertRaises(StorageRootException, s.check_root_structure)
         # Two declarations
-        decl2 = os.path.join(tempdir, '0=ocfl_1.0')
-        with open(decl2, 'w', encoding="utf-8") as fh:
+        decl2 = os.path.join(tempdir, "0=ocfl_1.0")
+        with open(decl2, "w", encoding="utf-8") as fh:
             fh.write("not correct")
             fh.close()
         self.assertRaises(StorageRootException, s.check_root_structure)
@@ -111,7 +111,7 @@ def test_check_root_structure(self):
         self.assertRaises(StorageRootException, s.check_root_structure)
         os.remove(decl2)
         # All good
-        with open(decl2, 'w', encoding="utf-8") as fh:
+        with open(decl2, "w", encoding="utf-8") as fh:
             fh.write("ocfl_1.0\n")
             fh.close()
         self.assertTrue(s.check_root_structure())
@@ -120,28 +120,28 @@ def test_parse_layout_file(self):
         """Test parse_layout_file method."""
         s = StorageRoot(root="mem://")
         s.open_root_fs(create=True)
-        s.root_fs.writetext('ocfl_layout.json', '{"extension": "aa", "description": "bb"}')
+        s.root_fs.writetext("ocfl_layout.json", '{"extension": "aa", "description": "bb"}')
         self.assertEqual(s.parse_layout_file(), ("aa", "bb"))
-        s.root_fs.writetext('ocfl_layout.json', '["aa", "bb"]')
+        s.root_fs.writetext("ocfl_layout.json", '["aa", "bb"]')
         self.assertRaises(StorageRootException, s.parse_layout_file)
-        s.root_fs.writetext('ocfl_layout.json', '{"extension": "yy", "description": ["zz"]}')
+        s.root_fs.writetext("ocfl_layout.json", '{"extension": "yy", "description": ["zz"]}')
         self.assertRaises(StorageRootException, s.parse_layout_file)
-        s.root_fs.remove('ocfl_layout.json')
-        s.root_fs.makedir('ocfl_layout.json')
+        s.root_fs.remove("ocfl_layout.json")
+        s.root_fs.makedir("ocfl_layout.json")
         self.assertRaises(StorageRootException, s.parse_layout_file)
 
     def test_object_paths(self):
         """Test object_paths generator."""
-        s = StorageRoot(root='extra_fixtures/good-storage-roots/fedora-root')
+        s = StorageRoot(root="extra_fixtures/good-storage-roots/fedora-root")
         s.open_root_fs()
         paths = list(s.object_paths())
         self.assertEqual(len(paths), 176)
-        self.assertIn('61/38/37/3fede0e4-d168-475a-9b51-edbed6f0d972', paths)
+        self.assertIn("61/38/37/3fede0e4-d168-475a-9b51-edbed6f0d972", paths)
         # Error cases
         log_io = io.StringIO()
         logger = logging.getLogger()
         logger.addHandler(logging.StreamHandler(log_io))
-        s = StorageRoot(root='zip://extra_fixtures/bad-storage-roots/simple-bad-root.zip')  # Using ZipFS
+        s = StorageRoot(root="zip://extra_fixtures/bad-storage-roots/simple-bad-root.zip")  # Using ZipFS
         s.open_root_fs()
         paths = list(s.object_paths())
         self.assertEqual(len(paths), 2)
@@ -153,40 +153,40 @@ def test_object_paths(self):
         self.assertIn("E004b - path='/object_unrecognized_declaration', declaration='0=special_object_yeah'", log_out)
         self.assertIn("E072 - path='/dir_with_file_but_no_declaration'", log_out)
         # Specific error cases
-        s = StorageRoot(root='extra_fixtures/bad-storage-roots/E072_root_with_file_not_in_object')
+        s = StorageRoot(root="extra_fixtures/bad-storage-roots/E072_root_with_file_not_in_object")
         s.open_root_fs()
         s.log = ValidationLogger()
-        self.assertEqual(list(s.object_paths()), ['dir2/minimal_no_content'])
+        self.assertEqual(list(s.object_paths()), ["dir2/minimal_no_content"])
         self.assertEqual(s.num_traversal_errors, 1)
-        self.assertIn('E072', s.log.codes)
+        self.assertIn("E072", s.log.codes)
         #
-        s = StorageRoot(root='zip://extra_fixtures/bad-storage-roots/E073_root_with_empty_dir.zip')
+        s = StorageRoot(root="zip://extra_fixtures/bad-storage-roots/E073_root_with_empty_dir.zip")
         s.open_root_fs()
         s.log = ValidationLogger()
         self.assertEqual(list(s.object_paths()), [])
         self.assertEqual(s.num_traversal_errors, 1)
-        self.assertIn('E073', s.log.codes)
+        self.assertIn("E073", s.log.codes)
 
     def test_validate(self):
         """Test validate method."""
-        s = StorageRoot(root='extra_fixtures/good-storage-roots/fedora-root')
+        s = StorageRoot(root="extra_fixtures/good-storage-roots/fedora-root")
         self.assertTrue(s.validate())
         self.assertEqual(s.num_objects, 176)
         self.assertEqual(s.good_objects, 176)
         # Simple case of three objects
-        s = StorageRoot(root='extra_fixtures/good-storage-roots/simple-root')
+        s = StorageRoot(root="extra_fixtures/good-storage-roots/simple-root")
         self.assertTrue(s.validate())
         self.assertEqual(s.num_objects, 3)
         self.assertEqual(s.good_objects, 3)
         # Reg extension will not give warning
-        s = StorageRoot(root='extra_fixtures/good-storage-roots/reg-extension-dir-root')
+        s = StorageRoot(root="extra_fixtures/good-storage-roots/reg-extension-dir-root")
         self.assertTrue(s.validate())
         self.assertEqual(s.num_objects, 1)
         self.assertEqual(s.good_objects, 1)
-        self.assertNotIn('W901', s.log.codes)
+        self.assertNotIn("W901", s.log.codes)
         # Unreg extension will give warning
-        s = StorageRoot(root='extra_fixtures/good-storage-roots/unreg-extension-dir-root')
+        s = StorageRoot(root="extra_fixtures/good-storage-roots/unreg-extension-dir-root")
         self.assertTrue(s.validate())
         self.assertEqual(s.num_objects, 1)
         self.assertEqual(s.good_objects, 1)
-        self.assertIn('W901', s.log.codes)
+        self.assertIn("W901", s.log.codes)
diff --git a/tests/test_validation_logger.py b/tests/test_validation_logger.py
index 864907a..f5aa83a 100644
--- a/tests/test_validation_logger.py
+++ b/tests/test_validation_logger.py
@@ -31,7 +31,7 @@ def test_log(self):
                     "xy": "Warning 999"
                 }
             }}
-        vl = ValidationLogger(lang='zz', validation_codes=vc)
+        vl = ValidationLogger(lang="zz", validation_codes=vc)
         vl.log("E333", is_error=True)
         self.assertEqual(len(vl.messages), 1)
         self.assertIn("Unknown error: E333 - params ({})", vl.messages[-1])
@@ -41,7 +41,7 @@ def test_log(self):
         vl.log("E999", is_error=True)
         self.assertEqual(len(vl.messages), 3)
         self.assertIn("[E999] No params here {}", vl.messages[-1])
-        vl.lang = 'yy'
+        vl.lang = "yy"
         vl.log("E999", is_error=True, param1="THAT")
         self.assertEqual(len(vl.messages), 4)
         self.assertIn("[E999] It is all broken, THAT - whoaaa!", vl.messages[-1])
diff --git a/tests/test_validator.py b/tests/test_validator.py
index fc46328..19140f8 100644
--- a/tests/test_validator.py
+++ b/tests/test_validator.py
@@ -7,12 +7,12 @@
 
 def extra_fixture_maybe_zip(filepath):
     """Filepath or URL for extra_fixture that may be a zip file."""
-    if filepath.endswith('.zip'):
+    if filepath.endswith(".zip"):
         zippath = filepath
     else:
-        zippath = filepath + '.zip'
+        zippath = filepath + ".zip"
     if os.path.isfile(zippath):
-        return 'zip://' + zippath
+        return "zip://" + zippath
     return filepath
 
 
@@ -21,200 +21,200 @@ class TestAll(unittest.TestCase):
 
     def test01_bad_v1_0(self):
         """Check bad v1.0 objects fail."""
-        for bad, codes in {'does_not_even_exist': ['E003e'],
-                           'E001_extra_dir_in_root': ['E001b'],
-                           'E001_extra_file_in_root': ['E001a'],
-                           'E001_invalid_version_format': ['E009'],  # E009 OK, see https://github.com/OCFL/fixtures/pull/80
-                           'E001_v2_file_in_root': ['E001a'],
-                           'E003_E063_empty': ['E003a', 'E063'],
-                           'E003_no_decl': ['E003a'],
-                           'E008_E036_no_versions_no_head': ['E008', 'E036d'],
-                           'E009_version_two_only': ['E009'],
-                           'E010_missing_versions': ['E046a'],  # FIXME - Check code https://github.com/OCFL/spec/issues/540
-                           'E010_skipped_versions': ['E010'],
-                           'E011_E013_invalid_padded_head_version': ['E011', 'E040', 'E064'],  # E011 only OK, see https://github.com/OCFL/spec/issues/541
-                           'E015_content_not_in_content_dir': ['E042a'],  # FIXME - What should test case and error be?
-                           'E017_invalid_content_dir': ['E017'],
-                           'E019_inconsistent_content_dir': ['E042c'],  # FIXME - What should error be? Should be E020
-                           'E023_extra_file': ['E023a'],
-                           'E023_old_manifest_missing_entries': ['E023b'],
-                           'E024_empty_dir_in_content': ['E024'],
-                           'E025_wrong_digest_algorithm': ['E025a'],
-                           'E033_inventory_bad_json': ['E033'],
-                           'E036_no_id': ['E036a'],
-                           'E036_no_head': ['E036d'],
-                           'E037_inconsistent_id': ['E037b'],
-                           'E040_head_not_most_recent': ['E040'],
-                           'E040_wrong_head_doesnt_exist': ['E040'],
-                           'E040_wrong_head_format': ['E040'],
-                           'E040_wrong_version_in_version_dir': ['E066a'],  # FIXME
-                           'E041_no_manifest': ['E041a'],
-                           'E041_manifest_not_object': ['E041c'],
-                           'E042_bad_manifest_content_path': ['E042a'],
-                           'E046_missing_version_dir': ['E046a'],
-                           'E046_root_not_most_recent': ['E046b'],
-                           'E049_E050_E054_bad_version_block_values': ['E049d', 'E050c', 'E054a', 'E094', 'E107'],
-                           'E049_created_no_timezone': ['E049a'],
-                           'E049_created_not_to_seconds': ['E049b'],
-                           'E050_manifest_digest_wrong_case': ['E050f'],
-                           'E050_state_digest_different_case': ['E050f'],
-                           'E050_state_repeated_digest': ['E050f'],
-                           'E053_E052_invalid_logical_paths': ['E052', 'E053'],
-                           'E056_null_fixity': ['E056a'],
-                           'E058_no_sidecar': ['E058a'],
-                           'E060_E064_root_inventory_digest_mismatch': ['E060', 'E064'],
-                           'E061_invalid_sidecar': ['E061'],
-                           'E063_no_inv': ['E063'],
-                           'E064_different_root_and_latest_inventories': ['E064'],
-                           'E066_E092_old_manifest_digest_incorrect': ['E066d', 'E066e', 'E092a'],
-                           'E066_algorithm_change_state_mismatch': ['E066b'],
-                           'E066_changed_v1_logical_path': ['E066b'],
-                           'E066_inconsistent_version_state': ['E066b'],
-                           'E066_changed_content_for_logical_path': ['E066c'],
-                           'E067_file_in_extensions_dir': ['E067'],
-                           'E092_bad_manifest_digest': ['E092a'],
-                           'E092_content_file_digest_mismatch': ['E092a'],
-                           'E092_algorithm_change_incorrect_digest': ['E092a'],
-                           'E092_E093_content_path_does_not_exist': ['E092b', 'E093b'],
-                           'E093_fixity_digest_mismatch': ['E093a'],
-                           'E093_fixity_digest_mismatch_in_v1': ['E093a'],
-                           'E094_message_not_a_string': ['E094'],
-                           'E095_conflicting_logical_paths': ['E095b'],
-                           'E095_non_unique_logical_paths': ['E095a'],
-                           'E096_manifest_repeated_digest': ['E096'],
-                           'E096_manifest_duplicate_digests': ['E096'],
-                           'E097_fixity_repeated_digest': ['E097'],
-                           'E097_fixity_duplicate_digests': ['E097'],
-                           'E099_bad_content_path_elements': ['E099'],
-                           'E100_E099_fixity_invalid_content_paths': ['E057d'],  # E057 OK, different test approach
-                           'E100_E099_manifest_invalid_content_paths': ['E099', 'E100'],
-                           'E101_non_unique_content_paths': ['E101a']}.items():
+        for bad, codes in {"does_not_even_exist": ["E003e"],
+                           "E001_extra_dir_in_root": ["E001b"],
+                           "E001_extra_file_in_root": ["E001a"],
+                           "E001_invalid_version_format": ["E009"],  # E009 OK, see https://github.com/OCFL/fixtures/pull/80
+                           "E001_v2_file_in_root": ["E001a"],
+                           "E003_E063_empty": ["E003a", "E063"],
+                           "E003_no_decl": ["E003a"],
+                           "E008_E036_no_versions_no_head": ["E008", "E036d"],
+                           "E009_version_two_only": ["E009"],
+                           "E010_missing_versions": ["E046a"],  # FIXME - Check code https://github.com/OCFL/spec/issues/540
+                           "E010_skipped_versions": ["E010"],
+                           "E011_E013_invalid_padded_head_version": ["E011", "E040", "E064"],  # E011 only OK, see https://github.com/OCFL/spec/issues/541
+                           "E015_content_not_in_content_dir": ["E042a"],  # FIXME - What should test case and error be?
+                           "E017_invalid_content_dir": ["E017"],
+                           "E019_inconsistent_content_dir": ["E042c"],  # FIXME - What should error be? Should be E020
+                           "E023_extra_file": ["E023a"],
+                           "E023_old_manifest_missing_entries": ["E023b"],
+                           "E024_empty_dir_in_content": ["E024"],
+                           "E025_wrong_digest_algorithm": ["E025a"],
+                           "E033_inventory_bad_json": ["E033"],
+                           "E036_no_id": ["E036a"],
+                           "E036_no_head": ["E036d"],
+                           "E037_inconsistent_id": ["E037b"],
+                           "E040_head_not_most_recent": ["E040"],
+                           "E040_wrong_head_doesnt_exist": ["E040"],
+                           "E040_wrong_head_format": ["E040"],
+                           "E040_wrong_version_in_version_dir": ["E066a"],  # FIXME
+                           "E041_no_manifest": ["E041a"],
+                           "E041_manifest_not_object": ["E041c"],
+                           "E042_bad_manifest_content_path": ["E042a"],
+                           "E046_missing_version_dir": ["E046a"],
+                           "E046_root_not_most_recent": ["E046b"],
+                           "E049_E050_E054_bad_version_block_values": ["E049d", "E050c", "E054a", "E094", "E107"],
+                           "E049_created_no_timezone": ["E049a"],
+                           "E049_created_not_to_seconds": ["E049b"],
+                           "E050_manifest_digest_wrong_case": ["E050f"],
+                           "E050_state_digest_different_case": ["E050f"],
+                           "E050_state_repeated_digest": ["E050f"],
+                           "E053_E052_invalid_logical_paths": ["E052", "E053"],
+                           "E056_null_fixity": ["E056a"],
+                           "E058_no_sidecar": ["E058a"],
+                           "E060_E064_root_inventory_digest_mismatch": ["E060", "E064"],
+                           "E061_invalid_sidecar": ["E061"],
+                           "E063_no_inv": ["E063"],
+                           "E064_different_root_and_latest_inventories": ["E064"],
+                           "E066_E092_old_manifest_digest_incorrect": ["E066d", "E066e", "E092a"],
+                           "E066_algorithm_change_state_mismatch": ["E066b"],
+                           "E066_changed_v1_logical_path": ["E066b"],
+                           "E066_inconsistent_version_state": ["E066b"],
+                           "E066_changed_content_for_logical_path": ["E066c"],
+                           "E067_file_in_extensions_dir": ["E067"],
+                           "E092_bad_manifest_digest": ["E092a"],
+                           "E092_content_file_digest_mismatch": ["E092a"],
+                           "E092_algorithm_change_incorrect_digest": ["E092a"],
+                           "E092_E093_content_path_does_not_exist": ["E092b", "E093b"],
+                           "E093_fixity_digest_mismatch": ["E093a"],
+                           "E093_fixity_digest_mismatch_in_v1": ["E093a"],
+                           "E094_message_not_a_string": ["E094"],
+                           "E095_conflicting_logical_paths": ["E095b"],
+                           "E095_non_unique_logical_paths": ["E095a"],
+                           "E096_manifest_repeated_digest": ["E096"],
+                           "E096_manifest_duplicate_digests": ["E096"],
+                           "E097_fixity_repeated_digest": ["E097"],
+                           "E097_fixity_duplicate_digests": ["E097"],
+                           "E099_bad_content_path_elements": ["E099"],
+                           "E100_E099_fixity_invalid_content_paths": ["E057d"],  # E057 OK, different test approach
+                           "E100_E099_manifest_invalid_content_paths": ["E099", "E100"],
+                           "E101_non_unique_content_paths": ["E101a"]}.items():
             v = Validator()
-            filepath = 'fixtures/1.0/bad-objects/' + bad
+            filepath = "fixtures/1.0/bad-objects/" + bad
             if not os.path.isdir(filepath):
-                filepath = extra_fixture_maybe_zip('extra_fixtures/1.0/bad-objects/' + bad)
+                filepath = extra_fixture_maybe_zip("extra_fixtures/1.0/bad-objects/" + bad)
             self.assertFalse(v.validate_object(filepath), msg=" for v1.0 object at " + filepath)
             for code in codes:
                 self.assertIn(code, v.log.codes, msg="for v1.0 object at " + filepath)
 
     def test02_bad_v1_1(self):
         """Check bad v1.1 objects fail."""
-        for bad, codes in {'does_not_even_exist': ['E003e'],
-                           'E001_extra_dir_in_root': ['E001b'],
-                           'E001_extra_file_in_root': ['E001a'],
-                           'E001_invalid_version_format': ['E009'],  # E009 OK, see https://github.com/OCFL/fixtures/pull/80
-                           'E001_v2_file_in_root': ['E001a'],
-                           'E003_E063_empty': ['E003a', 'E063'],
-                           'E003_no_decl': ['E003a'],
-                           'E003_two_declarations': ['E003b'],
-                           'E008_E036_no_versions_no_head': ['E008', 'E036d'],
-                           'E010_missing_versions': ['E046a'],  # FIXME - Check code https://github.com/OCFL/spec/issues/540
-                           'E010_skipped_versions': ['E010'],
-                           'E011_E013_invalid_padded_head_version': ['E011', 'E040', 'E064'],  # E011 only OK, see https://github.com/OCFL/spec/issues/541
-                           'E015_content_not_in_content_dir': ['E042a'],  # FIXME - What should test case and error be?
-                           'E017_invalid_content_dir': ['E017'],
-                           'E019_content_dir_set_in_v2_not_v1': ['E019'],
-                           'E019_inconsistent_content_dir': ['E042c'],  # FIXME - What should error be? Should be E020
-                           'E020_inconsistent_content_directory': ['E020'],
-                           'E023_extra_file': ['E023a'],
-                           'E023_old_manifest_missing_entries': ['E023b'],
-                           'E025_wrong_digest_algorithm': ['E025a'],
-                           'E036_no_id': ['E036a'],
-                           'E036_no_head': ['E036d'],
-                           'E037_inconsistent_id': ['E037b'],
-                           'E040_head_not_most_recent': ['E040'],
-                           'E040_wrong_head_doesnt_exist': ['E040'],
-                           'E040_wrong_head_format': ['E040'],
-                           'E040_wrong_version_in_version_dir': ['E066a'],  # FIXME
-                           'E041_no_manifest': ['E041a'],
-                           'E046_root_not_most_recent': ['E046b'],
-                           'E049_E050_E054_bad_version_block_values': ['E049d', 'E050c', 'E054a', 'E094', 'E107'],
-                           'E049_created_no_timezone': ['E049a'],
-                           'E049_created_not_to_seconds': ['E049b'],
-                           'E050_manifest_digest_wrong_case': ['E050f'],
-                           'E050_state_digest_not_in_manifest': ['E050f'],
-                           'E053_E052_invalid_logical_paths': ['E052', 'E053'],
-                           'E058_no_sidecar': ['E058a'],
-                           'E060_E064_root_inventory_digest_mismatch': ['E060', 'E064'],
-                           'E061_invalid_sidecar': ['E061'],
-                           'E063_no_inv': ['E063'],
-                           'E064_different_root_and_latest_inventories': ['E064'],
-                           'E066_E092_old_manifest_digest_incorrect': ['E066d', 'E066e', 'E092a'],
-                           'E066_algorithm_change_state_mismatch': ['E066b'],
-                           'E066_inconsistent_version_state': ['E066b'],
-                           'E067_file_in_extensions_dir': ['E067'],
-                           'E092_content_file_digest_mismatch': ['E092a'],
-                           'E092_algorithm_change_incorrect_digest': ['E092a'],
-                           'E092_E093_content_path_does_not_exist': ['E092b', 'E093b'],
-                           'E093_fixity_digest_mismatch': ['E093a'],
-                           'E095_conflicting_logical_paths': ['E095b'],
-                           'E095_non_unique_logical_paths': ['E095a'],
-                           'E096_manifest_duplicate_digests': ['E096'],
-                           'E097_fixity_duplicate_digests': ['E097'],
-                           'E100_E099_fixity_invalid_content_paths': ['E057d'],  # E057 OK, different test approach
-                           'E100_E099_manifest_invalid_content_paths': ['E099', 'E100'],
-                           'E101_non_unique_content_paths': ['E101a'],
-                           'E103_older_spec_v2': ['E103'],
-                           'E107_file_in_manifest_not_used': ['E107'],
-                           'E111_null_fixity': ['E111']
+        for bad, codes in {"does_not_even_exist": ["E003e"],
+                           "E001_extra_dir_in_root": ["E001b"],
+                           "E001_extra_file_in_root": ["E001a"],
+                           "E001_invalid_version_format": ["E009"],  # E009 OK, see https://github.com/OCFL/fixtures/pull/80
+                           "E001_v2_file_in_root": ["E001a"],
+                           "E003_E063_empty": ["E003a", "E063"],
+                           "E003_no_decl": ["E003a"],
+                           "E003_two_declarations": ["E003b"],
+                           "E008_E036_no_versions_no_head": ["E008", "E036d"],
+                           "E010_missing_versions": ["E046a"],  # FIXME - Check code https://github.com/OCFL/spec/issues/540
+                           "E010_skipped_versions": ["E010"],
+                           "E011_E013_invalid_padded_head_version": ["E011", "E040", "E064"],  # E011 only OK, see https://github.com/OCFL/spec/issues/541
+                           "E015_content_not_in_content_dir": ["E042a"],  # FIXME - What should test case and error be?
+                           "E017_invalid_content_dir": ["E017"],
+                           "E019_content_dir_set_in_v2_not_v1": ["E019"],
+                           "E019_inconsistent_content_dir": ["E042c"],  # FIXME - What should error be? Should be E020
+                           "E020_inconsistent_content_directory": ["E020"],
+                           "E023_extra_file": ["E023a"],
+                           "E023_old_manifest_missing_entries": ["E023b"],
+                           "E025_wrong_digest_algorithm": ["E025a"],
+                           "E036_no_id": ["E036a"],
+                           "E036_no_head": ["E036d"],
+                           "E037_inconsistent_id": ["E037b"],
+                           "E040_head_not_most_recent": ["E040"],
+                           "E040_wrong_head_doesnt_exist": ["E040"],
+                           "E040_wrong_head_format": ["E040"],
+                           "E040_wrong_version_in_version_dir": ["E066a"],  # FIXME
+                           "E041_no_manifest": ["E041a"],
+                           "E046_root_not_most_recent": ["E046b"],
+                           "E049_E050_E054_bad_version_block_values": ["E049d", "E050c", "E054a", "E094", "E107"],
+                           "E049_created_no_timezone": ["E049a"],
+                           "E049_created_not_to_seconds": ["E049b"],
+                           "E050_manifest_digest_wrong_case": ["E050f"],
+                           "E050_state_digest_not_in_manifest": ["E050f"],
+                           "E053_E052_invalid_logical_paths": ["E052", "E053"],
+                           "E058_no_sidecar": ["E058a"],
+                           "E060_E064_root_inventory_digest_mismatch": ["E060", "E064"],
+                           "E061_invalid_sidecar": ["E061"],
+                           "E063_no_inv": ["E063"],
+                           "E064_different_root_and_latest_inventories": ["E064"],
+                           "E066_E092_old_manifest_digest_incorrect": ["E066d", "E066e", "E092a"],
+                           "E066_algorithm_change_state_mismatch": ["E066b"],
+                           "E066_inconsistent_version_state": ["E066b"],
+                           "E067_file_in_extensions_dir": ["E067"],
+                           "E092_content_file_digest_mismatch": ["E092a"],
+                           "E092_algorithm_change_incorrect_digest": ["E092a"],
+                           "E092_E093_content_path_does_not_exist": ["E092b", "E093b"],
+                           "E093_fixity_digest_mismatch": ["E093a"],
+                           "E095_conflicting_logical_paths": ["E095b"],
+                           "E095_non_unique_logical_paths": ["E095a"],
+                           "E096_manifest_duplicate_digests": ["E096"],
+                           "E097_fixity_duplicate_digests": ["E097"],
+                           "E100_E099_fixity_invalid_content_paths": ["E057d"],  # E057 OK, different test approach
+                           "E100_E099_manifest_invalid_content_paths": ["E099", "E100"],
+                           "E101_non_unique_content_paths": ["E101a"],
+                           "E103_older_spec_v2": ["E103"],
+                           "E107_file_in_manifest_not_used": ["E107"],
+                           "E111_null_fixity": ["E111"]
                            }.items():
             v = Validator()
-            filepath = 'fixtures/1.1/bad-objects/' + bad
+            filepath = "fixtures/1.1/bad-objects/" + bad
             if not os.path.isdir(filepath):
-                filepath = extra_fixture_maybe_zip('extra_fixtures/1.1/bad-objects/' + bad)
+                filepath = extra_fixture_maybe_zip("extra_fixtures/1.1/bad-objects/" + bad)
             self.assertFalse(v.validate_object(filepath), msg=" for v1.1 object at " + filepath)
             for code in codes:
                 self.assertIn(code, v.log.codes, msg="for v1.1 object at " + filepath)
 
     def test03_warn_1_0(self):
         """Check warn v1.0 objects pass but give expected warnings."""
-        for warn, codes in {'W001_zero_padded_versions': ['W001'],
-                            'W001_W004_W005_zero_padded_versions': ['W001', 'W004', 'W005'],
-                            'W002_extra_dir_in_version_dir': ['W002'],
-                            'W003_empty_content_dir': ['W003'],
-                            'W004_uses_sha256': ['W004'],
-                            'W004_versions_diff_digests': ['W004'],
-                            'W005_id_not_uri': ['W005'],
-                            'W007_no_message_or_user': ['W007a', 'W007b'],
-                            'W008_user_no_address': ['W008'],
-                            'W009_user_address_not_uri': ['W009'],
-                            'W010_no_version_inventory': ['W010'],
-                            'W011_version_inv_diff_metadata': ['W011'],
-                            'W013_unregistered_extension': ['W013']}.items():
+        for warn, codes in {"W001_zero_padded_versions": ["W001"],
+                            "W001_W004_W005_zero_padded_versions": ["W001", "W004", "W005"],
+                            "W002_extra_dir_in_version_dir": ["W002"],
+                            "W003_empty_content_dir": ["W003"],
+                            "W004_uses_sha256": ["W004"],
+                            "W004_versions_diff_digests": ["W004"],
+                            "W005_id_not_uri": ["W005"],
+                            "W007_no_message_or_user": ["W007a", "W007b"],
+                            "W008_user_no_address": ["W008"],
+                            "W009_user_address_not_uri": ["W009"],
+                            "W010_no_version_inventory": ["W010"],
+                            "W011_version_inv_diff_metadata": ["W011"],
+                            "W013_unregistered_extension": ["W013"]}.items():
             v = Validator()
-            filepath = 'fixtures/1.0/warn-objects/' + warn
+            filepath = "fixtures/1.0/warn-objects/" + warn
             if not os.path.isdir(filepath):
-                filepath = extra_fixture_maybe_zip('extra_fixtures/1.0/warn-objects/' + warn)
+                filepath = extra_fixture_maybe_zip("extra_fixtures/1.0/warn-objects/" + warn)
             self.assertTrue(v.validate_object(filepath), msg="for v1.0 object at " + filepath)
             self.assertEqual(set(codes), set(v.log.codes), msg="for v1.0 object at " + filepath)
 
     def test04_warn_1_1(self):
         """Check warn v1.1 objects pass but give expected warnings."""
-        for warn, codes in {'W001_zero_padded_versions': ['W001'],
-                            'W001_W004_W005_zero_padded_versions': ['W001', 'W004', 'W005'],
-                            'W002_extra_dir_in_version_dir': ['W002'],
-                            'W004_uses_sha256': ['W004'],
-                            'W004_versions_diff_digests': ['W004'],
-                            'W005_id_not_uri': ['W005'],
-                            'W007_no_message_or_user': ['W007a', 'W007b'],
-                            'W008_user_no_address': ['W008'],
-                            'W009_user_address_not_uri': ['W009'],
-                            'W010_no_version_inventory': ['W010'],
-                            'W011_version_inv_diff_metadata': ['W011'],
-                            'W013_unregistered_extension': ['W013']}.items():
+        for warn, codes in {"W001_zero_padded_versions": ["W001"],
+                            "W001_W004_W005_zero_padded_versions": ["W001", "W004", "W005"],
+                            "W002_extra_dir_in_version_dir": ["W002"],
+                            "W004_uses_sha256": ["W004"],
+                            "W004_versions_diff_digests": ["W004"],
+                            "W005_id_not_uri": ["W005"],
+                            "W007_no_message_or_user": ["W007a", "W007b"],
+                            "W008_user_no_address": ["W008"],
+                            "W009_user_address_not_uri": ["W009"],
+                            "W010_no_version_inventory": ["W010"],
+                            "W011_version_inv_diff_metadata": ["W011"],
+                            "W013_unregistered_extension": ["W013"]}.items():
             v = Validator()
-            filepath = 'fixtures/1.1/warn-objects/' + warn
+            filepath = "fixtures/1.1/warn-objects/" + warn
             if not os.path.isdir(filepath):
-                filepath = extra_fixture_maybe_zip('extra_fixtures/1.1/warn-objects/' + warn)
+                filepath = extra_fixture_maybe_zip("extra_fixtures/1.1/warn-objects/" + warn)
             self.assertTrue(v.validate_object(filepath), msg="for v1.1 object at " + filepath)
             self.assertEqual(set(codes), set(v.log.codes), msg="for v1.1 object at " + filepath)
 
     def test05_good(self):
         """Check good objects (v1.0, v1.1 and extra) pass."""
-        for base_dir in ['fixtures/1.0/good-objects',
-                         'fixtures/1.1/good-objects',
-                         'extra_fixtures/1.0/good-objects',
-                         'extra_fixtures/1.1/good-objects']:
+        for base_dir in ["fixtures/1.0/good-objects",
+                         "fixtures/1.1/good-objects",
+                         "extra_fixtures/1.0/good-objects",
+                         "extra_fixtures/1.1/good-objects"]:
             for name in os.listdir(base_dir):
                 filepath = extra_fixture_maybe_zip(os.path.join(base_dir, name))
                 v = Validator()
diff --git a/tests/test_version_metadata.py b/tests/test_version_metadata.py
index 5bb85a3..2f1b408 100644
--- a/tests/test_version_metadata.py
+++ b/tests/test_version_metadata.py
@@ -1,33 +1,31 @@
-"""Version tests."""
-import argparse
+"""VersionMetadata tests."""
 import json
 import unittest
 from ocfl.version_metadata import VersionMetadata, VersionMetadataException
+from ocfl.inventory import Inventory
 
 
 class TestAll(unittest.TestCase):
     """TestAll class to run tests."""
 
-    def test02_init(self):
+    def test01_init(self):
         """Test VersionMetadata init method."""
-        args = argparse.Namespace(created='a',
-                                  message='b',
-                                  name='c',
-                                  address='d')
-        m = VersionMetadata(args=args)
-        d = m.as_dict(extra='x')
-        self.assertEqual(d['created'], 'a')
-        self.assertEqual(d['message'], 'b')
-        self.assertEqual(d['user'], {'name': 'c', 'address': 'd'})
-        self.assertEqual(d['extra'], 'x')
+        m = VersionMetadata(created="a",
+                            message="b",
+                            name="c",
+                            address="d")
+        d = m.as_dict()
+        self.assertEqual(d["created"], "a")
+        self.assertEqual(d["message"], "b")
+        self.assertEqual(d["user"], {"name": "c", "address": "d"})
         # with load from file
-        with open('tests/testdata/inventories/inv_1_good.json', 'r', encoding="utf-8") as fh:
+        with open("tests/testdata/inventories/inv_1_good.json", "r", encoding="utf-8") as fh:
             inventory = json.load(fh)
-        m = VersionMetadata(inventory=inventory, version='v1')
-        self.assertEqual(m.version, 'v1')
-        self.assertEqual(m.created, '2018-10-02T12:00:00Z')
+        m = VersionMetadata(inventory=inventory, version="v1")
+        self.assertEqual(m.version, "v1")
+        self.assertEqual(m.created, "2018-10-02T12:00:00Z")
 
-    def test03_from_inventory(self):
+    def test02_from_inventory(self):
         """Test from_inventory method."""
         m = VersionMetadata()
         m.from_inventory(inventory={
@@ -45,6 +43,18 @@ def test03_from_inventory(self):
         self.assertEqual(m.message, "text")
         self.assertEqual(m.address, "mailto:alice@example.org")
         self.assertEqual(m.name, "Alice")
+        # And now from an Inventory object
+        inv = Inventory()
+        inv.id = "info:a/b/c/99"
+        v = inv.add_version()
+        v.message = "Hello"
+        v.user_name = "Teresa"
+        m = VersionMetadata()
+        m.from_inventory(inventory=inv, version="v1")
+        self.assertEqual(m.id, "info:a/b/c/99")
+        self.assertEqual(m.created, None)
+        self.assertEqual(m.message, "Hello")
+        self.assertEqual(m.name, "Teresa")
         # Error cases
         m = VersionMetadata()
         self.assertRaises(VersionMetadataException, m.from_inventory, inventory={"no versions": 1})
diff --git a/tests/test_w3c_datetime.py b/tests/test_w3c_datetime.py
index ba962ca..1dc6a0d 100644
--- a/tests/test_w3c_datetime.py
+++ b/tests/test_w3c_datetime.py
@@ -60,8 +60,8 @@ def test01_datetime_to_str(self):
 
         # Special cases
         self.assertEqual(datetime_to_str(None), None)
-        nt = datetime_to_str('now', no_fractions=True)
-        self.assertTrue(re.match(r'''\d\d\d\d\-\d\d\-\d\dT\d\d:\d\d:\d\dZ''', nt))
+        nt = datetime_to_str("now", no_fractions=True)
+        self.assertTrue(re.match(r"""\d\d\d\d\-\d\d\-\d\dT\d\d:\d\d:\d\dZ""", nt))
 
     def test02_str_to_datetime(self):
         """Reading."""
@@ -79,23 +79,23 @@ def test02_str_to_datetime(self):
 
     def test03_same(self):
         """Datetime values that are the same."""
-        astr = '2012-01-01T00:00:00Z'
+        astr = "2012-01-01T00:00:00Z"
         a = str_to_datetime(astr)
-        for bstr in ('2012',
-                     '2012-01',
-                     '2012-01-01',
-                     '2012-01-01T00:00Z',
-                     '2012-01-01T00:00:00Z',
-                     '2012-01-01T00:00:00.000000Z',
-                     '2012-01-01T00:00:00.000000000000Z',
-                     '2012-01-01T00:00:00.000000000001Z',  # below resolution
-                     '2012-01-01T00:00:00.00+00:00',
-                     '2012-01-01T00:00:00.00-00:00',
-                     '2012-01-01T02:00:00.00-02:00',
-                     '2011-12-31T23:00:00.00+01:00'
+        for bstr in ("2012",
+                     "2012-01",
+                     "2012-01-01",
+                     "2012-01-01T00:00Z",
+                     "2012-01-01T00:00:00Z",
+                     "2012-01-01T00:00:00.000000Z",
+                     "2012-01-01T00:00:00.000000000000Z",
+                     "2012-01-01T00:00:00.000000000001Z",  # below resolution
+                     "2012-01-01T00:00:00.00+00:00",
+                     "2012-01-01T00:00:00.00-00:00",
+                     "2012-01-01T02:00:00.00-02:00",
+                     "2011-12-31T23:00:00.00+01:00"
                      ):
             b = str_to_datetime(bstr)
-            self.assertEqual(a, b, ('%s (%f) == %s (%f)' % (astr, a, bstr, b)))
+            self.assertEqual(a, b, ("%s (%f) == %s (%f)" % (astr, a, bstr, b)))
 
     def test04_bad_str(self):
         """Bad formats."""
@@ -123,15 +123,15 @@ def test04_bad_str(self):
 
     def test05_roundtrips(self):
         """Round trips."""
-        self.assertEqual(rt('2012-03-14T00:00:00+00:00'),
-                         '2012-03-14T00:00:00Z')
-        self.assertEqual(rt('2012-03-14T00:00:00-00:00'),
-                         '2012-03-14T00:00:00Z')
-        self.assertEqual(rt('2012-03-14T11:00:00-11:00'),
-                         '2012-03-14T00:00:00Z')
-        self.assertEqual(rt('2012-03-14T18:37:36Z'),
-                         '2012-03-14T18:37:36Z')
-        self.assertEqual(rt('2012-03-14T18:37:36+00:10'),
-                         '2012-03-14T18:47:36Z')
-        self.assertEqual(rt('2012-03-14T18:37:36-01:01'),
-                         '2012-03-14T17:36:36Z')
+        self.assertEqual(rt("2012-03-14T00:00:00+00:00"),
+                         "2012-03-14T00:00:00Z")
+        self.assertEqual(rt("2012-03-14T00:00:00-00:00"),
+                         "2012-03-14T00:00:00Z")
+        self.assertEqual(rt("2012-03-14T11:00:00-11:00"),
+                         "2012-03-14T00:00:00Z")
+        self.assertEqual(rt("2012-03-14T18:37:36Z"),
+                         "2012-03-14T18:37:36Z")
+        self.assertEqual(rt("2012-03-14T18:37:36+00:10"),
+                         "2012-03-14T18:47:36Z")
+        self.assertEqual(rt("2012-03-14T18:37:36-01:01"),
+                         "2012-03-14T17:36:36Z")
diff --git a/tests/testlib/demo_unittest.py b/tests/testlib/demo_unittest.py
index f75299d..c3ff43a 100644
--- a/tests/testlib/demo_unittest.py
+++ b/tests/testlib/demo_unittest.py
@@ -21,7 +21,7 @@ def setUp(self):
         """Do setup for each test."""
         type(self).n += 1  # access class variable not copy
         self.m = 0
-        self.tmpdir = tempfile.mkdtemp(prefix='test' + str(self.n) + '_')
+        self.tmpdir = tempfile.mkdtemp(prefix="test" + str(self.n) + "_")
         if self.demo:
             print("\n## %d. %s" % (self.n, self.shortDescription()))
 
@@ -42,20 +42,20 @@ def run_script(self, desc, options, text=None):
             if self.demo:
                 print("\n### %d.%d %s\n" % (self.n, self.m, desc))
         if text:
-            text = re.sub('TMPDIR', 'tmp', text)
-            print(text + '\n')
+            text = re.sub("TMPDIR", "tmp", text)
+            print(text + "\n")
         cmd = []
         for option in options:
-            cmd.append(option.replace('TMPDIR', self.tmpdir))
+            cmd.append(option.replace("TMPDIR", self.tmpdir))
         code = 0
         try:
-            out = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode('utf-8')
+            out = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8")
         except subprocess.CalledProcessError as e:
-            out = e.output.decode('utf-8')
+            out = e.output.decode("utf-8")
             code = e.returncode
-        out = "```\n> " + ' '.join(cmd) + "\n" + out + "```\n"
+        out = "```\n> " + " ".join(cmd) + "\n" + out + "```\n"
         if self.demo:
-            out = re.sub(self.tmpdir, 'tmp', out)
+            out = re.sub(self.tmpdir, "tmp", out)
             print(out)
             if code != 0:
                 print("(last command exited with return code %d)\n" % (code))
@@ -66,9 +66,9 @@ def demo_tree(self, treedir, text=None):
         if self.demo:
             if text is not None:
                 print(text + "\n")
-            tree = subprocess.check_output('cd %s; tree -a %s' % (self.tmpdir, treedir),
+            tree = subprocess.check_output("cd %s; tree -a %s" % (self.tmpdir, treedir),
                                            stderr=subprocess.STDOUT,
-                                           shell=True).decode('utf-8')
+                                           shell=True).decode("utf-8")
             print("```\n" + tree + "```\n")
 
     def demo_text(self, text=None):
@@ -82,6 +82,6 @@ def run_as_demo(cls, title="Demo output"):
         """Run tests in demo mode."""
         cls.demo = True
         print("# " + title + "\n")
-        script_name = re.sub(r'''^.*/tests/''', 'tests/', main.__file__)
+        script_name = re.sub(r"""^.*/tests/""", "tests/", main.__file__)
         print("_Output from `" + script_name + "`._")
         unittest.main(verbosity=0)  # No dots added while running