Repository: linode/linode_api4-python (language: python)

Issue:
inconsistent update behavior
This works:
```
client = ...
instance = client.load(Instance, 123)
instance.tags = ['new-tags']
instance.save()
```
This does nothing:
```
client = ...
instance = Instance(client, 123) # from documentation it is not clear that this will make call at all
instance.tags = ['new-tags']
instance.save()
```
However this does work:
```
client = ...
volume = Volume(client, 1234)
volume.resize(25)
```
Same behavior for all objects. Is it intended?

Also could you confirm that first snippet will cause 2 remote API calls while second will cause 3 and how many call at what method calls will cause third snippet?
LinodeLoginClient.finish_oauth() doesn't handle tokens with more than one scope
# Steps to reproduce

1. Initiate the OAuth flow with a request for multiple scopes
 ```
login_client.generate_login_url(
            scopes=[OAuthScopes.Account.read_write, OAuthScopes.Domains.read_write ]
        )
```
2. In the OAuth redirect handler, use the `LinodeLoginClient.finish_login()` utility to retrieve the token
```
token, scopes, expiry, refresh_token = login_client.finish_oauth(code)
```
4. Observe exception
```
  File "/root/app/main.py", line 80, in auth_callback_test
    token, scopes, expiry, refresh_token = login_client.finish_oauth(code)
  File "/usr/local/lib/python3.6/dist-packages/linode_api4/login_client.py", line 411, in finish_oauth
    scopes = OAuthScopes.parse(r.json()["scopes"])
  File "/usr/local/lib/python3.6/dist-packages/linode_api4/login_client.py", line 274, in parse
    resource, access = scope.split(':')
   
```

# Analysis
The callback receives a json blob like:
```
{
    "access_token": "...",
    "expires_in": 7200,
    "refresh_token": "...",
    "scope": "account:read_write linodes:read_write linodes:read_only domains:read_only",
    "scopes": "account:read_write linodes:read_write linodes:read_only domains:read_only",
    "token_type": "bearer"
}
```

The scopes are space separated in the json payload, but the `finish_oauth` uses a comma separator:
```
scopes = OAuthScopes.parse(r.json()["scopes"])

for scope in scopes.split(','):
            resource = access = None
            if ':' in scope:
                resource, access = scope.split(':')
            else:
                resource = scope
                access = '*'

            parsed_scope = OAuthScopes._get_parsed_scope(resource, access)
```



Cannot update Domain type from master to slave
Existing domains can be updated from a type `master` to type `slave` through the API. Through trial and error, I learned that it also requires `master_ips` to be popluated, but as long as it is provided, it works.

```
$ curl -s \
    -H "Authorization: Bearer ${LINODE_TOKEN}" \
    https://api.linode.com/v4/domains/1404371 | jq
{
  "id": 1404371,
  "type": "master",
  "domain": "sjacobs.local",
  "tags": [],
  "group": "",
  "status": "active",
  "description": "",
  "soa_email": "stjacobs@example.com",
  "retry_sec": 0,
  "master_ips": [],
  "axfr_ips": [],
  "expire_sec": 0,
  "refresh_sec": 0,
  "ttl_sec": 0,
  "created": "2020-07-25T02:38:49",
  "updated": "2020-09-18T17:56:10"
}
$ curl -s -X PUT \
    -H "content-type: application/json" \
    -H "Authorization: Bearer ${LINODE_TOKEN}" \
    -d '{"id": 1404371, "type": "slave", "domain": "sjacobs.local", "master_ips": ["127.0.0.1"]}' \
    https://api.linode.com/v4/domains/1404371 | jq
{
  "id": 1404371,
  "type": "slave",
  "domain": "sjacobs.local",
  "tags": [],
  "group": "",
  "status": "active",
  "description": "",
  "soa_email": "stjacobs@example.com",
  "retry_sec": 0,
  "master_ips": [
    "127.0.0.1"
  ],
  "axfr_ips": [],
  "expire_sec": 0,
  "refresh_sec": 0,
  "ttl_sec": 0,
  "created": "2020-07-25T02:38:49",
  "updated": "2021-07-22T18:30:52"
}
```

When the same operation trying through the Python library, this fails.

```
>>> d = Domain(client, 1404371)
>>> d.domain
'sjacobs.local'
>>> d.type
'master'
>>> d.type = 'slave'
>>> d.master_ips = ['127.0.0.1']
>>> d.save()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/sjacobs/Code/foo/venv/lib/python3.9/site-packages/linode_api4/objects/base.py", line 156, in save
    resp = self._client.put(type(self).api_endpoint, model=self,
  File "/home/sjacobs/Code/foo/venv/lib/python3.9/site-packages/linode_api4/linode_client.py", line 1323, in put
    return self._api_call(*args, method=self.session.put, **kwargs)
  File "/home/sjacobs/Code/foo/venv/lib/python3.9/site-packages/linode_api4/linode_client.py", line 1286, in _api_call
    raise ApiError(error_msg, status=response.status_code, json=j)
linode_api4.errors.ApiError: 400: Length must be 1-50 characters;
```

Stats:

```
$ pip freeze | grep linode
linode-api4==5.2.0

$ python --version
Python 3.9.2
```
new: Add `ExplicitNullValue` class
## 📝 Description

This change introduces an `ExplicitNullValue` class that can be used to specify null values that should explicitly be included in API put requests. 

For example, you can reset the RDNS for an IP address by explicitly specifying `rdns` as null when making a request to this endpoint: https://www.linode.com/docs/api/networking/#ip-address-rdns-update__request-body-schema

The corresponding implementation would look like this:

```python
 ip = IPAddress(client, "127.0.0.1")
 ip.rdns = ExplicitNullValue
 ip.save()
```


## ✔️ How to Test

```
tox
```

SupportTicket upload_attachment raises with image file
`UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte` when attempting to upload a jpg


Discussion / hints:

Thanks for the report; I believe this will be fixed in #220 
For the purposes of debugging, it might help to see the JSON the API returned that generated that error:

```python
In [15]: try:
    ...:     d.save()
    ...: except Exception as e:
    ...:     err = e
    ...:

In [16]: err
Out[16]: linode_api4.errors.ApiError('400: Length must be 1-50 characters; ')

In [17]: err.json
Out[17]: {'errors': [{'reason': 'Length must be 1-50 characters', 'field': 'group'}]}
```

It looks like when we called `d.save()`, it also attempted to save the empty string for the `group` field:

```python
In [18]: d.group
Out[18]: ''
```

You can just populate that as whatever and it should work:

```python
In [19]: d.group = "whatever"

In [20]: d.save()
Out[20]: True

In [21]: d.type
Out[21]: 'slave'
```

However, it would be nice if it wasn't necessary; I'll see what I do can.

This is being addressed in https://github.com/linode/python-linode-api/pull/30

Fix patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..ba1c6b80a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+  - package-ecosystem: "pip" # See documentation for possible values
+    directory: "/" # Location of package manifests
+    schedule:
+      interval: "daily"
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 000000000..63d4fe1cd
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,24 @@
+name: Linting Actions
+on:
+  pull_request: null
+  push:
+    branches:
+      - master
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - name: checkout repo
+        uses: actions/checkout@v3
+
+      - name: setup python 3
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.x'
+
+      - name: install dependencies
+        run: pip3 install -r requirements-dev.txt -r requirements.txt
+
+      - name: run linter
+        run: make lint
\ No newline at end of file
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index d2f1033cc..ae876b72a 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -3,9 +3,10 @@ name: Test Suite
 
 on:
   push:
-    branches: [ master ]
+    branches:
+      - dev
+      - main
   pull_request:
-    branches: [ master ]
   workflow_dispatch:
 
 jobs:
@@ -13,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ['3.7','3.8','3.9','3.10', '3.11']
+        python-version: ['3.7','3.8','3.9','3.10','3.11']
     steps:
       - uses: actions/checkout@v2
       - uses: actions/setup-python@v2
diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml
new file mode 100644
index 000000000..0d0dff60b
--- /dev/null
+++ b/.github/workflows/publish-pypi.yaml
@@ -0,0 +1,38 @@
+name: release
+on:
+  workflow_dispatch: null
+  release:
+    types: [ published ]
+jobs:
+  pypi-release:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Update system packages
+        run: sudo apt-get update -y
+
+      - name: Install make
+        run: sudo apt-get install -y build-essential
+
+      - name: Setup Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.x'
+
+      - name: Install Python deps
+        run: pip install wheel
+
+      - name: Install package requirements
+        run: make requirements
+
+      - name: Build the package
+        run: make build
+        env:
+          LINODE_SDK_VERSION: ${{ github.event.release.tag_name }}
+
+      - name: Publish the release artifacts to PyPI
+        uses: pypa/gh-action-pypi-publish@37f50c210e3d2f9450da2cd423303d6a14a6e29f # pin@release/v1
+        with:
+          password: ${{ secrets.PYPI_API_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5007e7880..57450459a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ docs/_build/*
 .pytest_cache/*
 .tox/*
 venv
+baked_version
\ No newline at end of file
diff --git a/.pylintrc b/.pylintrc
index 246360d05..2084a0c5d 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -50,13 +50,13 @@ confidence=
 # --enable=similarities". If you want to run only the classes checker, but have
 # no Warning level messages displayed, use"--disable=all --enable=classes
 # --disable=W"
-disable=blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call
+disable=consider-using-dict-items,blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call,no-value-for-parameter,c-extension-no-member,attribute-defined-outside-init,use-a-generator
 
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
 # multiple time (only on the command line, not in the configuration file where
 # it should appear only once). See also the "--disable" option for examples.
-enable=syntax-error,unrecognized-inline-option,init-is-generator,return-in-init,function-redefined,not-in-loop,return-outside-function,yield-outside-function,return-arg-in-generator,nonexistent-operator,duplicate-argument-name,abstract-class-instantiated,bad-reversed-sequence,too-many-star-expressions,invalid-star-assignment-target,star-needs-assignment-target,nonlocal-and-global,continue-in-finally,nonlocal-without-binding,used-prior-global-declaration,method-hidden,access-member-before-definition,no-method-argument,no-self-argument,invalid-slots-object,assigning-non-slot,invalid-slots,inherit-non-class,inconsistent-mro,duplicate-bases,non-iterator-returned,unexpected-special-method-signature,invalid-length-returned,import-error,relative-beyond-top-level,used-before-assignment,undefined-variable,undefined-all-variable,invalid-all-object,no-name-in-module,unbalanced-tuple-unpacking,unpacking-non-sequence,bad-except-order,raising-bad-type,bad-exception-context,misplaced-bare-raise,raising-non-exception,notimplemented-raised,catching-non-exception,slots-on-old-class,super-on-old-class,bad-super-call,missing-super-argument,no-member,not-callable,assignment-from-no-return,no-value-for-parameter,too-many-function-args,unexpected-keyword-arg,redundant-keyword-arg,missing-kwoa,invalid-sequence-index,invalid-slice-index,assignment-from-none,not-context-manager,invalid-unary-operand-type,unsupported-binary-operation,repeated-keyword,not-an-iterable,not-a-mapping,unsupported-membership-test,unsubscriptable-object,unsupported-assignment-operation,unsupported-delete-operation,invalid-metaclass,logging-unsupported-format,logging-format-truncated,logging-too-many-args,logging-too-few-args,bad-format-character,truncated-format-string,mixed-format-string,format-needs-mapping,missing-format-string-key,too-many-format-args,too-few-format-args,bad-str-strip-call,print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,yield-inside-async-function,not-async-context-manager,unused-variable,attribute-defined-outside-init,bad-indentation
+enable=syntax-error,unrecognized-inline-option,init-is-generator,return-in-init,function-redefined,not-in-loop,return-outside-function,yield-outside-function,return-arg-in-generator,nonexistent-operator,duplicate-argument-name,abstract-class-instantiated,bad-reversed-sequence,too-many-star-expressions,invalid-star-assignment-target,star-needs-assignment-target,nonlocal-and-global,continue-in-finally,nonlocal-without-binding,used-prior-global-declaration,method-hidden,access-member-before-definition,no-method-argument,no-self-argument,invalid-slots-object,assigning-non-slot,invalid-slots,inherit-non-class,inconsistent-mro,duplicate-bases,non-iterator-returned,unexpected-special-method-signature,invalid-length-returned,import-error,relative-beyond-top-level,used-before-assignment,undefined-variable,undefined-all-variable,invalid-all-object,no-name-in-module,unbalanced-tuple-unpacking,unpacking-non-sequence,bad-except-order,raising-bad-type,bad-exception-context,misplaced-bare-raise,raising-non-exception,notimplemented-raised,catching-non-exception,slots-on-old-class,super-on-old-class,bad-super-call,missing-super-argument,no-member,not-callable,assignment-from-no-return,too-many-function-args,unexpected-keyword-arg,redundant-keyword-arg,missing-kwoa,invalid-sequence-index,invalid-slice-index,assignment-from-none,not-context-manager,invalid-unary-operand-type,unsupported-binary-operation,repeated-keyword,not-an-iterable,not-a-mapping,unsupported-membership-test,unsubscriptable-object,unsupported-assignment-operation,unsupported-delete-operation,invalid-metaclass,logging-unsupported-format,logging-format-truncated,logging-too-many-args,logging-too-few-args,bad-format-character,truncated-format-string,mixed-format-string,format-needs-mapping,missing-format-string-key,too-many-format-args,too-few-format-args,bad-str-strip-call,print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,yield-inside-async-function,not-async-context-manager,unused-variable,bad-indentation
 
 
 [REPORTS]
diff --git a/MANIFEST.in b/MANIFEST.in
index af3d266ab..96c48f6d8 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,3 @@
 graft test
 global-exclude *.pyc
+include baked_version
\ No newline at end of file
diff --git a/Makefile b/Makefile
index ca657d7aa..e51e574bc 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,8 @@ PYTHON ?= python3
 @PHONEY: clean
 clean:
 	mkdir -p dist
-	rm dist/*
+	rm -r dist
+	rm -f baked_version
 
 @PHONEY: build
 build: clean
@@ -14,3 +15,33 @@ build: clean
 @PHONEY: release
 release: build
 	twine upload dist/*
+
+
+install: clean
+	python3 setup.py install
+
+
+requirements:
+	pip install -r requirements.txt -r requirements-dev.txt
+
+
+black:
+	black linode_api4 test
+
+
+isort:
+	isort linode_api4 test
+
+
+autoflake:
+	autoflake linode_api4 test
+
+
+format: black isort autoflake
+
+
+lint:
+	isort --check-only linode_api4 test
+	autoflake --check linode_api4 test
+	black --check --verbose linode_api4 test
+	pylint linode_api4
diff --git a/README.rst b/README.rst
index a95a96982..f04105bdf 100644
--- a/README.rst
+++ b/README.rst
@@ -5,8 +5,8 @@ The official python library for the `Linode API v4`_ in python.
 
 .. _Linode API v4: https://developers.linode.com/api/v4/
 
-.. image:: https://travis-ci.com/linode/linode_api4-python.svg?branch=master
-    :target: https://travis-ci.com/linode/linode_api4-python
+.. image:: https://img.shields.io/github/actions/workflow/status/linode/linode_api4-python/main.yml?label=tests
+    :target: https://img.shields.io/github/actions/workflow/status/linode/linode_api4-python/main.yml?label=tests
 
 .. image:: https://badge.fury.io/py/linode-api4.svg
    :target: https://badge.fury.io/py/linode-api4
@@ -30,13 +30,62 @@ To build and install this package:
 - ``./setup.py install``
 
 Usage
------
+=====
+
+Quick Start
+-----------
+
+In order to authenticate with the Linode API, you will first need to create a
+`Linode Personal Access Token`_ with your desired account permissions.
+
+The following code sample can help you quickly get started using this package.
+
+.. code-block:: python
+
+    from linode_api4 import LinodeClient, Instance
+
+    # Create a Linode API client
+    client = LinodeClient("MY_PERSONAL_ACCESS_TOKEN")
+
+    # Create a new Linode
+    new_linode, root_pass = client.linode.instance_create(
+        ltype="g6-nanode-1",
+        region="us-southeast",
+        image="linode/ubuntu22.04",
+        label="my-ubuntu-linode"
+    )
 
-Check out the `Getting Started guide`_ to start using this library, or read
-`the docs`_ for extensive documentation.
+    # Print info about the Linode
+    print("Linode IP:", new_linode.ipv4[0])
+    print("Linode Root Password:", root_pass)
 
-.. _Getting Started guide: http://linode_api4.readthedocs.io/en/latest/guides/getting_started.html
-.. _the docs: http://linode_api4.readthedocs.io/en/latest/index.html
+    # List all Linodes on the account
+    my_linodes = client.linode.instances()
+
+    # Print the Label of every Linode on the account
+    print("All Instances:")
+    for instance in my_linodes:
+        print(instance.label)
+
+    # List Linodes in the us-southeast region
+    specific_linodes = client.linode.instances(
+        Instance.region == "us-southeast"
+    )
+
+    # Print the label of each Linode in us-southeast
+    print("Instances in us-southeast:")
+    for instance in specific_linodes:
+        print(instance.label)
+
+    # Delete the new instance
+    new_linode.delete()
+
+Check out the `Getting Started guide`_ for more details on getting started
+with this library, or read `the docs`_ for more extensive documentation.
+
+.. _Linode Personal Access Token: https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/
+.. _Getting Started guide: https://linode-api4.readthedocs.io/en/latest/guides/getting_started.html
+.. _the docs: https://linode-api4.readthedocs.io/en/latest/index.html
 
 Examples
 --------
@@ -89,7 +138,12 @@ Documentation
 
 This library is documented with Sphinx_.  Docs live in the ``docs`` directory.
 The easiest way to build the docs is to run ``sphinx-autobuild`` in that
-folder.
+folder::
+
+    sphinx-autobuild docs docs/build
+
+After running this command, ``sphinx-autobuild`` will host a local web server
+with the rendered documentation.
 
 Classes and functions inside the library should be annotated with sphinx-compliant
 docstrings which will be used to automatically generate documentation for the
diff --git a/docs/conf.py b/docs/conf.py
index a3f980a52..cd1654ef0 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -20,7 +20,7 @@
 # -- Project information -----------------------------------------------------
 
 project = 'linode_api4'
-copyright = '2020, Linode'
+copyright = '2023, Linode'
 author = 'Linode'
 
 # The short X.Y version
@@ -86,7 +86,10 @@
 # further.  For a list of options available for each theme, see the
 # documentation.
 #
-# html_theme_options = {}
+html_theme_options = {
+    'sidebar_width': '320px',
+    'page_width': '1000px',
+}
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
diff --git a/docs/guides/core_concepts.rst b/docs/guides/core_concepts.rst
index 22b0bbcfc..535d8079a 100644
--- a/docs/guides/core_concepts.rst
+++ b/docs/guides/core_concepts.rst
@@ -82,6 +82,7 @@ In addition to looking up models from collections, you can simply import the
 model class and create it by ID.::
 
    from linode_api4 import Instance
+
    my_linode = Instance(client, 123)
 
 All models take a `LinodeClient` as their first parameter, and their ID as the
@@ -94,6 +95,14 @@ Be aware that when creating a model this way, it is _not_ loaded from the API
 immediately.  Models in this library are **lazy-loaded**, and will not be looked
 up until one of their attributes that is currently unknown is accessed.
 
+In order to automatically populate a model for an existing Linode resource,
+consider using the :any:`LinodeClient.load` method::
+
+    from linode_api4 import Instance, Disk
+
+    instance = client.load(Instance, 12345)
+    instance_disk = client.load(Disk, 123, instance.id)
+
 Lazy Loading
 ^^^^^^^^^^^^
 
@@ -149,10 +158,15 @@ models can also be deleted in a similar fashion.::
 .. note::
 
    Saving a model *may* fail if the values you are attempting to save are invalid.
-   If the values you are attemting to save are coming from an untrusted source,
+   If the values you are attempting to save are coming from an untrusted source,
    be sure to handle a potential :any:`ApiError` raised by the API returning
    an unsuccessful response code.
 
+    When updating an attribute on a model, ensure that the model has been populated
+    *before* any local changes have been made. Attempting to update an attribute
+    and save a model before the model has been populated will result in no changes
+    being applied.
+
 Relationships
 ^^^^^^^^^^^^^
 
diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst
index b7e5ec65d..172e00864 100644
--- a/docs/linode_api4/linode_client.rst
+++ b/docs/linode_api4/linode_client.rst
@@ -52,6 +52,42 @@ example::
 See :any:`LinodeClient` for more information on the naming of these groups,
 although generally they are named the same as the first word of the group.
 
+AccountGroup
+^^^^^^^^^^^^
+
+Includes methods for managing your account.
+
+.. autoclass:: linode_api4.linode_client.AccountGroup
+   :members:
+   :special-members:
+
+DatabaseGroup
+^^^^^^^^^^^^^
+
+Includes methods for managing Linode Managed Databases.
+
+.. autoclass:: linode_api4.linode_client.DatabaseGroup
+   :members:
+   :special-members:
+
+DomainGroup
+^^^^^^^^^^^
+
+Includes methods for managing Linode Domains.
+
+.. autoclass:: linode_api4.linode_client.DomainGroup
+   :members:
+   :special-members:
+
+ImageGroup
+^^^^^^^^^^
+
+Includes methods for managing Linode Images.
+
+.. autoclass:: linode_api4.linode_client.ImageGroup
+   :members:
+   :special-members:
+
 LinodeGroup
 ^^^^^^^^^^^
 
@@ -60,22 +96,25 @@ accessing and working with associated features.
 
 .. autoclass:: linode_api4.linode_client.LinodeGroup
    :members:
+   :special-members:
 
-AccountGroup
-^^^^^^^^^^^^
+LKE Group
+^^^^^^^^^
 
-Includes methods for managing your account.
+Includes methods for interacting with Linode Kubernetes Engine.
 
-.. autoclass:: linode_api4.linode_client.AccountGroup
+.. autoclass:: linode_api4.linode_client.LKEGroup
    :members:
+   :special-members:
 
-ProfileGroup
-^^^^^^^^^^^^
+LongviewGroup
+^^^^^^^^^^^^^
 
-Includes methods for managing your user.
+Includes methods for interacting with our Longview service.
 
-.. autoclass:: linode_api4.linode_client.ProfileGroup
+.. autoclass:: linode_api4.linode_client.LongviewGroup
    :members:
+   :special-members:
 
 NetworkingGroup
 ^^^^^^^^^^^^^^^
@@ -84,14 +123,16 @@ Includes methods for managing your networking systems.
 
 .. autoclass:: linode_api4.linode_client.NetworkingGroup
    :members:
+   :special-members:
 
-LongviewGroup
-^^^^^^^^^^^^^
+NodeBalancerGroup
+^^^^^^^^^^^^^^^^^
 
-Includes methods for interacting with our Longview service.
+Includes methods for managing Linode NodeBalancers.
 
-.. autoclass:: linode_api4.linode_client.LongviewGroup
+.. autoclass:: linode_api4.linode_client.NodeBalancerGroup
    :members:
+   :special-members:
 
 ObjectStorageGroup
 ^^^^^^^^^^^^^^^^^^
@@ -101,16 +142,27 @@ with buckets and objects, use the s3 API directly with a library like `boto3`_.
 
 .. autoclass:: linode_api4.linode_client.ObjectStorageGroup
    :members:
+   :special-members:
 
 .. _boto3: https://github.com/boto/boto3
 
-LKE Group
-^^^^^^^^^
+ProfileGroup
+^^^^^^^^^^^^
 
-Includes methods for interacting with Linode Kubernetes Engine.
+Includes methods for managing your user.
 
-.. autoclass:: linode_api4.linode_client.LKEGroup
+.. autoclass:: linode_api4.linode_client.ProfileGroup
+   :members:
+   :special-members:
+
+RegionGroup
+^^^^^^^^^^^
+
+Includes methods for accessing information about Linode Regions.
+
+.. autoclass:: linode_api4.linode_client.RegionGroup
    :members:
+   :special-members:
 
 SupportGroup
 ^^^^^^^^^^^^
@@ -119,3 +171,22 @@ Includes methods for viewing and opening tickets with our support department.
 
 .. autoclass:: linode_api4.linode_client.SupportGroup
    :members:
+   :special-members:
+
+TagGroup
+^^^^^^^^
+
+Includes methods for managing Linode Tags.
+
+.. autoclass:: linode_api4.linode_client.TagGroup
+   :members:
+   :special-members:
+
+VolumeGroup
+^^^^^^^^^^^
+
+Includes methods for managing Linode Volumes.
+
+.. autoclass:: linode_api4.linode_client.VolumeGroup
+   :members:
+   :special-members:
diff --git a/linode_api4/__init__.py b/linode_api4/__init__.py
index f9266732f..bd1d6023a 100644
--- a/linode_api4/__init__.py
+++ b/linode_api4/__init__.py
@@ -1,3 +1,4 @@
+# isort: skip_file
 from linode_api4.objects import *
 from linode_api4.errors import ApiError, UnexpectedResponseError
 from linode_api4.linode_client import LinodeClient
diff --git a/linode_api4/common.py b/linode_api4/common.py
index aacdd55c3..df3da9733 100644
--- a/linode_api4/common.py
+++ b/linode_api4/common.py
@@ -1,8 +1,13 @@
 import os
 
-
-SSH_KEY_TYPES = ("ssh-dss", "ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256",
-                 "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521")
+SSH_KEY_TYPES = (
+    "ssh-dss",
+    "ssh-rsa",
+    "ssh-ed25519",
+    "ecdsa-sha2-nistp256",
+    "ecdsa-sha2-nistp384",
+    "ecdsa-sha2-nistp521",
+)
 
 
 def load_and_validate_keys(authorized_keys):
@@ -26,8 +31,15 @@ def load_and_validate_keys(authorized_keys):
     ret = []
 
     for k in authorized_keys:
-        accepted_types = ('ssh-dss', 'ssh-rsa', 'ecdsa-sha2-nistp', 'ssh-ed25519')
-        if any([ t for t in accepted_types if k.startswith(t) ]): # pylint: disable=use-a-generator
+        accepted_types = (
+            "ssh-dss",
+            "ssh-rsa",
+            "ecdsa-sha2-nistp",
+            "ssh-ed25519",
+        )
+        if any(
+            [t for t in accepted_types if k.startswith(t)]
+        ):  # pylint: disable=use-a-generator
             # this looks like a key, cool
             ret.append(k)
         else:
@@ -37,7 +49,11 @@ def load_and_validate_keys(authorized_keys):
                 with open(k) as f:
                     ret.append(f.read().rstrip())
             else:
-                raise ValueError("authorized_keys must either be paths "
-                                 "to the key files or a list of raw "
-                                 "public key of one of these types: {}".format(accepted_types))
+                raise ValueError(
+                    "authorized_keys must either be paths "
+                    "to the key files or a list of raw "
+                    "public key of one of these types: {}".format(
+                        accepted_types
+                    )
+                )
     return ret
diff --git a/linode_api4/errors.py b/linode_api4/errors.py
index 54df28dce..bc2df6108 100644
--- a/linode_api4/errors.py
+++ b/linode_api4/errors.py
@@ -7,13 +7,15 @@ class ApiError(RuntimeError):
     typically have a status code in the 400s or 500s.  Most
     often, this will be caused by invalid input to the API.
     """
+
     def __init__(self, message, status=400, json=None):
         super().__init__(message)
         self.status = status
         self.json = json
         self.errors = []
-        if json and 'errors' in json and isinstance(json['errors'], list):
-            self.errors = [ e['reason'] for e in json['errors'] ]
+        if json and "errors" in json and isinstance(json["errors"], list):
+            self.errors = [e["reason"] for e in json["errors"]]
+
 
 class UnexpectedResponseError(RuntimeError):
     """
@@ -23,6 +25,7 @@ class UnexpectedResponseError(RuntimeError):
     These typically indicate an oversight in developing this
     library, and should be fixed with changes to this codebase.
     """
+
     def __init__(self, message, status=200, json=None):
         super().__init__(message)
         self.status = status
diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py
new file mode 100644
index 000000000..bf815fffd
--- /dev/null
+++ b/linode_api4/groups/__init__.py
@@ -0,0 +1,18 @@
+# Group needs to be imported first
+from .group import *  # isort: skip
+
+from .account import *
+from .database import *
+from .domain import *
+from .image import *
+from .linode import *
+from .lke import *
+from .longview import *
+from .networking import *
+from .nodebalancer import *
+from .object_storage import *
+from .profile import *
+from .region import *
+from .support import *
+from .tag import *
+from .volume import *
diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py
new file mode 100644
index 000000000..d630939e0
--- /dev/null
+++ b/linode_api4/groups/account.py
@@ -0,0 +1,444 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import (
+    Account,
+    AccountSettings,
+    Event,
+    Invoice,
+    Login,
+    MappedObject,
+    OAuthClient,
+    Payment,
+    PaymentMethod,
+    ServiceTransfer,
+    User,
+)
+
+
+class AccountGroup(Group):
+    """
+    Collections related to your account.
+    """
+
+    def __call__(self):
+        """
+        Retrieves information about the acting user's account, such as billing
+        information.  This is intended to be called off of the :any:`LinodeClient`
+        class, like this::
+
+           account = client.account()
+
+        API Documentation: https://www.linode.com/docs/api/account/#account-view
+
+        :returns: Returns the acting user's account information.
+        :rtype: Account
+        """
+        result = self.client.get("/account")
+
+        if not "email" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when getting account!", json=result
+            )
+
+        return Account(self.client, result["email"], result)
+
+    def events(self, *filters):
+        """
+        Lists events on the current account matching the given filters.
+
+        API Documentation: https://www.linode.com/docs/api/account/#events-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of events on the current account matching the given filters.
+        :rtype: PaginatedList of Event
+        """
+
+        return self.client._get_and_filter(Event, *filters)
+
+    def events_mark_seen(self, event):
+        """
+        Marks event as the last event we have seen.  If event is an int, it is treated
+        as an event_id, otherwise it should be an event object whose id will be used.
+
+        API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen
+
+        :param event: The Linode event to mark as seen.
+        :type event: Event or int
+        """
+        last_seen = event if isinstance(event, int) else event.id
+        self.client.post(
+            "{}/seen".format(Event.api_endpoint),
+            model=Event(self.client, last_seen),
+        )
+
+    def settings(self):
+        """
+        Returns the account settings data for this acocunt.  This is not  a
+        listing endpoint.
+
+        API Documentation: https://www.linode.com/docs/api/account/#account-settings-view
+
+        :returns: The account settings data for this account.
+        :rtype: AccountSettings
+        """
+        result = self.client.get("/account/settings")
+
+        if not "managed" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when getting account settings!",
+                json=result,
+            )
+
+        s = AccountSettings(self.client, result["managed"], result)
+        return s
+
+    def invoices(self):
+        """
+        Returns Invoices issued to this account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#invoices-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: Invoices issued to this account.
+        :rtype: PaginatedList of Invoice
+        """
+        return self.client._get_and_filter(Invoice)
+
+    def payments(self):
+        """
+        Returns a list of Payments made on this account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#payments-list
+
+        :returns: A list of payments made on this account.
+        :rtype: PaginatedList of Payment
+        """
+        return self.client._get_and_filter(Payment)
+
+    def oauth_clients(self, *filters):
+        """
+        Returns the OAuth Clients associated with this account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#oauth-clients-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of OAuth Clients associated with this account.
+        :rtype: PaginatedList of OAuthClient
+        """
+        return self.client._get_and_filter(OAuthClient, *filters)
+
+    def oauth_client_create(self, name, redirect_uri, **kwargs):
+        """
+        Creates a new OAuth client.
+
+        API Documentation: https://www.linode.com/docs/api/account/#oauth-client-create
+
+        :param name: The name of this application.
+        :type name: str
+        :param redirect_uri: The location a successful log in from https://login.linode.com should be redirected to for this client.
+        :type redirect_uri: str
+
+        :returns: The created OAuth Client.
+        :rtype: OAuthClient
+        """
+        params = {
+            "label": name,
+            "redirect_uri": redirect_uri,
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/account/oauth-clients", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating OAuth Client!", json=result
+            )
+
+        c = OAuthClient(self.client, result["id"], result)
+        return c
+
+    def users(self, *filters):
+        """
+        Returns a list of users on this account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#users-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of users on this account.
+        :rtype: PaginatedList of User
+        """
+        return self.client._get_and_filter(User, *filters)
+
+    def logins(self):
+        """
+        Returns a collection of successful logins for all users on the account during the last 90 days.
+
+        API Documentation: https://www.linode.com/docs/api/account/#user-logins-list-all
+
+        :returns: A list of Logins on this account.
+        :rtype: PaginatedList of Login
+        """
+
+        return self.client._get_and_filter(Login)
+
+    def maintenance(self):
+        """
+        Returns a collection of Maintenance objects for any entity a user has permissions to view. Cancelled Maintenance objects are not returned.
+
+        API Documentation: https://www.linode.com/docs/api/account/#user-logins-list-all
+
+        :returns: A list of Maintenance objects on this account.
+        :rtype: List of Maintenance objects as MappedObjects
+        """
+
+        result = self.client.get(
+            "{}/maintenance".format(Account.api_endpoint), model=self
+        )
+
+        return [MappedObject(**r) for r in result["data"]]
+
+    def payment_methods(self):
+        """
+        Returns a  list of Payment Methods for this Account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#payment-methods-list
+
+        :returns: A list of Payment Methods on this account.
+        :rtype: PaginatedList of PaymentMethod
+        """
+
+        return self.client._get_and_filter(PaymentMethod)
+
+    def add_payment_method(self, data, is_default, type):
+        """
+        Adds a Payment Method to your Account with the option to set it as the default method.
+
+        API Documentation: https://www.linode.com/docs/api/account/#payment-method-add
+
+        :param data: An object representing the credit card information you have on file with
+                     Linode to make Payments against your Account.
+        :type data: dict
+
+        Example usage::
+           data = {
+                "card_number": "4111111111111111",
+                "expiry_month": 11,
+                "expiry_year": 2020,
+                "cvv": "111"
+            }
+
+        :param is_default: Whether this Payment Method is the default method for
+                           automatically processing service charges.
+        :type is_default: bool
+
+        :param type: The type of Payment Method. Enum: ["credit_card]
+        :type type: str
+        """
+
+        if type != "credit_card":
+            raise ValueError("Unknown Payment Method type: {}".format(type))
+
+        if (
+            "card_number" not in data
+            or "expiry_month" not in data
+            or "expiry_year" not in data
+            or "cvv" not in data
+            or not data
+        ):
+            raise ValueError("Invalid credit card info provided")
+
+        params = {"data": data, "type": type, "is_default": is_default}
+
+        resp = self.client.post(
+            "{}/payment-methods".format(Account.api_endpoint),
+            model=self,
+            data=params,
+        )
+
+        if "error" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when adding payment method!",
+                json=resp,
+            )
+
+    def notifications(self):
+        """
+        Returns a collection of Notification objects representing important, often time-sensitive items related to your Account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#notifications-list
+
+        :returns: A list of Notifications on this account.
+        :rtype: List of Notification objects as MappedObjects
+        """
+
+        result = self.client.get(
+            "{}/notifications".format(Account.api_endpoint), model=self
+        )
+
+        return [MappedObject(**r) for r in result["data"]]
+
+    def linode_managed_enable(self):
+        """
+        Enables Linode Managed for the entire account and sends a welcome email to the account’s associated email address.
+
+        API Documentation: https://www.linode.com/docs/api/account/#linode-managed-enable
+        """
+
+        resp = self.client.post(
+            "{}/settings/managed-enable".format(Account.api_endpoint),
+            model=self,
+        )
+
+        if "error" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when enabling Linode Managed!",
+                json=resp,
+            )
+
+    def add_promo_code(self, promo_code):
+        """
+        Adds an expiring Promo Credit to your account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#promo-credit-add
+
+        :param promo_code: The Promo Code.
+        :type promo_code: str
+        """
+
+        params = {
+            "promo_code": promo_code,
+        }
+
+        resp = self.client.post(
+            "{}/promo-codes".format(Account.api_endpoint),
+            model=self,
+            data=params,
+        )
+
+        if "error" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when adding Promo Code!",
+                json=resp,
+            )
+
+    def service_transfers(self):
+        """
+        Returns a collection of all created and accepted Service Transfers for this account, regardless of the user that created or accepted the transfer.
+
+        API Documentation: https://www.linode.com/docs/api/account/#service-transfers-list
+
+        :returns: A list of Service Transfers on this account.
+        :rtype: PaginatedList of ServiceTransfer
+        """
+
+        return self.client._get_and_filter(ServiceTransfer)
+
+    def service_transfer_create(self, entities):
+        """
+        Creates a transfer request for the specified services.
+
+        API Documentation: https://www.linode.com/docs/api/account/#service-transfer-create
+
+        :param entities: A collection of the services to include in this transfer request, separated by type.
+        :type entities: dict
+
+        Example usage::
+           entities = {
+                "linodes": [
+                    111,
+                    222
+                ]
+            }
+        """
+
+        if not entities:
+            raise ValueError("Entities must be provided!")
+
+        bad_entries = [
+            k for k, v in entities.items() if not isinstance(v, list)
+        ]
+        if len(bad_entries) > 0:
+            raise ValueError(
+                f"Got unexpected type for entity lists: {', '.join(bad_entries)}"
+            )
+
+        params = {"entities": entities}
+
+        resp = self.client.post(
+            "{}/service-transfers".format(Account.api_endpoint),
+            model=self,
+            data=params,
+        )
+
+        if "error" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Service Transfer!",
+                json=resp,
+            )
+
+    def transfer(self):
+        """
+        Returns a MappedObject containing the account's transfer pool data.
+
+        API Documentation: https://www.linode.com/docs/api/account/#network-utilization-view
+
+        :returns: Information about this account's transfer pool data.
+        :rtype: MappedObject
+        """
+        result = self.client.get("/account/transfer")
+
+        if not "used" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when getting Transfer Pool!"
+            )
+
+        return MappedObject(**result)
+
+    def user_create(self, email, username, restricted=True):
+        """
+        Creates a new user on your account.  If you create an unrestricted user,
+        they will immediately be able to access everything on your account.  If
+        you create a restricted user, you must grant them access to parts of your
+        account that you want to allow them to manage (see :any:`User.grants` for
+        details).
+
+        The new user will receive an email inviting them to set up their password.
+        This must be completed before they can log in.
+
+        API Documentation: https://www.linode.com/docs/api/account/#user-create
+
+        :param email: The new user's email address.  This is used to finish setting
+                      up their user account.
+        :type email: str
+        :param username: The new user's unique username.  They will use this username
+                         to log in.
+        :type username: str
+        :param restricted: If True, the new user must be granted access to parts of
+                           the account before they can do anything.  If False, the
+                           new user will immediately be able to manage the entire
+                           account.  Defaults to True.
+        :type restricted: True
+
+        :returns The new User.
+        :rtype: User
+        """
+        params = {
+            "email": email,
+            "username": username,
+            "restricted": restricted,
+        }
+        result = self.client.post("/account/users", data=params)
+
+        if not all(
+            [c in result for c in ("email", "restricted", "username")]
+        ):  # pylint: disable=use-a-generator
+            raise UnexpectedResponseError(
+                "Unexpected response when creating user!", json=result
+            )
+
+        u = User(self.client, result["username"], result)
+        return u
diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py
new file mode 100644
index 000000000..494c30eba
--- /dev/null
+++ b/linode_api4/groups/database.py
@@ -0,0 +1,266 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import (
+    Base,
+    Database,
+    DatabaseEngine,
+    DatabaseType,
+    MongoDBDatabase,
+    MySQLDatabase,
+    PostgreSQLDatabase,
+)
+
+
+class DatabaseGroup(Group):
+    """
+    Encapsulates Linode Managed Databases related methods of the :any:`LinodeClient`. This
+    should not be instantiated on its own, but should instead be used through
+    an instance of :any:`LinodeClient`::
+
+       client = LinodeClient(token)
+       instances = client.database.instances() # use the DatabaseGroup
+
+    This group contains all features beneath the `/databases` group in the API v4.
+    """
+
+    def types(self, *filters):
+        """
+        Returns a list of Linode Database-compatible Instance types.
+        These may be used to create Managed Databases, or simply
+        referenced to on their own. DatabaseTypes can be
+        filtered to return specific types, for example::
+
+           database_types = client.database.types(DatabaseType.deprecated == False)
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-database-types-list
+
+        :param filters: Any number of filters to apply to the query.
+
+        :returns: A list of types that match the query.
+        :rtype: PaginatedList of DatabaseType
+        """
+        return self.client._get_and_filter(DatabaseType, *filters)
+
+    def engines(self, *filters):
+        """
+        Returns a list of Linode Managed Database Engines.
+        These may be used to create Managed Databases, or simply
+        referenced to on their own. Engines can be filtered to
+        return specific engines, for example::
+
+           mysql_engines = client.database.engines(DatabaseEngine.engine == 'mysql')
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engines-list
+
+        :param filters: Any number of filters to apply to the query.
+
+        :returns: A list of types that match the query.
+        :rtype: PaginatedList of DatabaseEngine
+        """
+        return self.client._get_and_filter(DatabaseEngine, *filters)
+
+    def instances(self, *filters):
+        """
+        Returns a list of Managed Databases active on this account.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-databases-list-all
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of databases that matched the query.
+        :rtype: PaginatedList of Database
+        """
+        return self.client._get_and_filter(Database, *filters)
+
+    def mysql_instances(self, *filters):
+        """
+        Returns a list of Managed MySQL Databases active on this account.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-databases-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of MySQL databases that matched the query.
+        :rtype: PaginatedList of MySQLDatabase
+        """
+        return self.client._get_and_filter(MySQLDatabase, *filters)
+
+    def mysql_create(self, label, region, engine, ltype, **kwargs):
+        """
+        Creates an :any:`MySQLDatabase` on this account with
+        the given label, region, engine, and node type.  For example::
+
+           client = LinodeClient(TOKEN)
+
+           # look up Region and Types to use.  In this example I'm just using
+           # the first ones returned.
+           region = client.regions().first()
+           node_type = client.database.types()[0]
+           engine = client.database.engines(DatabaseEngine.engine == 'mysql')[0]
+
+           new_database = client.database.mysql_create(
+               "example-database",
+               region,
+               engine.id,
+               type.id
+            )
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-create
+
+        :param label: The name for this cluster
+        :type label: str
+        :param region: The region to deploy this cluster in
+        :type region: str or Region
+        :param engine: The engine to deploy this cluster with
+        :type engine: str or Engine
+        :param ltype: The Linode Type to use for this cluster
+        :type ltype: str or Type
+        """
+
+        params = {
+            "label": label,
+            "region": region.id if issubclass(type(region), Base) else region,
+            "engine": engine.id if issubclass(type(engine), Base) else engine,
+            "type": ltype.id if issubclass(type(ltype), Base) else ltype,
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/databases/mysql/instances", data=params)
+
+        if "id" not in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating MySQL Database", json=result
+            )
+
+        d = MySQLDatabase(self.client, result["id"], result)
+        return d
+
+    def postgresql_instances(self, *filters):
+        """
+        Returns a list of Managed PostgreSQL Databases active on this account.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-databases-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of PostgreSQL databases that matched the query.
+        :rtype: PaginatedList of PostgreSQLDatabase
+        """
+        return self.client._get_and_filter(PostgreSQLDatabase, *filters)
+
+    def postgresql_create(self, label, region, engine, ltype, **kwargs):
+        """
+        Creates an :any:`PostgreSQLDatabase` on this account with
+        the given label, region, engine, and node type.  For example::
+
+           client = LinodeClient(TOKEN)
+
+           # look up Region and Types to use.  In this example I'm just using
+           # the first ones returned.
+           region = client.regions().first()
+           node_type = client.database.types()[0]
+           engine = client.database.engines(DatabaseEngine.engine == 'postgresql')[0]
+
+           new_database = client.database.postgresql_create(
+               "example-database",
+               region,
+               engine.id,
+               type.id
+            )
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-create
+
+        :param label: The name for this cluster
+        :type label: str
+        :param region: The region to deploy this cluster in
+        :type region: str or Region
+        :param engine: The engine to deploy this cluster with
+        :type engine: str or Engine
+        :param ltype: The Linode Type to use for this cluster
+        :type ltype: str or Type
+        """
+
+        params = {
+            "label": label,
+            "region": region.id if issubclass(type(region), Base) else region,
+            "engine": engine.id if issubclass(type(engine), Base) else engine,
+            "type": ltype.id if issubclass(type(ltype), Base) else ltype,
+        }
+        params.update(kwargs)
+
+        result = self.client.post(
+            "/databases/postgresql/instances", data=params
+        )
+
+        if "id" not in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating PostgreSQL Database",
+                json=result,
+            )
+
+        d = PostgreSQLDatabase(self.client, result["id"], result)
+        return d
+
+    def mongodb_instances(self, *filters):
+        """
+        Returns a list of Managed MongoDB Databases active on this account.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-databases-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of MongoDB databases that matched the query.
+        :rtype: PaginatedList of MongoDBDatabase
+        """
+        return self.client._get_and_filter(MongoDBDatabase, *filters)
+
+    def mongodb_create(self, label, region, engine, ltype, **kwargs):
+        """
+        Creates an :any:`MongoDBDatabase` on this account with
+        the given label, region, engine, and node type.  For example::
+
+           client = LinodeClient(TOKEN)
+
+           # look up Region and Types to use.  In this example I'm just using
+           # the first ones returned.
+           region = client.regions().first()
+           node_type = client.database.types()[0]
+           engine = client.database.engines(DatabaseEngine.engine == 'mongodb')[0]
+
+           new_database = client.database.mongodb_create(
+               "example-database",
+               region,
+               engine.id,
+               type.id
+            )
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-create
+
+        :param label: The name for this cluster
+        :type label: str
+        :param region: The region to deploy this cluster in
+        :type region: str or Region
+        :param engine: The engine to deploy this cluster with
+        :type engine: str or Engine
+        :param ltype: The Linode Type to use for this cluster
+        :type ltype: str or Type
+        """
+
+        params = {
+            "label": label,
+            "region": region.id if issubclass(type(region), Base) else region,
+            "engine": engine.id if issubclass(type(engine), Base) else engine,
+            "type": ltype.id if issubclass(type(ltype), Base) else ltype,
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/databases/mongodb/instances", data=params)
+
+        if "id" not in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating MongoDB Database",
+                json=result,
+            )
+
+        d = MongoDBDatabase(self.client, result["id"], result)
+        return d
diff --git a/linode_api4/groups/domain.py b/linode_api4/groups/domain.py
new file mode 100644
index 000000000..731bc3fb4
--- /dev/null
+++ b/linode_api4/groups/domain.py
@@ -0,0 +1,59 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import Domain
+
+
+class DomainGroup(Group):
+    def __call__(self, *filters):
+        """
+        Retrieves all of the Domains the acting user has access to.
+
+        This is intended to be called off of the :any:`LinodeClient`
+        class, like this::
+
+           domains = client.domains()
+
+        API Documentation: https://www.linode.com/docs/api/domains/#domains-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Domains the acting user can access.
+        :rtype: PaginatedList of Domain
+        """
+        return self.client._get_and_filter(Domain, *filters)
+
+    def create(self, domain, master=True, **kwargs):
+        """
+        Registers a new Domain on the acting user's account.  Make sure to point
+        your registrar to Linode's nameservers so that Linode's DNS manager will
+        correctly serve your domain.
+
+        API Documentation: https://www.linode.com/docs/api/domains/#domain-create
+
+        :param domain: The domain to register to Linode's DNS manager.
+        :type domain: str
+        :param master: Whether this is a master (defaults to true)
+        :type master: bool
+        :param tags: A list of tags to apply to the new domain.  If any of the
+                     tags included do not exist, they will be created as part of
+                     this operation.
+        :type tags: list[str]
+
+        :returns: The new Domain object.
+        :rtype: Domain
+        """
+        params = {
+            "domain": domain,
+            "type": "master" if master else "slave",
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/domains", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Domain!", json=result
+            )
+
+        d = Domain(self.client, result["id"], result)
+        return d
diff --git a/linode_api4/groups/group.py b/linode_api4/groups/group.py
new file mode 100644
index 000000000..1ca41627a
--- /dev/null
+++ b/linode_api4/groups/group.py
@@ -0,0 +1,3 @@
+class Group:
+    def __init__(self, client: "LinodeClient"):
+        self.client = client
diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py
new file mode 100644
index 000000000..c1d6f998e
--- /dev/null
+++ b/linode_api4/groups/image.py
@@ -0,0 +1,130 @@
+from typing import BinaryIO, Tuple
+
+import requests
+
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import Base, Image
+from linode_api4.util import drop_null_keys
+
+
+class ImageGroup(Group):
+    def __call__(self, *filters):
+        """
+        Retrieves a list of available Images, including public and private
+        Images available to the acting user.  You can filter this query to
+        retrieve only Images relevant to a specific query, for example::
+
+           debian_images = client.images(
+               Image.vendor == "debain")
+
+        API Documentation: https://www.linode.com/docs/api/images/#images-list
+
+        :param filters: Any number of filters to apply to the query.
+
+        :returns: A list of available Images.
+        :rtype: PaginatedList of Image
+        """
+        return self.client._get_and_filter(Image, *filters)
+
+    def create(self, disk, label=None, description=None):
+        """
+        Creates a new Image from a disk you own.
+
+        API Documentation: https://www.linode.com/docs/api/images/#image-create
+
+        :param disk: The Disk to imagize.
+        :type disk: Disk or int
+        :param label: The label for the resulting Image (defaults to the disk's
+                      label.
+        :type label: str
+        :param description: The description for the new Image.
+        :type description: str
+
+        :returns: The new Image.
+        :rtype: Image
+        """
+        params = {
+            "disk_id": disk.id if issubclass(type(disk), Base) else disk,
+        }
+
+        if label is not None:
+            params["label"] = label
+
+        if description is not None:
+            params["description"] = description
+
+        result = self.client.post("/images", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating an Image from disk {}".format(
+                    disk
+                )
+            )
+
+        return Image(self.client, result["id"], result)
+
+    def create_upload(
+        self, label: str, region: str, description: str = None
+    ) -> Tuple[Image, str]:
+        """
+        Creates a new Image and returns the corresponding upload URL.
+
+        API Documentation: https://www.linode.com/docs/api/images/#image-upload
+
+        :param label: The label of the Image to create.
+        :type label: str
+        :param region: The region to upload to. Once the image has been created, it can be used in any region.
+        :type region: str
+        :param description: The description for the new Image.
+        :type description: str
+
+        :returns: A tuple containing the new image and the image upload URL.
+        :rtype: (Image, str)
+        """
+        params = {"label": label, "region": region, "description": description}
+
+        result = self.client.post("/images/upload", data=drop_null_keys(params))
+
+        if "image" not in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating an Image upload URL"
+            )
+
+        result_image = result["image"]
+        result_url = result["upload_to"]
+
+        return Image(self.client, result_image["id"], result_image), result_url
+
+    def upload(
+        self, label: str, region: str, file: BinaryIO, description: str = None
+    ) -> Image:
+        """
+        Creates and uploads a new image.
+
+        API Documentation: https://www.linode.com/docs/api/images/#image-upload
+
+        :param label: The label of the Image to create.
+        :type label: str
+        :param region: The region to upload to. Once the image has been created, it can be used in any region.
+        :type region: str
+        :param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb").
+        :param description: The description for the new Image.
+        :type description: str
+
+        :returns: The resulting image.
+        :rtype: Image
+        """
+
+        image, url = self.create_upload(label, region, description=description)
+
+        requests.put(
+            url,
+            headers={"Content-Type": "application/octet-stream"},
+            data=file,
+        )
+
+        image._api_get()
+
+        return image
diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py
new file mode 100644
index 000000000..3c4112d29
--- /dev/null
+++ b/linode_api4/groups/linode.py
@@ -0,0 +1,365 @@
+import os
+
+from linode_api4 import Profile
+from linode_api4.common import SSH_KEY_TYPES, load_and_validate_keys
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import (
+    AuthorizedApp,
+    Base,
+    Image,
+    Instance,
+    Kernel,
+    PersonalAccessToken,
+    SSHKey,
+    StackScript,
+    Type,
+)
+from linode_api4.objects.filtering import Filter
+from linode_api4.paginated_list import PaginatedList
+
+
+class LinodeGroup(Group):
+    """
+    Encapsulates Linode-related methods of the :any:`LinodeClient`.  This
+    should not be instantiated on its own, but should instead be used through
+    an instance of :any:`LinodeClient`::
+
+       client = LinodeClient(token)
+       instances = client.linode.instances() # use the LinodeGroup
+
+    This group contains all features beneath the `/linode` group in the API v4.
+    """
+
+    def types(self, *filters):
+        """
+        Returns a list of Linode Instance types.  These may be used to create
+        or resize Linodes, or simply referenced on their own.  Types can be
+        filtered to return specific types, for example::
+
+           standard_types = client.linode.types(Type.class == "standard")
+
+        API documentation: https://www.linode.com/docs/api/linode-types/#types-list
+
+        :param filters: Any number of filters to apply to the query.
+
+        :returns: A list of types that match the query.
+        :rtype: PaginatedList of Type
+        """
+        return self.client._get_and_filter(Type, *filters)
+
+    def instances(self, *filters):
+        """
+        Returns a list of Linode Instances on your account.  You may filter
+        this query to return only Linodes that match specific criteria::
+
+           prod_linodes = client.linode.instances(Instance.group == "prod")
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Instances that matched the query.
+        :rtype: PaginatedList of Instance
+        """
+        return self.client._get_and_filter(Instance, *filters)
+
+    def stackscripts(self, *filters, **kwargs):
+        """
+        Returns a list of :any:`StackScripts<StackScript>`, both public and
+        private.  You may filter this query to return only
+        :any:`StackScripts<StackScript>` that match certain criteria.  You may
+        also request only your own private :any:`StackScripts<StackScript>`::
+
+           my_stackscripts = client.linode.stackscripts(mine_only=True)
+
+        API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscripts-list
+
+        :param filters: Any number of filters to apply to this query.
+        :param mine_only: If True, returns only private StackScripts
+        :type mine_only: bool
+
+        :returns: A list of StackScripts matching the query.
+        :rtype: PaginatedList of StackScript
+        """
+        # python2 can't handle *args and a single keyword argument, so this is a workaround
+        if "mine_only" in kwargs:
+            if kwargs["mine_only"]:
+                new_filter = Filter({"mine": True})
+                if filters:
+                    filters = list(filters)
+                    filters[0] = filters[0] & new_filter
+                else:
+                    filters = [new_filter]
+
+            del kwargs["mine_only"]
+
+        if kwargs:
+            raise TypeError(
+                "stackscripts() got unexpected keyword argument '{}'".format(
+                    kwargs.popitem()[0]
+                )
+            )
+
+        return self.client._get_and_filter(StackScript, *filters)
+
+    def kernels(self, *filters):
+        """
+        Returns a list of available :any:`Kernels<Kernel>`.  Kernels are used
+        when creating or updating :any:`LinodeConfigs,LinodeConfig>`.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#kernels-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of available kernels that match the query.
+        :rtype: PaginatedList of Kernel
+        """
+        return self.client._get_and_filter(Kernel, *filters)
+
+    # create things
+    def instance_create(
+        self, ltype, region, image=None, authorized_keys=None, **kwargs
+    ):
+        """
+        Creates a new Linode Instance. This function has several modes of operation:
+
+        **Create an Instance from an Image**
+
+        To create an Instance from an :any:`Image`, call `instance_create` with
+        a :any:`Type`, a :any:`Region`, and an :any:`Image`.  All three of
+        these fields may be provided as either the ID or the appropriate object.
+        In this mode, a root password will be generated and returned with the
+        new Instance object.  For example::
+
+           new_linode, password = client.linode.instance_create(
+               "g6-standard-2",
+               "us-east",
+               image="linode/debian9")
+
+           ltype = client.linode.types().first()
+           region = client.regions().first()
+           image = client.images().first()
+
+           another_linode, password = client.linode.instance_create(
+               ltype,
+               region,
+               image=image)
+
+        **Create an Instance from StackScript**
+
+        When creating an Instance from a :any:`StackScript`, an :any:`Image` that
+        the StackScript support must be provided..  You must also provide any
+        required StackScript data for the script's User Defined Fields..  For
+        example, if deploying `StackScript 10079`_ (which deploys a new Instance
+        with a user created from keys on `github`_::
+
+           stackscript = StackScript(client, 10079)
+
+           new_linode, password = client.linode.instance_create(
+              "g6-standard-2",
+              "us-east",
+              image="linode/debian9",
+              stackscript=stackscript,
+              stackscript_data={"gh_username": "example"})
+
+        In the above example, "gh_username" is the name of a User Defined Field
+        in the chosen StackScript.  For more information on StackScripts, see
+        the `StackScript guide`_.
+
+        .. _`StackScript 10079`: https://www.linode.com/stackscripts/view/10079
+        .. _`github`: https://github.com
+        .. _`StackScript guide`: https://www.linode.com/docs/platform/stackscripts/
+
+        **Create an Instance from a Backup**
+
+        To create a new Instance by restoring a :any:`Backup` to it, provide a
+        :any:`Type`, a :any:`Region`, and the :any:`Backup` to restore.  You
+        may provide either IDs or objects for all of these fields::
+
+           existing_linode = Instance(client, 123)
+           snapshot = existing_linode.available_backups.snapshot.current
+
+           new_linode = client.linode.instance_create(
+               "g6-standard-2",
+               "us-east",
+               backup=snapshot)
+
+        **Create an empty Instance**
+
+        If you want to create an empty Instance that you will configure manually,
+        simply call `instance_create` with a :any:`Type` and a :any:`Region`::
+
+           empty_linode = client.linode.instance_create("g6-standard-2", "us-east")
+
+        When created this way, the Instance will not be booted and cannot boot
+        successfully until disks and configs are created, or it is otherwise
+        configured.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-create
+
+        :param ltype: The Instance Type we are creating
+        :type ltype: str or Type
+        :param region: The Region in which we are creating the Instance
+        :type region: str or Region
+        :param image: The Image to deploy to this Instance. If this is provided
+                      and no root_pass is given, a password will be generated
+                      and returned along with the new Instance.
+        :type image: str or Image
+        :param stackscript: The StackScript to deploy to the new Instance.  If
+                            provided, "image" is required and must be compatible
+                            with the chosen StackScript.
+        :type stackscript: int or StackScript
+        :param stackscript_data: Values for the User Defined Fields defined in
+                                 the chosen StackScript.  Does nothing if
+                                 StackScript is not provided.
+        :type stackscript_data: dict
+        :param backup: The Backup to restore to the new Instance.  May not be
+                       provided if "image" is given.
+        :type backup: int of Backup
+        :param authorized_keys: The ssh public keys to install in the linode's
+                                /root/.ssh/authorized_keys file.  Each entry may
+                                be a single key, or a path to a file containing
+                                the key.
+        :type authorized_keys: list or str
+        :param label: The display label for the new Instance
+        :type label: str
+        :param group: The display group for the new Instance
+        :type group: str
+        :param booted: Whether the new Instance should be booted.  This will
+                       default to True if the Instance is deployed from an Image
+                       or Backup.
+        :type booted: bool
+        :param tags: A list of tags to apply to the new instance.  If any of the
+                     tags included do not exist, they will be created as part of
+                     this operation.
+        :type tags: list[str]
+        :param private_ip: Whether the new Instance should have private networking
+                           enabled and assigned a private IPv4 address.
+        :type private_ip: bool
+
+        :returns: A new Instance object, or a tuple containing the new Instance and
+                  the generated password.
+        :rtype: Instance or tuple(Instance, str)
+        :raises ApiError: If contacting the API fails
+        :raises UnexpectedResponseError: If the API response is somehow malformed.
+                                         This usually indicates that you are using
+                                         an outdated library.
+        """
+        ret_pass = None
+        if image and not "root_pass" in kwargs:
+            ret_pass = Instance.generate_root_password()
+            kwargs["root_pass"] = ret_pass
+
+        authorized_keys = load_and_validate_keys(authorized_keys)
+
+        if "stackscript" in kwargs:
+            # translate stackscripts
+            kwargs["stackscript_id"] = (
+                kwargs["stackscript"].id
+                if issubclass(type(kwargs["stackscript"]), Base)
+                else kwargs["stackscript"]
+            )
+            del kwargs["stackscript"]
+
+        if "backup" in kwargs:
+            # translate backups
+            kwargs["backup_id"] = (
+                kwargs["backup"].id
+                if issubclass(type(kwargs["backup"]), Base)
+                else kwargs["backup"]
+            )
+            del kwargs["backup"]
+
+        params = {
+            "type": ltype.id if issubclass(type(ltype), Base) else ltype,
+            "region": region.id if issubclass(type(region), Base) else region,
+            "image": (image.id if issubclass(type(image), Base) else image)
+            if image
+            else None,
+            "authorized_keys": authorized_keys,
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/linode/instances", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating linode!", json=result
+            )
+
+        l = Instance(self.client, result["id"], result)
+        if not ret_pass:
+            return l
+        return l, ret_pass
+
+    def stackscript_create(
+        self, label, script, images, desc=None, public=False, **kwargs
+    ):
+        """
+        Creates a new :any:`StackScript` on your account.
+
+        API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-create
+
+        :param label: The label for this StackScript.
+        :type label: str
+        :param script: The script to run when an :any:`Instance` is deployed with
+                       this StackScript.  Must begin with a shebang (#!).
+        :type script: str
+        :param images: A list of :any:`Images<Image>` that this StackScript
+                       supports.  Instances will not be deployed from this
+                       StackScript unless deployed from one of these Images.
+        :type images: list of Image
+        :param desc: A description for this StackScript.
+        :type desc: str
+        :param public: Whether this StackScript is public.  Defaults to False.
+                       Once a StackScript is made public, it may not be set
+                       back to private.
+        :type public: bool
+
+        :returns: The new StackScript
+        :rtype: StackScript
+        """
+        image_list = None
+        if type(images) is list or type(images) is PaginatedList:
+            image_list = [
+                d.id if issubclass(type(d), Base) else d for d in images
+            ]
+        elif type(images) is Image:
+            image_list = [images.id]
+        elif type(images) is str:
+            image_list = [images]
+        else:
+            raise ValueError(
+                "images must be a list of Images or a single Image"
+            )
+
+        script_body = script
+        if not script.startswith("#!"):
+            # it doesn't look like a stackscript body, let's see if it's a file
+            if os.path.isfile(script):
+                with open(script) as f:
+                    script_body = f.read()
+            else:
+                raise ValueError(
+                    "script must be the script text or a path to a file"
+                )
+
+        params = {
+            "label": label,
+            "images": image_list,
+            "is_public": public,
+            "script": script_body,
+            "description": desc if desc else "",
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/linode/stackscripts", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating StackScript!", json=result
+            )
+
+        s = StackScript(self.client, result["id"], result)
+        return s
diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py
new file mode 100644
index 000000000..dfd4c6d7a
--- /dev/null
+++ b/linode_api4/groups/lke.py
@@ -0,0 +1,136 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import Base, KubeVersion, LKECluster
+
+
+class LKEGroup(Group):
+    """
+    Encapsulates LKE-related methods of the :any:`LinodeClient`.  This
+    should not be instantiated on its own, but should instead be used through
+    an instance of :any:`LinodeClient`::
+
+       client = LinodeClient(token)
+       instances = client.lke.clusters() # use the LKEGroup
+
+    This group contains all features beneath the `/lke` group in the API v4.
+    """
+
+    def versions(self, *filters):
+        """
+        Returns a :any:`PaginatedList` of :any:`KubeVersion` objects that can be
+        used when creating an LKE Cluster.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-versions-list
+
+        :param filters: Any number of filters to apply to the query.
+
+        :returns: A Paginated List of kube versions that match the query.
+        :rtype: PaginatedList of KubeVersion
+        """
+        return self.client._get_and_filter(KubeVersion, *filters)
+
+    def clusters(self, *filters):
+        """
+        Returns a :any:`PaginagtedList` of :any:`LKECluster` objects that belong
+        to this account.
+
+        https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-clusters-list
+
+        :param filters: Any number of filters to apply to the query.
+
+        :returns: A Paginated List of LKE clusters that match the query.
+        :rtype: PaginatedList of LKECluster
+        """
+        return self.client._get_and_filter(LKECluster, *filters)
+
+    def cluster_create(self, region, label, node_pools, kube_version, **kwargs):
+        """
+        Creates an :any:`LKECluster` on this account in the given region, with
+        the given label, and with node pools as described.  For example::
+
+           client = LinodeClient(TOKEN)
+
+           # look up Region and Types to use.  In this example I'm just using
+           # the first ones returned.
+           target_region = client.regions().first()
+           node_type = client.linode.types()[0]
+           node_type_2 = client.linode.types()[1]
+           kube_version = client.lke.versions()[0]
+
+           new_cluster = client.lke.cluster_create(
+               target_region,
+               "example-cluster",
+               [client.lke.node_pool(node_type, 3), client.lke.node_pool(node_type_2, 3)],
+               kube_version
+            )
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-create
+
+        :param region: The Region to create this LKE Cluster in.
+        :type region: Region or str
+        :param label: The label for the new LKE Cluster.
+        :type label: str
+        :param node_pools: The Node Pools to create.
+        :type node_pools: one or a list of dicts containing keys "type" and "count".  See
+                          :any:`node_pool` for a convenient way to create correctly-
+                          formatted dicts.
+        :param kube_version: The version of Kubernetes to use
+        :type kube_version: KubeVersion or str
+        :param kwargs: Any other arguments to pass along to the API.  See the API
+                       docs for possible values.
+
+        :returns: The new LKE Cluster
+        :rtype: LKECluster
+        """
+        pools = []
+        if not isinstance(node_pools, list):
+            node_pools = [node_pools]
+
+        for c in node_pools:
+            if isinstance(c, dict):
+                new_pool = {
+                    "type": c["type"].id
+                    if "type" in c and issubclass(type(c["type"]), Base)
+                    else c.get("type"),
+                    "count": c.get("count"),
+                }
+
+                pools += [new_pool]
+
+        params = {
+            "label": label,
+            "region": region.id if issubclass(type(region), Base) else region,
+            "node_pools": pools,
+            "k8s_version": kube_version.id
+            if issubclass(type(kube_version), Base)
+            else kube_version,
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/lke/clusters", data=params)
+
+        if "id" not in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating LKE cluster!", json=result
+            )
+
+        return LKECluster(self.client, result["id"], result)
+
+    def node_pool(self, node_type, node_count):
+        """
+        Returns a dict that is suitable for passing into the `node_pools` array
+        of :any:`cluster_create`.  This is a convenience method, and need not be
+        used to create Node Pools.  For proper usage, see the docs for :any:`cluster_create`.
+
+        :param node_type: The type of node to create in this node pool.
+        :type node_type: Type or str
+        :param node_count: The number of nodes to create in this node pool.
+        :type node_count: int
+
+        :returns: A dict describing the desired node pool.
+        :rtype: dict
+        """
+        return {
+            "type": node_type,
+            "count": node_count,
+        }
diff --git a/linode_api4/groups/longview.py b/linode_api4/groups/longview.py
new file mode 100644
index 000000000..25b980ec7
--- /dev/null
+++ b/linode_api4/groups/longview.py
@@ -0,0 +1,103 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import (
+    LongviewClient,
+    LongviewPlan,
+    LongviewSubscription,
+)
+
+
+class LongviewGroup(Group):
+    """
+    Collections related to Linode Longview.
+    """
+
+    def clients(self, *filters):
+        """
+        Requests and returns a paginated list of LongviewClients on your
+        account.
+
+        API Documentation: https://www.linode.com/docs/api/longview/#longview-clients-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Longview Clients matching the given filters.
+        :rtype: PaginatedList of LongviewClient
+        """
+        return self.client._get_and_filter(LongviewClient, *filters)
+
+    def client_create(self, label=None):
+        """
+        Creates a new LongviewClient, optionally with a given label.
+
+        API Documentation: https://www.linode.com/docs/api/longview/#longview-client-create
+
+        :param label: The label for the new client.  If None, a default label based
+            on the new client's ID will be used.
+
+        :returns: A new LongviewClient
+
+        :raises ApiError: If a non-200 status code is returned
+        :raises UnexpectedResponseError: If the returned data from the api does
+            not look as expected.
+        """
+        result = self.client.post("/longview/clients", data={"label": label})
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Longview Client!",
+                json=result,
+            )
+
+        c = LongviewClient(self.client, result["id"], result)
+        return c
+
+    def subscriptions(self, *filters):
+        """
+        Requests and returns a paginated list of LongviewSubscriptions available
+
+        API Documentation: https://www.linode.com/docs/api/longview/#longview-subscriptions-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Longview Subscriptions matching the given filters.
+        :rtype: PaginatedList of LongviewSubscription
+        """
+        return self.client._get_and_filter(LongviewSubscription, *filters)
+
+    def longview_plan_update(self, longview_subscription):
+        """
+        Update your Longview plan to that of the given subcription ID.
+
+        :param longview_subscription: The subscription ID for a particular Longview plan.
+                                      A value of null corresponds to Longview Free.
+        :type longview_subscription: str
+
+        :returns: The updated Longview Plan
+        :rtype: LongviewPlan
+        """
+
+        if longview_subscription not in [
+            "",
+            "longview-3",
+            "longview-10",
+            "longview-40",
+            "longview-100",
+        ]:
+            raise ValueError(
+                "Invalid longview plan subscription: {}".format(
+                    longview_subscription
+                )
+            )
+
+        params = {"longview_subscription": longview_subscription}
+
+        result = self.client.post(
+            LongviewPlan.api_endpoint, model=self, data=params
+        )
+
+        plan = LongviewPlan(self.client, result["id"], result)
+
+        plan.invalidate()
+
+        return plan
diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py
new file mode 100644
index 000000000..87c84d922
--- /dev/null
+++ b/linode_api4/groups/networking.py
@@ -0,0 +1,330 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import (
+    VLAN,
+    Base,
+    Firewall,
+    Instance,
+    IPAddress,
+    IPv6Pool,
+    IPv6Range,
+    Region,
+)
+
+
+class NetworkingGroup(Group):
+    """
+    Collections related to Linode Networking.
+    """
+
+    def firewalls(self, *filters):
+        """
+        Retrieves the Firewalls your user has access to.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#firewalls-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Firewalls the acting user can access.
+        :rtype: PaginatedList of Firewall
+        """
+        return self.client._get_and_filter(Firewall, *filters)
+
+    def firewall_create(self, label, rules, **kwargs):
+        """
+        Creates a new Firewall, either in the given Region or
+        attached to the given Instance.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#firewall-create
+
+        :param label: The label for the new Firewall.
+        :type label: str
+        :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_.
+        :type rules: dict
+
+        :returns: The new Firewall.
+        :rtype: Firewall
+
+        Example usage::
+
+           rules = {
+                'outbound': [
+                    {
+                        'action': 'ACCEPT',
+                        'addresses': {
+                            'ipv4': [
+                                '0.0.0.0/0'
+                            ],
+                            'ipv6': [
+                                "ff00::/8"
+                            ]
+                        },
+                        'description': 'Allow HTTP out.',
+                        'label': 'allow-http-out',
+                        'ports': '80',
+                        'protocol': 'TCP'
+                    }
+                ],
+                'outbound_policy': 'DROP',
+                'inbound': [],
+                'inbound_policy': 'DROP'
+            }
+
+            firewall = client.networking.firewall_create('my-firewall', rules)
+
+        .. _Firewalls Documentation: https://www.linode.com/docs/api/networking/#firewall-create__request-body-schema
+        """
+
+        params = {
+            "label": label,
+            "rules": rules,
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/networking/firewalls", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Firewall!", json=result
+            )
+
+        f = Firewall(self.client, result["id"], result)
+        return f
+
+    def ips(self, *filters):
+        """
+        Returns a list of IP addresses on this account, excluding private addresses.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of IP addresses on this account.
+        :rtype: PaginatedList of IPAddress
+        """
+        return self.client._get_and_filter(IPAddress, *filters)
+
+    def ipv6_ranges(self, *filters):
+        """
+        Returns a list of IPv6 ranges on this account.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#ipv6-ranges-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of IPv6 ranges on this account.
+        :rtype: PaginatedList of IPv6Range
+        """
+        return self.client._get_and_filter(IPv6Range, *filters)
+
+    def ipv6_pools(self, *filters):
+        """
+        Returns a list of IPv6 pools on this account.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#ipv6-pools-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of IPv6 pools on this account.
+        :rtype: PaginatedList of IPv6Pool
+        """
+
+        return self.client._get_and_filter(IPv6Pool, *filters)
+
+    def vlans(self, *filters):
+        """
+        .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
+
+        Returns a list of VLANs on your account.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#vlans-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A List of VLANs on your account.
+        :rtype: PaginatedList of VLAN
+        """
+        return self.client._get_and_filter(VLAN, *filters)
+
+    def ips_assign(self, region, *assignments):
+        """
+        Redistributes :any:`IP Addressees<IPAddress>` within a single region.
+        This function takes a :any:`Region` and a list of assignments to make,
+        then requests that the assignments take place.  If any :any:`Instance`
+        ends up without a public IP, or with more than one private IP, all of
+        the assignments will fail.
+
+        .. note::
+           This function *does not* update the local Linode Instance objects
+           when called.  In order to see the new addresses on the local
+           instance objects, be sure to invalidate them with ``invalidate()``
+           after this completes.
+
+        Example usage::
+
+           linode1 = Instance(client, 123)
+           linode2 = Instance(client, 456)
+
+           # swap IPs between linodes 1 and 2
+           client.networking.assign_ips(linode1.region,
+                                        linode1.ips.ipv4.public[0].to(linode2),
+                                        linode2.ips.ipv4.public[0].to(linode1))
+
+           # make sure linode1 and linode2 have updated ipv4 and ips values
+           linode1.invalidate()
+           linode2.invalidate()
+
+        API Documentation: https://www.linode.com/docs/api/networking/#linodes-assign-ipv4s
+
+        :param region: The Region in which the assignments should take place.
+                       All Instances and IPAddresses involved in the assignment
+                       must be within this region.
+        :type region: str or Region
+        :param assignments: Any number of assignments to make.  See
+                            :any:`IPAddress.to` for details on how to construct
+                            assignments.
+        :type assignments: dct
+
+        DEPRECATED: Use ip_addresses_assign() instead
+        """
+        for a in assignments:
+            if not "address" in a or not "linode_id" in a:
+                raise ValueError("Invalid assignment: {}".format(a))
+        if isinstance(region, Region):
+            region = region.id
+
+        self.client.post(
+            "/networking/ipv4/assign",
+            data={
+                "region": region,
+                "assignments": assignments,
+            },
+        )
+
+    def ip_allocate(self, linode, public=True):
+        """
+        Allocates an IP to a Instance you own.  Additional IPs must be requested
+        by opening a support ticket first.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#ip-address-allocate
+
+        :param linode: The Instance to allocate the new IP for.
+        :type linode: Instance or int
+        :param public: If True, allocate a public IP address.  Defaults to True.
+        :type public: bool
+
+        :returns: The new IPAddress.
+        :rtype: IPAddress
+        """
+        result = self.client.post(
+            "/networking/ips/",
+            data={
+                "linode_id": linode.id if isinstance(linode, Base) else linode,
+                "type": "ipv4",
+                "public": public,
+            },
+        )
+
+        if not "address" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when adding IPv4 address!", json=result
+            )
+
+        ip = IPAddress(self.client, result["address"], result)
+        return ip
+
+    def ips_share(self, linode, *ips):
+        """
+        Shares the given list of :any:`IPAddresses<IPAddress>` with the provided
+        :any:`Instance`.  This will enable the provided Instance to bring up the
+        shared IP Addresses even though it does not own them.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#ipv4-sharing-configure
+
+        :param linode: The Instance to share the IPAddresses with.  This Instance
+                       will be able to bring up the given addresses.
+        :type: linode: int or Instance
+        :param ips: Any number of IPAddresses to share to the Instance.
+        :type ips: str or IPAddress
+
+        DEPRECATED: Use ip_addresses_share() instead
+        """
+        if not isinstance(linode, Instance):
+            # make this an object
+            linode = Instance(self.client, linode)
+
+        params = []
+        for ip in ips:
+            if isinstance(ip, str):
+                params.append(ip)
+            elif isinstance(ip, IPAddress):
+                params.append(ip.address)
+            else:
+                params.append(str(ip))  # and hope that works
+
+        params = {"ips": params}
+
+        self.client.post(
+            "{}/networking/ipv4/share".format(Instance.api_endpoint),
+            model=linode,
+            data=params,
+        )
+
+        linode.invalidate()  # clear the Instance's shared IPs
+
+    def ip_addresses_share(self, ips, linode):
+        """
+        Configure shared IPs. P sharing allows IP address reassignment
+        (also referred to as IP failover) from one Linode to another if the
+        primary Linode becomes unresponsive. This means that requests to the primary Linode’s
+        IP address can be automatically rerouted to secondary Linodes at the configured shared IP addresses.
+
+        :param linode: The id of the Instance or the Instance to share the IPAddresses with.
+                          This Instance will be able to bring up the given addresses.
+        :type: linode: int or Instance
+        :param ips: Any number of IPAddresses to share to the Instance.
+        :type ips: str or IPAddress
+        """
+
+        params = {
+            "ips": ips
+            if not isinstance(ips[0], IPAddress)
+            else [ip.address for ip in ips],
+            "linode_id": linode
+            if not isinstance(linode, Instance)
+            else linode.id,
+        }
+
+        self.client.post("/networking/ips/share", model=self, data=params)
+
+    def ip_addresses_assign(self, assignments, region):
+        """
+        Assign multiple IPv4 addresses and/or IPv6 ranges to multiple Linodes in one Region.
+        This allows swapping, shuffling, or otherwise reorganizing IPs to your Linodes.
+
+        The following restrictions apply:
+            - All Linodes involved must have at least one public IPv4 address after assignment.
+            - Linodes may have no more than one assigned private IPv4 address.
+            - Linodes may have no more than one assigned IPv6 range.
+
+
+        :param region: The Region in which the assignments should take place.
+                       All Instances and IPAddresses involved in the assignment
+                       must be within this region.
+        :type region: str or Region
+        :param assignments: Any number of assignments to make.  See
+                            :any:`IPAddress.to` for details on how to construct
+                            assignments.
+        :type assignments: dct
+        """
+
+        for a in assignments["assignments"]:
+            if not "address" in a or not "linode_id" in a:
+                raise ValueError("Invalid assignment: {}".format(a))
+
+        if isinstance(region, Region):
+            region = region.id
+
+        params = {"assignments": assignments, "region": region}
+
+        self.client.post("/networking/ips/assign", model=self, data=params)
diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py
new file mode 100644
index 000000000..72bd12ea1
--- /dev/null
+++ b/linode_api4/groups/nodebalancer.py
@@ -0,0 +1,50 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import Base, NodeBalancer
+
+
+class NodeBalancerGroup(Group):
+    def __call__(self, *filters):
+        """
+        Retrieves all of the NodeBalancers the acting user has access to.
+
+        This is intended to be called off of the :any:`LinodeClient`
+        class, like this::
+
+           nodebalancers = client.nodebalancers()
+
+        API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancers-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of NodeBalancers the acting user can access.
+        :rtype: PaginatedList of NodeBalancers
+        """
+        return self.client._get_and_filter(NodeBalancer, *filters)
+
+    def create(self, region, **kwargs):
+        """
+        Creates a new NodeBalancer in the given Region.
+
+        API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-create
+
+        :param region: The Region in which to create the NodeBalancer.
+        :type region: Region or str
+
+        :returns: The new NodeBalancer
+        :rtype: NodeBalancer
+        """
+        params = {
+            "region": region.id if isinstance(region, Base) else region,
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/nodebalancers", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Nodebalaner!", json=result
+            )
+
+        n = NodeBalancer(self.client, result["id"], result)
+        return n
diff --git a/linode_api4/groups/obj.py b/linode_api4/groups/obj.py
new file mode 100644
index 000000000..78245d246
--- /dev/null
+++ b/linode_api4/groups/obj.py
@@ -0,0 +1,156 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import Base, ObjectStorageCluster, ObjectStorageKeys
+
+
+class ObjectStorageGroup(Group):
+    """
+    This group encapsulates all endpoints under /object-storage, including viewing
+    available clusters and managing keys.
+    """
+
+    def clusters(self, *filters):
+        """
+        Returns a list of available Object Storage Clusters.  You may filter
+        this query to return only Clusters that are available in a specific region::
+
+           us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east")
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Object Storage Clusters that matched the query.
+        :rtype: PaginatedList of ObjectStorageCluster
+        """
+        return self.client._get_and_filter(ObjectStorageCluster, *filters)
+
+    def keys(self, *filters):
+        """
+        Returns a list of Object Storage Keys active on this account.  These keys
+        allow third-party applications to interact directly with Linode Object Storage.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Object Storage Keys that matched the query.
+        :rtype: PaginatedList of ObjectStorageKeys
+        """
+        return self.client._get_and_filter(ObjectStorageKeys, *filters)
+
+    def keys_create(self, label, bucket_access=None):
+        """
+        Creates a new Object Storage keypair that may be used to interact directly
+        with Linode Object Storage in third-party applications.  This response is
+        the only time that "secret_key" will be populated - be sure to capture its
+        value or it will be lost forever.
+
+        If given, `bucket_access` will cause the new keys to be restricted to only
+        the specified level of access for the specified buckets.  For example, to
+        create a keypair that can only access the "example" bucket in all clusters
+        (and assuming you own that bucket in every cluster), you might do this::
+
+           client = LinodeClient(TOKEN)
+
+           # look up clusters
+           all_clusters = client.object_storage.clusters()
+
+           new_keys = client.object_storage.keys_create(
+               "restricted-keys",
+               bucket_access=[
+                   client.object_storage.bucket_access(cluster, "example", "read_write")
+                   for cluster in all_clusters
+               ],
+           )
+
+        To create a keypair that can only read from the bucket "example2" in the
+        "us-east-1" cluster (an assuming you own that bucket in that cluster),
+        you might do this::
+
+           client = LinodeClient(TOKEN)
+           new_keys_2 = client.object_storage.keys_create(
+               "restricted-keys-2",
+               bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"),
+           )
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create
+
+        :param label: The label for this keypair, for identification only.
+        :type label: str
+        :param bucket_access: One or a list of dicts with keys "cluster,"
+                              "permissions", and "bucket_name".  If given, the
+                              resulting Object Storage keys will only have the
+                              requested level of access to the requested buckets,
+                              if they exist and are owned by you.  See the provided
+                              :any:`bucket_access` function for a convenient way
+                              to create these dicts.
+        :type bucket_access: dict or list of dict
+
+        :returns: The new keypair, with the secret key populated.
+        :rtype: ObjectStorageKeys
+        """
+        params = {"label": label}
+
+        if bucket_access is not None:
+            if not isinstance(bucket_access, list):
+                bucket_access = [bucket_access]
+
+            ba = [
+                {
+                    "permissions": c.get("permissions"),
+                    "bucket_name": c.get("bucket_name"),
+                    "cluster": c.id
+                    if "cluster" in c and issubclass(type(c["cluster"]), Base)
+                    else c.get("cluster"),
+                }
+                for c in bucket_access
+            ]
+
+            params["bucket_access"] = ba
+
+        result = self.client.post("/object-storage/keys", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Object Storage Keys!",
+                json=result,
+            )
+
+        ret = ObjectStorageKeys(self.client, result["id"], result)
+        return ret
+
+    def bucket_access(self, cluster, bucket_name, permissions):
+        """
+        Returns a dict formatted to be included in the `bucket_access` argument
+        of :any:`keys_create`.  See the docs for that method for an example of
+        usage.
+
+        :param cluster: The Object Storage cluster to grant access in.
+        :type cluster: :any:`ObjectStorageCluster` or str
+        :param bucket_name: The name of the bucket to grant access to.
+        :type bucket_name: str
+        :param permissions: The permissions to grant.  Should be one of "read_only"
+                            or "read_write".
+        :type permissions: str
+
+        :returns: A dict formatted correctly for specifying  bucket access for
+                  new keys.
+        :rtype: dict
+        """
+        return {
+            "cluster": cluster,
+            "bucket_name": bucket_name,
+            "permissions": permissions,
+        }
+
+    def cancel(self):
+        """
+        Cancels Object Storage service.  This may be a destructive operation.  Once
+        cancelled, you will no longer receive the transfer for or be billed for
+        Object Storage, and all keys will be invalidated.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel
+        """
+        self.client.post("/object-storage/cancel", data={})
+        return True
diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py
new file mode 100644
index 000000000..514e5e3a4
--- /dev/null
+++ b/linode_api4/groups/object_storage.py
@@ -0,0 +1,333 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import (
+    Base,
+    MappedObject,
+    ObjectStorageACL,
+    ObjectStorageBucket,
+    ObjectStorageCluster,
+    ObjectStorageKeys,
+)
+from linode_api4.util import drop_null_keys
+
+
+class ObjectStorageGroup(Group):
+    """
+    This group encapsulates all endpoints under /object-storage, including viewing
+    available clusters, buckets, and managing keys and TLS/SSL certs, etc.
+    """
+
+    def clusters(self, *filters):
+        """
+        Returns a list of available Object Storage Clusters.  You may filter
+        this query to return only Clusters that are available in a specific region::
+
+           us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east")
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Object Storage Clusters that matched the query.
+        :rtype: PaginatedList of ObjectStorageCluster
+        """
+        return self.client._get_and_filter(ObjectStorageCluster, *filters)
+
+    def keys(self, *filters):
+        """
+        Returns a list of Object Storage Keys active on this account.  These keys
+        allow third-party applications to interact directly with Linode Object Storage.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Object Storage Keys that matched the query.
+        :rtype: PaginatedList of ObjectStorageKeys
+        """
+        return self.client._get_and_filter(ObjectStorageKeys, *filters)
+
+    def keys_create(self, label, bucket_access=None):
+        """
+        Creates a new Object Storage keypair that may be used to interact directly
+        with Linode Object Storage in third-party applications.  This response is
+        the only time that "secret_key" will be populated - be sure to capture its
+        value or it will be lost forever.
+
+        If given, `bucket_access` will cause the new keys to be restricted to only
+        the specified level of access for the specified buckets.  For example, to
+        create a keypair that can only access the "example" bucket in all clusters
+        (and assuming you own that bucket in every cluster), you might do this::
+
+           client = LinodeClient(TOKEN)
+
+           # look up clusters
+           all_clusters = client.object_storage.clusters()
+
+           new_keys = client.object_storage.keys_create(
+               "restricted-keys",
+               bucket_access=[
+                   client.object_storage.bucket_access(cluster, "example", "read_write")
+                   for cluster in all_clusters
+               ],
+           )
+
+        To create a keypair that can only read from the bucket "example2" in the
+        "us-east-1" cluster (an assuming you own that bucket in that cluster),
+        you might do this::
+
+           client = LinodeClient(TOKEN)
+           new_keys_2 = client.object_storage.keys_create(
+               "restricted-keys-2",
+               bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"),
+           )
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create
+
+        :param label: The label for this keypair, for identification only.
+        :type label: str
+        :param bucket_access: One or a list of dicts with keys "cluster,"
+                              "permissions", and "bucket_name".  If given, the
+                              resulting Object Storage keys will only have the
+                              requested level of access to the requested buckets,
+                              if they exist and are owned by you.  See the provided
+                              :any:`bucket_access` function for a convenient way
+                              to create these dicts.
+        :type bucket_access: dict or list of dict
+
+        :returns: The new keypair, with the secret key populated.
+        :rtype: ObjectStorageKeys
+        """
+        params = {"label": label}
+
+        if bucket_access is not None:
+            if not isinstance(bucket_access, list):
+                bucket_access = [bucket_access]
+
+            ba = [
+                {
+                    "permissions": c.get("permissions"),
+                    "bucket_name": c.get("bucket_name"),
+                    "cluster": c.id
+                    if "cluster" in c and issubclass(type(c["cluster"]), Base)
+                    else c.get("cluster"),
+                }
+                for c in bucket_access
+            ]
+
+            params["bucket_access"] = ba
+
+        result = self.client.post("/object-storage/keys", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Object Storage Keys!",
+                json=result,
+            )
+
+        ret = ObjectStorageKeys(self.client, result["id"], result)
+        return ret
+
+    def bucket_access(self, cluster, bucket_name, permissions):
+        return ObjectStorageBucket.access(
+            self, cluster, bucket_name, permissions
+        )
+
+    def cancel(self):
+        """
+        Cancels Object Storage service.  This may be a destructive operation.  Once
+        cancelled, you will no longer receive the transfer for or be billed for
+        Object Storage, and all keys will be invalidated.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel
+        """
+        self.client.post("/object-storage/cancel", data={})
+        return True
+
+    def transfer(self):
+        """
+        The amount of outbound data transfer used by your account’s Object Storage buckets,
+        in bytes, for the current month’s billing cycle. Object Storage adds 1 terabyte
+        of outbound data transfer to your data transfer pool.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-transfer-view
+
+        :returns: The amount of outbound data transfer used by your account’s Object
+                  Storage buckets, in bytes, for the current month’s billing cycle.
+        :rtype: MappedObject
+        """
+        result = self.client.get("/object-storage/transfer")
+
+        if not "used" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when getting Transfer Pool!",
+                json=result,
+            )
+
+        return MappedObject(**result)
+
+    def buckets(self, *filters):
+        """
+        Returns a paginated list of all Object Storage Buckets that you own.
+        This endpoint is available for convenience.
+        It is recommended that instead you use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-list
+
+        :returns: A list of Object Storage Buckets that matched the query.
+        :rtype: PaginatedList of ObjectStorageBucket
+        """
+        return self.client._get_and_filter(ObjectStorageBucket, *filters)
+
+    def bucket_create(
+        self,
+        cluster,
+        label,
+        acl: ObjectStorageACL = ObjectStorageACL.PRIVATE,
+        cors_enabled=False,
+    ):
+        """
+        Creates an Object Storage Bucket in the specified cluster. Accounts with
+        negative balances cannot access this command. If the bucket already exists
+        and is owned by you, this endpoint returns a 200 response with that bucket
+        as if it had just been created.
+
+        This endpoint is available for convenience.
+        It is recommended that instead you use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-create
+
+        :param acl: The Access Control Level of the bucket using a canned ACL string.
+                    For more fine-grained control of ACLs, use the S3 API directly.
+        :type acl: str
+                   Enum: private,public-read,authenticated-read,public-read-write
+
+        :param cluster: The ID of the Object Storage Cluster where this bucket
+                        should be created.
+        :type cluster: str
+
+        :param cors_enabled: If true, the bucket will be created with CORS enabled for
+                             all origins. For more fine-grained controls of CORS, use
+                             the S3 API directly.
+        :type cors_enabled: bool
+
+        :param label: The name for this bucket. Must be unique in the cluster you are
+                      creating the bucket in, or an error will be returned. Labels will
+                      be reserved only for the cluster that active buckets are created
+                      and stored in. If you want to reserve this bucket’s label in
+                      another cluster, you must create a new bucket with the same label
+                      in the new cluster.
+        :type label: str
+
+        :returns: A Object Storage Buckets that created by user.
+        :rtype: ObjectStorageBucket
+        """
+        cluster_id = (
+            cluster.id if isinstance(cluster, ObjectStorageCluster) else cluster
+        )
+
+        params = {
+            "cluster": cluster_id,
+            "label": label,
+            "acl": acl,
+            "cors_enabled": cors_enabled,
+        }
+
+        result = self.client.post("/object-storage/buckets", data=params)
+
+        if not "label" in result or not "cluster" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Object Storage Bucket!",
+                json=result,
+            )
+
+        return ObjectStorageBucket(
+            self.client, result["label"], result["cluster"], result
+        )
+
+    def object_acl_config(self, cluster_id, bucket, name=None):
+        return ObjectStorageBucket(
+            self.client, bucket, cluster_id
+        ).object_acl_config(name)
+
+    def object_acl_config_update(
+        self, cluster_id, bucket, acl: ObjectStorageACL, name
+    ):
+        return ObjectStorageBucket(
+            self.client, bucket, cluster_id
+        ).object_acl_config_update(acl, name)
+
+    def object_url_create(
+        self,
+        cluster_id,
+        bucket,
+        method,
+        name,
+        content_type=None,
+        expires_in=3600,
+    ):
+        """
+        Creates a pre-signed URL to access a single Object in a bucket.
+        This can be used to share objects, and also to create/delete objects by using
+        the appropriate HTTP method in your request body’s method parameter.
+
+        This endpoint is available for convenience.
+        It is recommended that instead you use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-url-create
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :param content_type: The expected Content-type header of the request this
+                             signed URL will be valid for. If provided, the
+                             Content-type header must be sent with the request when
+                             this URL is used, and must be the same as it was when
+                             the signed URL was created.
+                             Required for all methods except “GET” or “DELETE”.
+        :type content_type: str
+
+        :param expires_in: How long this signed URL will be valid for, in seconds.
+                           If omitted, the URL will be valid for 3600 seconds (1 hour). Defaults to 3600.
+        :type expires_in: int 360..86400
+
+        :param method: The HTTP method allowed to be used with the pre-signed URL.
+        :type method: str
+
+        :param name: The name of the object that will be accessed with the pre-signed
+                     URL. This object need not exist, and no error will be returned
+                     if it doesn’t. This behavior is useful for generating pre-signed
+                     URLs to upload new objects to by setting the method to “PUT”.
+        :type name: str
+
+        :returns: The signed URL to perform the request at.
+        :rtype: MappedObject
+        """
+        if method not in ("GET", "DELETE") and content_type is None:
+            raise ValueError(
+                "Content-type header is missing for the current method! It's required for all methods except GET or DELETE."
+            )
+        params = {
+            "method": method,
+            "name": name,
+            "expires_in": expires_in,
+            "content_type": content_type,
+        }
+
+        result = self.client.post(
+            "/object-storage/buckets/{}/{}/object-url".format(
+                cluster_id, bucket
+            ),
+            data=drop_null_keys(params),
+        )
+
+        if not "url" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating the access url of an object!",
+                json=result,
+            )
+
+        return MappedObject(**result)
diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py
new file mode 100644
index 000000000..38c2f9695
--- /dev/null
+++ b/linode_api4/groups/profile.py
@@ -0,0 +1,339 @@
+import os
+from datetime import datetime
+
+from linode_api4 import UnexpectedResponseError
+from linode_api4.common import SSH_KEY_TYPES
+from linode_api4.groups import Group
+from linode_api4.objects import (
+    AuthorizedApp,
+    MappedObject,
+    PersonalAccessToken,
+    Profile,
+    ProfileLogin,
+    SSHKey,
+    TrustedDevice,
+)
+
+
+class ProfileGroup(Group):
+    """
+    Collections related to your user.
+    """
+
+    def __call__(self):
+        """
+        Retrieve the acting user's Profile, containing information about the
+        current user such as their email address, username, and uid.  This is
+        intended to be called off of a :any:`LinodeClient` object, like this::
+
+           profile = client.profile()
+
+        API Documentation: https://www.linode.com/docs/api/profile/#profile-view
+
+        :returns: The acting user's profile.
+        :rtype: Profile
+        """
+        result = self.client.get("/profile")
+
+        if not "username" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when getting profile!", json=result
+            )
+
+        p = Profile(self.client, result["username"], result)
+        return p
+
+    def trusted_devices(self):
+        """
+        Returns the Trusted Devices on your profile.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#trusted-devices-list
+
+        :returns: A list of Trusted Devices for this profile.
+        :rtype: PaginatedList of TrustedDevice
+        """
+        return self.client._get_and_filter(TrustedDevice)
+
+    def user_preferences(self):
+        """
+        View a list of user preferences tied to the OAuth client that generated the token making the request.
+        """
+
+        result = self.client.get(
+            "{}/preferences".format(Profile.api_endpoint), model=self
+        )
+
+        return MappedObject(**result)
+
+    def security_questions(self):
+        """
+        Returns a collection of security questions and their responses, if any, for your User Profile.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#security-questions-list
+        """
+
+        result = self.client.get(
+            "{}/security-questions".format(Profile.api_endpoint), model=self
+        )
+
+        return MappedObject(**result)
+
+    def security_questions_answer(self, questions):
+        """
+        Adds security question responses for your User. Requires exactly three unique questions.
+        Previous responses are overwritten if answered or reset to null if unanswered.
+
+        Example question:
+        {
+            "question_id": 11,
+            "response": "secret answer 3"
+        }
+        """
+
+        if len(questions) != 3:
+            raise ValueError("Exactly 3 security questions are required.")
+
+        params = {"security_questions": questions}
+
+        result = self.client.post(
+            "{}/security-questions".format(Profile.api_endpoint),
+            model=self,
+            data=params,
+        )
+
+        return MappedObject(**result)
+
+    def user_preferences_update(self, **preferences):
+        """
+        Updates a user’s preferences.
+        """
+
+        result = self.client.put(
+            "{}/preferences".format(Profile.api_endpoint),
+            model=self,
+            data=preferences,
+        )
+
+        return MappedObject(**result)
+
+    def phone_number_delete(self):
+        """
+        Delete the verified phone number for the User making this request.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#phone-number-delete
+
+        :returns: Returns True if the operation was successful.
+        :rtype: bool
+        """
+
+        resp = self.client.delete(
+            "{}/phone-number".format(Profile.api_endpoint), model=self
+        )
+
+        if "error" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when deleting phone number!",
+                json=resp,
+            )
+
+        return True
+
+    def phone_number_verify(self, otp_code):
+        """
+        Verify a phone number by confirming the one-time code received via SMS message
+        after accessing the Phone Verification Code Send (POST /profile/phone-number) command.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#phone-number-verify
+
+        :param otp_code: The one-time code received via SMS message after accessing the Phone Verification Code Send
+        :type otp_code: str
+
+        :returns: Returns True if the operation was successful.
+        :rtype: bool
+        """
+
+        if not otp_code:
+            raise ValueError("OTP Code required to verify phone number.")
+
+        params = {"otp_code": str(otp_code)}
+
+        resp = self.client.post(
+            "{}/phone-number/verify".format(Profile.api_endpoint),
+            model=self,
+            data=params,
+        )
+
+        if "error" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when verifying phone number!",
+                json=resp,
+            )
+
+        return True
+
+    def phone_number_verification_code_send(self, iso_code, phone_number):
+        """
+        Send a one-time verification code via SMS message to the submitted phone number.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#phone-number-verification-code-send
+
+        :param iso_code: The two-letter ISO 3166 country code associated with the phone number.
+        :type iso_code: str
+
+        :param phone_number: A valid phone number.
+        :type phone_number: str
+
+        :returns: Returns True if the operation was successful.
+        :rtype: bool
+        """
+
+        if not iso_code:
+            raise ValueError("ISO Code required to send verification code.")
+
+        if not phone_number:
+            raise ValueError("Phone Number required to send verification code.")
+
+        params = {"iso_code": iso_code, "phone_number": phone_number}
+
+        resp = self.client.post(
+            "{}/phone-number".format(Profile.api_endpoint),
+            model=self,
+            data=params,
+        )
+
+        if "error" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when sending verification code!",
+                json=resp,
+            )
+
+        return True
+
+    def logins(self):
+        """
+        Returns the logins on your profile.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#logins-list
+
+        :returns: A list of logins for this profile.
+        :rtype: PaginatedList of ProfileLogin
+        """
+        return self.client._get_and_filter(ProfileLogin)
+
+    def tokens(self, *filters):
+        """
+        Returns the Person Access Tokens active for this user.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#personal-access-tokens-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of tokens that matches the query.
+        :rtype: PaginatedList of PersonalAccessToken
+        """
+        return self.client._get_and_filter(PersonalAccessToken, *filters)
+
+    def token_create(self, label=None, expiry=None, scopes=None, **kwargs):
+        """
+        Creates and returns a new Personal Access Token.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-create
+
+        :param label: The label of the new Personal Access Token.
+        :type label: str
+        :param expiry: When the new Personal Accses Token will expire.
+        :type expiry: datetime or str
+        :param scopes: A space-separated list of OAuth scopes for this token.
+        :type scopes: str
+
+        :returns: The new Personal Access Token.
+        :rtype: PersonalAccessToken
+        """
+        if label:
+            kwargs["label"] = label
+        if expiry:
+            if isinstance(expiry, datetime):
+                expiry = datetime.strftime(expiry, "%Y-%m-%dT%H:%M:%S")
+            kwargs["expiry"] = expiry
+        if scopes:
+            kwargs["scopes"] = scopes
+
+        result = self.client.post("/profile/tokens", data=kwargs)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Personal Access Token!",
+                json=result,
+            )
+
+        token = PersonalAccessToken(self.client, result["id"], result)
+        return token
+
+    def apps(self, *filters):
+        """
+        Returns the Authorized Applications for this user
+
+        API Documentation: https://www.linode.com/docs/api/profile/#authorized-apps-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Authorized Applications for this user
+        :rtype: PaginatedList of AuthorizedApp
+        """
+        return self.client._get_and_filter(AuthorizedApp, *filters)
+
+    def ssh_keys(self, *filters):
+        """
+        Returns the SSH Public Keys uploaded to your profile.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#ssh-keys-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of SSH Keys for this profile.
+        :rtype: PaginatedList of SSHKey
+        """
+        return self.client._get_and_filter(SSHKey, *filters)
+
+    def ssh_key_upload(self, key, label):
+        """
+        Uploads a new SSH Public Key to your profile  This key can be used in
+        later Linode deployments.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-add
+
+        :param key: The ssh key, or a path to the ssh key.  If a path is provided,
+                    the file at the path must exist and be readable or an exception
+                    will be thrown.
+        :type key: str
+        :param label: The name to give this key.  This is purely aesthetic.
+        :type label: str
+
+        :returns: The newly uploaded SSH Key
+        :rtype: SSHKey
+        :raises ValueError: If the key provided does not appear to be valid, and
+                            does not appear to be a path to a valid key.
+        """
+        if not key.startswith(SSH_KEY_TYPES):
+            # this might be a file path - look for it
+            path = os.path.expanduser(key)
+            if os.path.isfile(path):
+                with open(path) as f:
+                    key = f.read().strip()
+            if not key.startswith(SSH_KEY_TYPES):
+                raise ValueError("Invalid SSH Public Key")
+
+        params = {
+            "ssh_key": key,
+            "label": label,
+        }
+
+        result = self.client.post("/profile/sshkeys", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when uploading SSH Key!", json=result
+            )
+
+        ssh_key = SSHKey(self.client, result["id"], result)
+        return ssh_key
diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py
new file mode 100644
index 000000000..be6440f73
--- /dev/null
+++ b/linode_api4/groups/region.py
@@ -0,0 +1,23 @@
+from linode_api4.groups import Group
+from linode_api4.objects import Region
+
+
+class RegionGroup(Group):
+    def __call__(self, *filters):
+        """
+        Returns the available Regions for Linode products.
+
+        This is intended to be called off of the :any:`LinodeClient`
+        class, like this::
+
+           region = client.regions()
+
+        API Documentation: https://www.linode.com/docs/api/regions/#regions-list
+
+        :param filters: Any number of filters to apply to the query.
+
+        :returns: A list of available Regions.
+        :rtype: PaginatedList of Region
+        """
+
+        return self.client._get_and_filter(Region, *filters)
diff --git a/linode_api4/groups/support.py b/linode_api4/groups/support.py
new file mode 100644
index 000000000..faa9547cb
--- /dev/null
+++ b/linode_api4/groups/support.py
@@ -0,0 +1,103 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import (
+    VLAN,
+    Database,
+    Domain,
+    Firewall,
+    Instance,
+    LKECluster,
+    LongviewClient,
+    NodeBalancer,
+    SupportTicket,
+    Volume,
+)
+
+
+class SupportGroup(Group):
+    """
+    Collections related to support tickets.
+    """
+
+    def tickets(self, *filters):
+        """
+        Returns a list of support tickets on this account.
+
+        API Documentation: https://www.linode.com/docs/api/support/#support-tickets-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of support tickets on this account.
+        :rtype: PaginatedList of SupportTicket
+        """
+
+        return self.client._get_and_filter(SupportTicket, *filters)
+
+    def ticket_open(
+        self,
+        summary,
+        description,
+        managed_issue=False,
+        regarding=None,
+        **kwargs,
+    ):
+        """
+        Opens a support ticket on this account.
+
+        API Documentation: https://www.linode.com/docs/api/support/#support-ticket-open
+
+        :param summary: The summary or title for this support ticket.
+        :type summary: str
+        :param description: The full details of the issue or question.
+        :type description: str
+        :param regarding: The resource being referred to in this ticket.
+        :type regarding:
+        :param managed_issue: Designates if this ticket relates to a managed service.
+        :type managed_issue: bool
+
+        :returns: The new support ticket.
+        :rtype: SupportTicket
+        """
+        params = {
+            "summary": summary,
+            "description": description,
+            "managed_issue": managed_issue,
+        }
+
+        type_to_id = {
+            Instance: "linode_id",
+            Domain: "domain_id",
+            NodeBalancer: "nodebalancer_id",
+            Volume: "volume_id",
+            Firewall: "firewall_id",
+            LKECluster: "lkecluster_id",
+            Database: "database_id",
+            LongviewClient: "longviewclient_id",
+        }
+
+        params.update(kwargs)
+
+        if regarding:
+            id_attr = type_to_id.get(type(regarding))
+
+            if id_attr is not None:
+                params[id_attr] = regarding.id
+            elif isinstance(regarding, VLAN):
+                params["vlan"] = regarding.label
+                params["region"] = regarding.region
+            else:
+                raise ValueError(
+                    "Cannot open ticket regarding type {}!".format(
+                        type(regarding)
+                    )
+                )
+
+        result = self.client.post("/support/tickets", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating ticket!", json=result
+            )
+
+        t = SupportTicket(self.client, result["id"], result)
+        return t
diff --git a/linode_api4/groups/tag.py b/linode_api4/groups/tag.py
new file mode 100644
index 000000000..6276897fa
--- /dev/null
+++ b/linode_api4/groups/tag.py
@@ -0,0 +1,114 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import Domain, Instance, NodeBalancer, Tag, Volume
+
+
+class TagGroup(Group):
+    def __call__(self, *filters):
+        """
+        Retrieves the Tags on your account.  This may only be attempted by
+        unrestricted users.
+
+        This is intended to be called off of the :any:`LinodeClient`
+        class, like this::
+
+           tags = client.tags()
+
+        API Documentation: https://www.linode.com/docs/api/domains/#domain-create
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Tags on the account.
+        :rtype: PaginatedList of Tag
+        """
+        return self.client._get_and_filter(Tag, *filters)
+
+    def create(
+        self,
+        label,
+        instances=None,
+        domains=None,
+        nodebalancers=None,
+        volumes=None,
+        entities=[],
+    ):
+        """
+        Creates a new Tag and optionally applies it to the given entities.
+
+        API Documentation: https://www.linode.com/docs/api/tags/#tags-list
+
+        :param label: The label for the new Tag
+        :type label: str
+        :param entities: A list of objects to apply this Tag to upon creation.
+                         May only be taggable types (Linode Instances, Domains,
+                         NodeBalancers, or Volumes).  These are applied *in addition
+                         to* any IDs specified with ``instances``, ``domains``,
+                         ``nodebalancers``, or ``volumes``, and is a convenience
+                         for sending multiple entity types without sorting them
+                         yourself.
+        :type entities: list of Instance, Domain, NodeBalancer, and/or Volume
+        :param instances: A list of Linode Instances to apply this Tag to upon
+                        creation
+        :type instances: list of Instance or list of int
+        :param domains: A list of Domains to apply this Tag to upon
+                        creation
+        :type domains: list of Domain or list of int
+        :param nodebalancers: A list of NodeBalancers to apply this Tag to upon
+                        creation
+        :type nodebalancers: list of NodeBalancer or list of int
+        :param volumes: A list of Volumes to apply this Tag to upon
+                        creation
+        :type volumes: list of Volumes or list of int
+
+        :returns: The new Tag
+        :rtype: Tag
+        """
+        linode_ids, nodebalancer_ids, domain_ids, volume_ids = [], [], [], []
+
+        # filter input into lists of ids
+        sorter = zip(
+            (linode_ids, nodebalancer_ids, domain_ids, volume_ids),
+            (instances, nodebalancers, domains, volumes),
+        )
+
+        for id_list, input_list in sorter:
+            # if we got something, we need to find its ID
+            if input_list is not None:
+                for cur in input_list:
+                    if isinstance(cur, int):
+                        id_list.append(cur)
+                    else:
+                        id_list.append(cur.id)
+
+        # filter entities into id lists too
+        type_map = {
+            Instance: linode_ids,
+            NodeBalancer: nodebalancer_ids,
+            Domain: domain_ids,
+            Volume: volume_ids,
+        }
+
+        for e in entities:
+            if type(e) in type_map:
+                type_map[type(e)].append(e.id)
+            else:
+                raise ValueError("Unsupported entity type {}".format(type(e)))
+
+        # finally, omit all id lists that are empty
+        params = {
+            "label": label,
+            "linodes": linode_ids or None,
+            "nodebalancers": nodebalancer_ids or None,
+            "domains": domain_ids or None,
+            "volumes": volume_ids or None,
+        }
+
+        result = self.client.post("/tags", data=params)
+
+        if not "label" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating Tag!", json=result
+            )
+
+        t = Tag(self.client, result["label"], result)
+        return t
diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py
new file mode 100644
index 000000000..54a9829a5
--- /dev/null
+++ b/linode_api4/groups/volume.py
@@ -0,0 +1,71 @@
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.groups import Group
+from linode_api4.objects import Base, Volume
+
+
+class VolumeGroup(Group):
+    def __call__(self, *filters):
+        """
+        Retrieves the Block Storage Volumes your user has access to.
+
+        This is intended to be called off of the :any:`LinodeClient`
+        class, like this::
+
+           volumes = client.volumes()
+
+        API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list
+
+        :param filters: Any number of filters to apply to this query.
+
+        :returns: A list of Volumes the acting user can access.
+        :rtype: PaginatedList of Volume
+        """
+        return self.client._get_and_filter(Volume, *filters)
+
+    def create(self, label, region=None, linode=None, size=20, **kwargs):
+        """
+        Creates a new Block Storage Volume, either in the given Region or
+        attached to the given Instance.
+
+        API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list
+
+        :param label: The label for the new Volume.
+        :type label: str
+        :param region: The Region to create this Volume in.  Not required if
+                       `linode` is provided.
+        :type region: Region or str
+        :param linode: The Instance to attach this Volume to.  If not given, the
+                       new Volume will not be attached to anything.
+        :type linode: Instance or int
+        :param size: The size, in GB, of the new Volume.  Defaults to 20.
+        :type size: int
+        :param tags: A list of tags to apply to the new volume.  If any of the
+                     tags included do not exist, they will be created as part of
+                     this operation.
+        :type tags: list[str]
+
+        :returns: The new Volume.
+        :rtype: Volume
+        """
+        if not (region or linode):
+            raise ValueError("region or linode required!")
+
+        params = {
+            "label": label,
+            "size": size,
+            "region": region.id if issubclass(type(region), Base) else region,
+            "linode_id": linode.id
+            if issubclass(type(linode), Base)
+            else linode,
+        }
+        params.update(kwargs)
+
+        result = self.client.post("/volumes", data=params)
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating volume!", json=result
+            )
+
+        v = Volume(self.client, result["id"], result)
+        return v
diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py
index d7b987c71..1357ef887 100644
--- a/linode_api4/linode_client.py
+++ b/linode_api4/linode_client.py
@@ -2,15 +2,14 @@
 
 import json
 import logging
-import os
 import time
-from datetime import datetime
-from typing import Tuple, BinaryIO
+from typing import BinaryIO, Tuple
 
 import pkg_resources
 import requests
 
 from linode_api4.errors import ApiError, UnexpectedResponseError
+from linode_api4.groups import *
 from linode_api4.objects import *
 from linode_api4.objects.filtering import Filter
 
@@ -23,1330 +22,15 @@
 logger = logging.getLogger(__name__)
 
 
-class Group:
-    def __init__(self, client: LinodeClient):
-        self.client = client
-
-
-class LinodeGroup(Group):
-    """
-    Encapsulates Linode-related methods of the :any:`LinodeClient`.  This
-    should not be instantiated on its own, but should instead be used through
-    an instance of :any:`LinodeClient`::
-
-       client = LinodeClient(token)
-       instances = client.linode.instances() # use the LinodeGroup
-
-    This group contains all features beneath the `/linode` group in the API v4.
-    """
-    def types(self, *filters):
-        """
-        Returns a list of Linode Instance types.  These may be used to create
-        or resize Linodes, or simply referenced on their own.  Types can be
-        filtered to return specific types, for example::
-
-           standard_types = client.linode.types(Type.class == "standard")
-
-        :param filters: Any number of filters to apply to the query.
-
-        :returns: A list of types that match the query.
-        :rtype: PaginatedList of Type
-        """
-        return self.client._get_and_filter(Type, *filters)
-
-    def instances(self, *filters):
-        """
-        Returns a list of Linode Instances on your account.  You may filter
-        this query to return only Linodes that match specific criteria::
-
-           prod_linodes = client.linode.instances(Instance.group == "prod")
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of Instances that matched the query.
-        :rtype: PaginatedList of Instance
-        """
-        return self.client._get_and_filter(Instance, *filters)
-
-    def stackscripts(self, *filters, **kwargs):
-        """
-        Returns a list of :any:`StackScripts<StackScript>`, both public and
-        private.  You may filter this query to return only
-        :any:`StackScripts<StackScript>` that match certain criteria.  You may
-        also request only your own private :any:`StackScripts<StackScript>`::
-
-           my_stackscripts = client.linode.stackscripts(mine_only=True)
-
-        :param filters: Any number of filters to apply to this query.
-        :param mine_only: If True, returns only private StackScripts
-        :type mine_only: bool
-
-        :returns: A list of StackScripts matching the query.
-        :rtype: PaginatedList of StackScript
-        """
-        # python2 can't handle *args and a single keyword argument, so this is a workaround
-        if 'mine_only' in kwargs:
-            if kwargs['mine_only']:
-                new_filter = Filter({"mine":True})
-                if filters:
-                    filters = list(filters)
-                    filters[0] = filters[0] & new_filter
-                else:
-                    filters = [new_filter]
-
-            del kwargs['mine_only']
-
-        if kwargs:
-            raise TypeError("stackscripts() got unexpected keyword argument '{}'".format(kwargs.popitem()[0]))
-
-        return self.client._get_and_filter(StackScript, *filters)
-
-    def kernels(self, *filters):
-        """
-        Returns a list of available :any:`Kernels<Kernel>`.  Kernels are used
-        when creating or updating :any:`LinodeConfigs,LinodeConfig>`.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of available kernels that match the query.
-        :rtype: PaginatedList of Kernel
-        """
-        return self.client._get_and_filter(Kernel, *filters)
-
-    # create things
-    def instance_create(self, ltype, region, image=None,
-            authorized_keys=None, **kwargs):
-        """
-        Creates a new Linode Instance. This function has several modes of operation:
-
-        **Create an Instance from an Image**
-
-        To create an Instance from an :any:`Image`, call `instance_create` with
-        a :any:`Type`, a :any:`Region`, and an :any:`Image`.  All three of
-        these fields may be provided as either the ID or the appropriate object.
-        In this mode, a root password will be generated and returned with the
-        new Instance object.  For example::
-
-           new_linode, password = client.linode.instance_create(
-               "g6-standard-2",
-               "us-east",
-               image="linode/debian9")
-
-           ltype = client.linode.types().first()
-           region = client.regions().first()
-           image = client.images().first()
-
-           another_linode, password = client.linode.instance_create(
-               ltype,
-               region,
-               image=image)
-
-        **Create an Instance from StackScript**
-
-        When creating an Instance from a :any:`StackScript`, an :any:`Image` that
-        the StackScript support must be provided..  You must also provide any
-        required StackScript data for the script's User Defined Fields..  For
-        example, if deploying `StackScript 10079`_ (which deploys a new Instance
-        with a user created from keys on `github`_::
-
-           stackscript = StackScript(client, 10079)
-
-           new_linode, password = client.linode.instance_create(
-              "g6-standard-2",
-              "us-east",
-              image="linode/debian9",
-              stackscript=stackscript,
-              stackscript_data={"gh_username": "example"})
-
-        In the above example, "gh_username" is the name of a User Defined Field
-        in the chosen StackScript.  For more information on StackScripts, see
-        the `StackScript guide`_.
-
-        .. _`StackScript 10079`: https://www.linode.com/stackscripts/view/10079
-        .. _`github`: https://github.com
-        .. _`StackScript guide`: https://www.linode.com/docs/platform/stackscripts/
-
-        **Create an Instance from a Backup**
-
-        To create a new Instance by restoring a :any:`Backup` to it, provide a
-        :any:`Type`, a :any:`Region`, and the :any:`Backup` to restore.  You
-        may provide either IDs or objects for all of these fields::
-
-           existing_linode = Instance(client, 123)
-           snapshot = existing_linode.available_backups.snapshot.current
-
-           new_linode = client.linode.instance_create(
-               "g6-standard-2",
-               "us-east",
-               backup=snapshot)
-
-        **Create an empty Instance**
-
-        If you want to create an empty Instance that you will configure manually,
-        simply call `instance_create` with a :any:`Type` and a :any:`Region`::
-
-           empty_linode = client.linode.instance_create("g6-standard-2", "us-east")
-
-        When created this way, the Instance will not be booted and cannot boot
-        successfully until disks and configs are created, or it is otherwise
-        configured.
-
-        :param ltype: The Instance Type we are creating
-        :type ltype: str or Type
-        :param region: The Region in which we are creating the Instance
-        :type region: str or Region
-        :param image: The Image to deploy to this Instance. If this is provided
-                      and no root_pass is given, a password will be generated
-                      and returned along with the new Instance.
-        :type image: str or Image
-        :param stackscript: The StackScript to deploy to the new Instance.  If
-                            provided, "image" is required and must be compatible
-                            with the chosen StackScript.
-        :type stackscript: int or StackScript
-        :param stackscript_data: Values for the User Defined Fields defined in
-                                 the chosen StackScript.  Does nothing if
-                                 StackScript is not provided.
-        :type stackscript_data: dict
-        :param backup: The Backup to restore to the new Instance.  May not be
-                       provided if "image" is given.
-        :type backup: int of Backup
-        :param authorized_keys: The ssh public keys to install in the linode's
-                                /root/.ssh/authorized_keys file.  Each entry may
-                                be a single key, or a path to a file containing
-                                the key.
-        :type authorized_keys: list or str
-        :param label: The display label for the new Instance
-        :type label: str
-        :param group: The display group for the new Instance
-        :type group: str
-        :param booted: Whether the new Instance should be booted.  This will
-                       default to True if the Instance is deployed from an Image
-                       or Backup.
-        :type booted: bool
-        :param tags: A list of tags to apply to the new instance.  If any of the
-                     tags included do not exist, they will be created as part of
-                     this operation.
-        :type tags: list[str]
-        :param private_ip: Whether the new Instance should have private networking
-                           enabled and assigned a private IPv4 address.
-        :type private_ip: bool
-
-        :returns: A new Instance object, or a tuple containing the new Instance and
-                  the generated password.
-        :rtype: Instance or tuple(Instance, str)
-        :raises ApiError: If contacting the API fails
-        :raises UnexpectedResponseError: If the API response is somehow malformed.
-                                         This usually indicates that you are using
-                                         an outdated library.
-        """
-        ret_pass = None
-        if image and not 'root_pass' in kwargs:
-            ret_pass = Instance.generate_root_password()
-            kwargs['root_pass'] = ret_pass
-
-        authorized_keys = load_and_validate_keys(authorized_keys)
-
-        if "stackscript" in kwargs:
-            # translate stackscripts
-            kwargs["stackscript_id"] = (kwargs["stackscript"].id if issubclass(type(kwargs["stackscript"]), Base)
-                                        else kwargs["stackscript"])
-            del kwargs["stackscript"]
-
-        if "backup" in kwargs:
-            # translate backups
-            kwargs["backup_id"] = (kwargs["backup"].id if issubclass(type(kwargs["backup"]), Base)
-                                   else kwargs["backup"])
-            del kwargs["backup"]
-
-        params = {
-             'type': ltype.id if issubclass(type(ltype), Base) else ltype,
-             'region': region.id if issubclass(type(region), Base) else region,
-             'image': (image.id if issubclass(type(image), Base) else image) if image else None,
-             'authorized_keys': authorized_keys,
-         }
-        params.update(kwargs)
-
-        result = self.client.post('/linode/instances', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating linode!', json=result)
-
-        l = Instance(self.client, result['id'], result)
-        if not ret_pass:
-            return l
-        return l, ret_pass
-
-    def stackscript_create(self, label, script, images, desc=None, public=False, **kwargs):
-        """
-        Creates a new :any:`StackScript` on your account.
-
-        :param label: The label for this StackScript.
-        :type label: str
-        :param script: The script to run when an :any:`Instance` is deployed with
-                       this StackScript.  Must begin with a shebang (#!).
-        :type script: str
-        :param images: A list of :any:`Images<Image>` that this StackScript
-                       supports.  Instances will not be deployed from this
-                       StackScript unless deployed from one of these Images.
-        :type images: list of Image
-        :param desc: A description for this StackScript.
-        :type desc: str
-        :param public: Whether this StackScript is public.  Defaults to False.
-                       Once a StackScript is made public, it may not be set
-                       back to private.
-        :type public: bool
-
-        :returns: The new StackScript
-        :rtype: StackScript
-        """
-        image_list = None
-        if type(images) is list or type(images) is PaginatedList:
-            image_list = [d.id if issubclass(type(d), Base) else d for d in images ]
-        elif type(images) is Image:
-            image_list = [images.id]
-        elif type(images) is str:
-            image_list = [images]
-        else:
-            raise ValueError('images must be a list of Images or a single Image')
-
-        script_body = script
-        if not script.startswith("#!"):
-            # it doesn't look like a stackscript body, let's see if it's a file
-            if os.path.isfile(script):
-                with open(script) as f:
-                    script_body = f.read()
-            else:
-                raise ValueError("script must be the script text or a path to a file")
-
-        params = {
-            "label": label,
-            "images": image_list,
-            "is_public": public,
-            "script": script_body,
-            "description": desc if desc else '',
-        }
-        params.update(kwargs)
-
-        result = self.client.post('/linode/stackscripts', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating StackScript!', json=result)
-
-        s = StackScript(self.client, result['id'], result)
-        return s
-
-
-class ProfileGroup(Group):
-    """
-    Collections related to your user.
-    """
-    def __call__(self):
-        """
-        Retrieve the acting user's Profile, containing information about the
-        current user such as their email address, username, and uid.  This is
-        intended to be called off of a :any:`LinodeClient` object, like this::
-
-           profile = client.profile()
-
-        :returns: The acting user's profile.
-        :rtype: Profile
-        """
-        result = self.client.get('/profile')
-
-        if not 'username' in result:
-            raise UnexpectedResponseError('Unexpected response when getting profile!', json=result)
-
-        p = Profile(self.client, result['username'], result)
-        return p
-
-    def tokens(self, *filters):
-        """
-        Returns the Person Access Tokens active for this user
-        """
-        return self.client._get_and_filter(PersonalAccessToken, *filters)
-
-    def token_create(self, label=None, expiry=None, scopes=None, **kwargs):
-        """
-        Creates and returns a new Personal Access Token
-        """
-        if label:
-            kwargs['label'] = label
-        if expiry:
-            if isinstance(expiry, datetime):
-                expiry = datetime.strftime(expiry, "%Y-%m-%dT%H:%M:%S")
-            kwargs['expiry'] = expiry
-        if scopes:
-            kwargs['scopes'] = scopes
-
-        result = self.client.post('/profile/tokens', data=kwargs)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating Personal Access '
-                    'Token!', json=result)
-
-        token = PersonalAccessToken(self.client, result['id'], result)
-        return token
-
-    def apps(self, *filters):
-        """
-        Returns the Authorized Applications for this user
-        """
-        return self.client._get_and_filter(AuthorizedApp, *filters)
-
-    def ssh_keys(self, *filters):
-        """
-        Returns the SSH Public Keys uploaded to your profile
-        """
-        return self.client._get_and_filter(SSHKey, *filters)
-
-    def ssh_key_upload(self, key, label):
-        """
-        Uploads a new SSH Public Key to your profile  This key can be used in
-        later Linode deployments.
-
-        :param key: The ssh key, or a path to the ssh key.  If a path is provided,
-                    the file at the path must exist and be readable or an exception
-                    will be thrown.
-        :type key: str
-        :param label: The name to give this key.  This is purely aesthetic.
-        :type label: str
-
-        :returns: The newly uploaded SSH Key
-        :rtype: SSHKey
-        :raises ValueError: If the key provided does not appear to be valid, and
-                            does not appear to be a path to a valid key.
-        """
-        if not key.startswith(SSH_KEY_TYPES):
-            # this might be a file path - look for it
-            path = os.path.expanduser(key)
-            if os.path.isfile(path):
-                with open(path) as f:
-                    key = f.read().strip()
-            if not key.startswith(SSH_KEY_TYPES):
-                raise ValueError('Invalid SSH Public Key')
-
-        params = {
-            'ssh_key': key,
-            'label': label,
-        }
-
-        result = self.client.post('/profile/sshkeys', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when uploading SSH Key!',
-                                          json=result)
-
-        ssh_key = SSHKey(self.client, result['id'], result)
-        return ssh_key
-
-
-class LKEGroup(Group):
-    """
-    Encapsulates LKE-related methods of the :any:`LinodeClient`.  This
-    should not be instantiated on its own, but should instead be used through
-    an instance of :any:`LinodeClient`::
-
-       client = LinodeClient(token)
-       instances = client.lke.clusters() # use the LKEGroup
-
-    This group contains all features beneath the `/lke` group in the API v4.
-    """
-    def versions(self, *filters):
-        """
-        Returns a :any:`PaginatedList` of :any:`KubeVersion` objects that can be
-        used when creating an LKE Cluster.
-
-        :param filters: Any number of filters to apply to the query.
-
-        :returns: A Paginated List of kube versions that match the query.
-        :rtype: PaginatedList of KubeVersion
-        """
-        return self.client._get_and_filter(KubeVersion, *filters)
-
-    def clusters(self, *filters):
-        """
-        Returns a :any:`PaginagtedList` of :any:`LKECluster` objects that belong
-        to this account.
-
-        :param filters: Any number of filters to apply to the query.
-
-        :returns: A Paginated List of LKE clusters that match the query.
-        :rtype: PaginatedList of LKECluster
-        """
-        return self.client._get_and_filter(LKECluster, *filters)
-
-    def cluster_create(self, region, label, node_pools, kube_version, **kwargs):
-        """
-        Creates an :any:`LKECluster` on this account in the given region, with
-        the given label, and with node pools as described.  For example::
-
-           client = LinodeClient(TOKEN)
-
-           # look up Region and Types to use.  In this example I'm just using
-           # the first ones returned.
-           target_region = client.regions().first()
-           node_type = client.linode.types()[0]
-           node_type_2 = client.linode.types()[1]
-           kube_version = client.lke.versions()[0]
-
-           new_cluster = client.lke.cluster_create(
-               target_region,
-               "example-cluster",
-               [client.lke.node_pool(node_type, 3), client.lke.node_pool(node_type_2, 3)],
-               kube_version
-            )
-
-        :param region: The Region to create this LKE Cluster in.
-        :type region: Region or str
-        :param label: The label for the new LKE Cluster.
-        :type label: str
-        :param node_pools: The Node Pools to create.
-        :type node_pools: one or a list of dicts containing keys "type" and "count".  See
-                          :any:`node_pool` for a convenient way to create correctly-
-                          formatted dicts.
-        :param kube_version: The version of Kubernetes to use
-        :type kube_version: KubeVersion or str
-        :param kwargs: Any other arguments to pass along to the API.  See the API
-                       docs for possible values.
-
-        :returns: The new LKE Cluster
-        :rtype: LKECluster
-        """
-        pools = []
-        if not isinstance(node_pools, list):
-            node_pools = [node_pools]
-
-        for c in node_pools:
-            if isinstance(c, dict):
-                new_pool = {
-                    "type": c["type"].id
-                    if "type" in c and issubclass(type(c["type"]), Base)
-                    else c.get("type"),
-                    "count": c.get("count"),
-                }
-
-                pools += [new_pool]
-
-        params = {
-            "label": label,
-            "region": region.id if issubclass(type(region), Base) else region,
-            "node_pools": pools,
-            "k8s_version": kube_version.id
-            if issubclass(type(kube_version), Base)
-            else kube_version,
-        }
-        params.update(kwargs)
-
-        result = self.client.post('/lke/clusters', data=params)
-
-        if 'id' not in result:
-            raise UnexpectedResponseError('Unexpected response when creating LKE cluster!', json=result)
-
-        return LKECluster(self.client, result['id'], result)
-
-    def node_pool(self, node_type, node_count):
-        """
-        Returns a dict that is suitable for passing into the `node_pools` array
-        of :any:`cluster_create`.  This is a convenience method, and need not be
-        used to create Node Pools.  For proper usage, see the docs for :any:`cluster_create`.
-
-        :param node_type: The type of node to create in this node pool.
-        :type node_type: Type or str
-        :param node_count: The number of nodes to create in this node pool.
-        :type node_count: int
-
-        :returns: A dict describing the desired node pool.
-        :rtype: dict
-        """
-        return {
-            "type": node_type,
-            "count": node_count,
-        }
-
-class LongviewGroup(Group):
-    def clients(self, *filters):
-        """
-        Requests and returns a paginated list of LongviewClients on your
-        account.
-        """
-        return self.client._get_and_filter(LongviewClient, *filters)
-
-    def client_create(self, label=None):
-        """
-        Creates a new LongviewClient, optionally with a given label.
-
-        :param label: The label for the new client.  If None, a default label based
-            on the new client's ID will be used.
-
-        :returns: A new LongviewClient
-
-        :raises ApiError: If a non-200 status code is returned
-        :raises UnexpectedResponseError: If the returned data from the api does
-            not look as expected.
-        """
-        result = self.client.post('/longview/clients', data={
-            "label": label
-        })
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating Longivew '
-                'Client!', json=result)
-
-        c = LongviewClient(self.client, result['id'], result)
-        return c
-
-    def subscriptions(self, *filters):
-        """
-        Requests and returns a paginated list of LongviewSubscriptions available
-        """
-        return self.client._get_and_filter(LongviewSubscription, *filters)
-
-
-class AccountGroup(Group):
-    def __call__(self):
-        """
-        Retrieves information about the acting user's account, such as billing
-        information.  This is intended to be called off of the :any:`LinodeClient`
-        class, like this::
-
-           account = client.account()
-
-        :returns: Returns the acting user's account information.
-        :rtype: Account
-        """
-        result = self.client.get('/account')
-
-        if not 'email' in result:
-            raise UnexpectedResponseError('Unexpected response when getting account!', json=result)
-
-        return Account(self.client, result['email'], result)
-
-
-    def events(self, *filters):
-        return self.client._get_and_filter(Event, *filters)
-
-    def events_mark_seen(self, event):
-        """
-        Marks event as the last event we have seen.  If event is an int, it is treated
-        as an event_id, otherwise it should be an event object whose id will be used.
-        """
-        last_seen = event if isinstance(event, int) else event.id
-        self.client.post('{}/seen'.format(Event.api_endpoint), model=Event(self.client, last_seen))
-
-    def settings(self):
-        """
-        Resturns the account settings data for this acocunt.  This is not  a
-        listing endpoint.
-        """
-        result = self.client.get('/account/settings')
-
-        if not 'managed' in result:
-            raise UnexpectedResponseError('Unexpected response when getting account settings!',
-                    json=result)
-
-        s = AccountSettings(self.client, result['managed'], result)
-        return s
-
-    def invoices(self):
-        """
-        Returns Invoices issued to this account
-        """
-        return self.client._get_and_filter(Invoice)
-
-    def payments(self):
-        """
-        Returns a list of Payments made to this account
-        """
-        return self.client._get_and_filter(Payment)
-
-    def oauth_clients(self, *filters):
-        """
-        Returns the OAuth Clients associated to this account
-        """
-        return self.client._get_and_filter(OAuthClient, *filters)
-
-    def oauth_client_create(self, name, redirect_uri, **kwargs):
-        """
-        Make a new OAuth Client and return it
-        """
-        params = {
-            "label": name,
-            "redirect_uri": redirect_uri,
-        }
-        params.update(kwargs)
-
-        result = self.client.post('/account/oauth-clients', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating OAuth Client!',
-                    json=result)
-
-        c = OAuthClient(self.client, result['id'], result)
-        return c
-
-    def users(self, *filters):
-        """
-        Returns a list of users on this account
-        """
-        return self.client._get_and_filter(User, *filters)
-
-    def transfer(self):
-        """
-        Returns a MappedObject containing the account's transfer pool data
-        """
-        result = self.client.get('/account/transfer')
-
-        if not 'used' in result:
-            raise UnexpectedResponseError('Unexpected response when getting Transfer Pool!')
-
-        return MappedObject(**result)
-
-    def user_create(self, email, username, restricted=True):
-        """
-        Creates a new user on your account.  If you create an unrestricted user,
-        they will immediately be able to access everything on your account.  If
-        you create a restricted user, you must grant them access to parts of your
-        account that you want to allow them to manage (see :any:`User.grants` for
-        details).
-
-        The new user will receive an email inviting them to set up their password.
-        This must be completed before they can log in.
-
-        :param email: The new user's email address.  This is used to finish setting
-                      up their user account.
-        :type email: str
-        :param username: The new user's unique username.  They will use this username
-                         to log in.
-        :type username: str
-        :param restricted: If True, the new user must be granted access to parts of
-                           the account before they can do anything.  If False, the
-                           new user will immediately be able to manage the entire
-                           account.  Defaults to True.
-        :type restricted: True
-
-        :returns The new User.
-        :rtype: User
-        """
-        params = {
-            "email": email,
-            "username": username,
-            "restricted": restricted,
-        }
-        result = self.client.post('/account/users', data=params)
-
-        if not all([c in result for c in ('email', 'restricted', 'username')]): # pylint: disable=use-a-generator
-            raise UnexpectedResponseError('Unexpected response when creating user!', json=result)
-
-        u = User(self.client, result['username'], result)
-        return u
-
-class NetworkingGroup(Group):
-    def firewalls(self, *filters):
-        """
-        .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
-
-        Retrieves the Firewalls your user has access to.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of Firewalls the acting user can access.
-        :rtype: PaginatedList of Firewall
-        """
-        return self.client._get_and_filter(Firewall, *filters)
-
-    def firewall_create(self, label, rules, **kwargs):
-        """
-        .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
-
-        Creates a new Firewall, either in the given Region or
-        attached to the given Instance.
-
-        :param label: The label for the new Firewall.
-        :type label: str
-        :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_.
-        :type rules: dict
-
-        :returns: The new Firewall.
-        :rtype: Firewall
-
-        Example usage::
-
-           rules = {
-                'outbound': [
-                    {
-                        'action': 'ACCEPT',
-                        'addresses': {
-                            'ipv4': [
-                                '0.0.0.0/0'
-                            ],
-                            'ipv6': [
-                                "ff00::/8"
-                            ]
-                        },
-                        'description': 'Allow HTTP out.',
-                        'label': 'allow-http-out',
-                        'ports': '80',
-                        'protocol': 'TCP'
-                    }
-                ],
-                'outbound_policy': 'DROP',
-                'inbound': [],
-                'inbound_policy': 'DROP'
-            }
-
-            firewall = client.networking.firewall_create('my-firewall', rules)
-
-        .. _Firewalls Documentation: https://www.linode.com/docs/api/networking/#firewall-create__request-body-schema
-        """
-
-        params = {
-            'label': label,
-            'rules': rules,
-        }
-        params.update(kwargs)
-
-        result = self.client.post('/networking/firewalls', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating Firewall!', json=result)
-
-        f = Firewall(self.client, result['id'], result)
-        return f
-
-    def ips(self, *filters):
-        return self.client._get_and_filter(IPAddress, *filters)
-
-    def ipv6_ranges(self, *filters):
-        return self.client._get_and_filter(IPv6Range, *filters)
-
-    def ipv6_pools(self, *filters):
-        return self.client._get_and_filter(IPv6Pool, *filters)
-
-    def vlans(self, *filters):
-        """
-        .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
-        Returns a list of VLANs on your account.
-        
-        :returns: A Paginated List of VLANs on your account.
-        :rtype: PaginatedList of VLAN
-        """
-        return self.client._get_and_filter(VLAN, *filters)
-
-    def ips_assign(self, region, *assignments):
-        """
-        Redistributes :any:`IP Addressees<IPAddress>` within a single region.
-        This function takes a :any:`Region` and a list of assignments to make,
-        then requests that the assignments take place.  If any :any:`Instance`
-        ends up without a public IP, or with more than one private IP, all of
-        the assignments will fail.
-
-        .. note::
-           This function *does not* update the local Linode Instance objects
-           when called.  In order to see the new addresses on the local
-           instance objects, be sure to invalidate them with ``invalidate()``
-           after this completes.
-
-        Example usage::
-
-           linode1 = Instance(client, 123)
-           linode2 = Instance(client, 456)
-
-           # swap IPs between linodes 1 and 2
-           client.networking.assign_ips(linode1.region,
-                                        linode1.ips.ipv4.public[0].to(linode2),
-                                        linode2.ips.ipv4.public[0].to(linode1))
-
-           # make sure linode1 and linode2 have updated ipv4 and ips values
-           linode1.invalidate()
-           linode2.invalidate()
-
-
-        :param region: The Region in which the assignments should take place.
-                       All Instances and IPAddresses involved in the assignment
-                       must be within this region.
-        :type region: str or Region
-        :param assignments: Any number of assignments to make.  See
-                            :any:`IPAddress.to` for details on how to construct
-                            assignments.
-        :type assignments: dct
-        """
-        for a in assignments:
-            if not 'address' in a or not 'linode_id' in a:
-                raise ValueError("Invalid assignment: {}".format(a))
-        if isinstance(region, Region):
-            region = region.id
-
-        self.client.post('/networking/ipv4/assign', data={
-            "region": region,
-            "assignments": assignments,
-        })
-
-    def ip_allocate(self, linode, public=True):
-        """
-        Allocates an IP to a Instance you own.  Additional IPs must be requested
-        by opening a support ticket first.
-
-        :param linode: The Instance to allocate the new IP for.
-        :type linode: Instance or int
-        :param public: If True, allocate a public IP address.  Defaults to True.
-        :type public: bool
-
-        :returns: The new IPAddress
-        :rtype: IPAddress
-        """
-        result = self.client.post('/networking/ips/', data={
-            "linode_id": linode.id if isinstance(linode, Base) else linode,
-            "type": "ipv4",
-            "public": public,
-        })
-
-        if not 'address' in result:
-            raise UnexpectedResponseError('Unexpected response when adding IPv4 address!',
-                    json=result)
-
-        ip = IPAddress(self.client, result['address'], result)
-        return ip
-
-    def ips_share(self, linode, *ips):
-        """
-        Shares the given list of :any:`IPAddresses<IPAddress>` with the provided
-        :any:`Instance`.  This will enable the provided Instance to bring up the
-        shared IP Addresses even though it does not own them.
-
-        :param linode: The Instance to share the IPAddresses with.  This Instance
-                       will be able to bring up the given addresses.
-        :type: linode: int or Instance
-        :param ips: Any number of IPAddresses to share to the Instance.
-        :type ips: str or IPAddress
-        """
-        if not isinstance(linode, Instance):
-            # make this an object
-            linode = Instance(self.client, linode)
-
-        params = []
-        for ip in ips:
-            if isinstance(ip, str):
-                params.append(ip)
-            elif isinstance(ip, IPAddress):
-                params.append(ip.address)
-            else:
-                params.append(str(ip)) # and hope that works
-
-        params = {
-            "ips": params
-        }
-
-        self.client.post('{}/networking/ipv4/share'.format(Instance.api_endpoint),
-                         model=linode, data=params)
-
-        linode.invalidate() # clear the Instance's shared IPs
-
-
-class SupportGroup(Group):
-    def tickets(self, *filters):
-        return self.client._get_and_filter(SupportTicket, *filters)
-
-    def ticket_open(self, summary, description, regarding=None):
-        """
-
-        """
-        params = {
-            "summary": summary,
-            "description": description,
-        }
-
-        if regarding:
-            if isinstance(regarding, Instance):
-                params['linode_id'] = regarding.id
-            elif isinstance(regarding, Domain):
-                params['domain_id'] = regarding.id
-            elif isinstance(regarding, NodeBalancer):
-                params['nodebalancer_id'] = regarding.id
-            elif isinstance(regarding, Volume):
-                params['volume_id'] = regarding.id
-            else:
-                raise ValueError('Cannot open ticket regarding type {}!'.format(type(regarding)))
-
-
-        result = self.client.post('/support/tickets', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating ticket!',
-                    json=result)
-
-        t = SupportTicket(self.client, result['id'], result)
-        return t
-
-
-class ObjectStorageGroup(Group):
-    """
-    This group encapsulates all endpoints under /object-storage, including viewing
-    available clusters and managing keys.
-    """
-    def clusters(self, *filters):
-        """
-        Returns a list of available Object Storage Clusters.  You may filter
-        this query to return only Clusters that are available in a specific region::
-
-           us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east")
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of Object Storage Clusters that matched the query.
-        :rtype: PaginatedList of ObjectStorageCluster
-        """
-        return self.client._get_and_filter(ObjectStorageCluster, *filters)
-
-    def keys(self, *filters):
-        """
-        Returns a list of Object Storage Keys active on this account.  These keys
-        allow third-party applications to interact directly with Linode Object Storage.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of Object Storage Keys that matched the query.
-        :rtype: PaginatedList of ObjectStorageKeys
-        """
-        return self.client._get_and_filter(ObjectStorageKeys, *filters)
-
-    def keys_create(self, label, bucket_access=None):
-        """
-        Creates a new Object Storage keypair that may be used to interact directly
-        with Linode Object Storage in third-party applications.  This response is
-        the only time that "secret_key" will be populated - be sure to capture its
-        value or it will be lost forever.
-
-        If given, `bucket_access` will cause the new keys to be restricted to only
-        the specified level of access for the specified buckets.  For example, to
-        create a keypair that can only access the "example" bucket in all clusters
-        (and assuming you own that bucket in every cluster), you might do this::
-
-           client = LinodeClient(TOKEN)
-
-           # look up clusters
-           all_clusters = client.object_storage.clusters()
-
-           new_keys = client.object_storage.keys_create(
-               "restricted-keys",
-               bucket_access=[
-                   client.object_storage.bucket_access(cluster, "example", "read_write")
-                   for cluster in all_clusters
-               ],
-           )
-
-        To create a keypair that can only read from the bucket "example2" in the
-        "us-east-1" cluster (an assuming you own that bucket in that cluster),
-        you might do this::
-
-           client = LinodeClient(TOKEN)
-           new_keys_2 = client.object_storage.keys_create(
-               "restricted-keys-2",
-               bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"),
-           )
-
-        :param label: The label for this keypair, for identification only.
-        :type label: str
-        :param bucket_access: One or a list of dicts with keys "cluster,"
-                              "permissions", and "bucket_name".  If given, the
-                              resulting Object Storage keys will only have the
-                              requested level of access to the requested buckets,
-                              if they exist and are owned by you.  See the provided
-                              :any:`bucket_access` function for a convenient way
-                              to create these dicts.
-        :type bucket_access: dict or list of dict
-
-        :returns: The new keypair, with the secret key populated.
-        :rtype: ObjectStorageKeys
-        """
-        params = {
-            "label": label
-        }
-
-        if bucket_access is not None:
-            if not isinstance(bucket_access, list):
-                bucket_access = [bucket_access]
-
-            ba = [
-                {
-                    "permissions": c.get("permissions"),
-                    "bucket_name": c.get("bucket_name"),
-                    "cluster": c.id if "cluster" in c and issubclass(type(c["cluster"]), Base) else c.get("cluster"),
-                } for c in bucket_access
-            ]
-
-            params['bucket_access'] = ba
-
-        result = self.client.post('/object-storage/keys', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating Object Storage Keys!', json=result)
-
-        ret = ObjectStorageKeys(self.client, result['id'], result)
-        return ret
-
-    def bucket_access(self, cluster, bucket_name, permissions):
-        """
-        Returns a dict formatted to be included in the `bucket_access` argument
-        of :any:`keys_create`.  See the docs for that method for an example of
-        usage.
-
-        :param cluster: The Object Storage cluster to grant access in.
-        :type cluster: :any:`ObjectStorageCluster` or str
-        :param bucket_name: The name of the bucket to grant access to.
-        :type bucket_name: str
-        :param permissions: The permissions to grant.  Should be one of "read_only"
-                            or "read_write".
-        :type permissions: str
-
-        :returns: A dict formatted correctly for specifying  bucket access for
-                  new keys.
-        :rtype: dict
-        """
-        return {
-            "cluster": cluster,
-            "bucket_name": bucket_name,
-            "permissions": permissions,
-        }
-
-    def cancel(self):
-        """
-        Cancels Object Storage service.  This may be a destructive operation.  Once
-        cancelled, you will no longer receive the transfer for or be billed for
-        Object Storage, and all keys will be invalidated.
-        """
-        self.client.post('/object-storage/cancel', data={})
-        return True
-
-
-class DatabaseGroup(Group):
-    """
-    Encapsulates Linode Managed Databases related methods of the :any:`LinodeClient`. This
-    should not be instantiated on its own, but should instead be used through
-    an instance of :any:`LinodeClient`::
-
-       client = LinodeClient(token)
-       instances = client.database.instances() # use the DatabaseGroup
-
-    This group contains all features beneath the `/databases` group in the API v4.
-    """
-
-    def types(self, *filters):
-        """
-        Returns a list of Linode Database-compatible Instance types.
-        These may be used to create Managed Databases, or simply
-        referenced to on their own. DatabaseTypes can be
-        filtered to return specific types, for example::
-
-           database_types = client.database.types(DatabaseType.deprecated == False)
-
-        :param filters: Any number of filters to apply to the query.
-
-        :returns: A list of types that match the query.
-        :rtype: PaginatedList of DatabaseType
-        """
-        return self.client._get_and_filter(DatabaseType, *filters)
-
-    def engines(self, *filters):
-        """
-        Returns a list of Linode Managed Database Engines.
-        These may be used to create Managed Databases, or simply
-        referenced to on their own. Engines can be filtered to
-        return specific engines, for example::
-
-           mysql_engines = client.database.engines(DatabaseEngine.engine == 'mysql')
-
-        :param filters: Any number of filters to apply to the query.
-
-        :returns: A list of types that match the query.
-        :rtype: PaginatedList of DatabaseEngine
-        """
-        return self.client._get_and_filter(DatabaseEngine, *filters)
-
-    def instances(self, *filters):
-        """
-        Returns a list of Managed Databases active on this account.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of databases that matched the query.
-        :rtype: PaginatedList of Database
-        """
-        return self.client._get_and_filter(Database, *filters)
-
-    def mysql_instances(self, *filters):
-        """
-        Returns a list of Managed MySQL Databases active on this account.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of MySQL databases that matched the query.
-        :rtype: PaginatedList of MySQLDatabase
-        """
-        return self.client._get_and_filter(MySQLDatabase, *filters)
-
-    def mysql_create(self, label, region, engine, ltype, **kwargs):
-        """
-        Creates an :any:`MySQLDatabase` on this account with
-        the given label, region, engine, and node type.  For example::
-
-           client = LinodeClient(TOKEN)
-
-           # look up Region and Types to use.  In this example I'm just using
-           # the first ones returned.
-           region = client.regions().first()
-           node_type = client.database.types()[0]
-           engine = client.database.engines(DatabaseEngine.engine == 'mysql')[0]
-
-           new_database = client.database.mysql_create(
-               "example-database",
-               region,
-               engine.id,
-               type.id
-            )
-
-        :param label: The name for this cluster
-        :type label: str
-        :param region: The region to deploy this cluster in
-        :type region: str or Region
-        :param engine: The engine to deploy this cluster with
-        :type engine: str or Engine
-        :param ltype: The Linode Type to use for this cluster
-        :type ltype: str or Type
-        """
-
-        params = {
-            'label': label,
-            'region': region.id if issubclass(type(region), Base) else region,
-            'engine': engine.id if issubclass(type(engine), Base) else engine,
-            'type': ltype.id if issubclass(type(ltype), Base) else ltype,
-        }
-        params.update(kwargs)
-
-        result = self.client.post('/databases/mysql/instances', data=params)
-
-        if 'id' not in result:
-            raise UnexpectedResponseError('Unexpected response when creating MySQL Database', json=result)
-
-        d = MySQLDatabase(self.client, result['id'], result)
-        return d
-
-    def postgresql_instances(self, *filters):
-        """
-        Returns a list of Managed PostgreSQL Databases active on this account.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of PostgreSQL databases that matched the query.
-        :rtype: PaginatedList of PostgreSQLDatabase
-        """
-        return self.client._get_and_filter(PostgreSQLDatabase, *filters)
-
-    def postgresql_create(self, label, region, engine, ltype, **kwargs):
-        """
-        Creates an :any:`PostgreSQLDatabase` on this account with
-        the given label, region, engine, and node type.  For example::
-
-           client = LinodeClient(TOKEN)
-
-           # look up Region and Types to use.  In this example I'm just using
-           # the first ones returned.
-           region = client.regions().first()
-           node_type = client.database.types()[0]
-           engine = client.database.engines(DatabaseEngine.engine == 'postgresql')[0]
-
-           new_database = client.database.postgresql_create(
-               "example-database",
-               region,
-               engine.id,
-               type.id
-            )
-
-        :param label: The name for this cluster
-        :type label: str
-        :param region: The region to deploy this cluster in
-        :type region: str or Region
-        :param engine: The engine to deploy this cluster with
-        :type engine: str or Engine
-        :param ltype: The Linode Type to use for this cluster
-        :type ltype: str or Type
-        """
-
-        params = {
-            'label': label,
-            'region': region.id if issubclass(type(region), Base) else region,
-            'engine': engine.id if issubclass(type(engine), Base) else engine,
-            'type': ltype.id if issubclass(type(ltype), Base) else ltype,
-        }
-        params.update(kwargs)
-
-        result = self.client.post('/databases/postgresql/instances', data=params)
-
-        if 'id' not in result:
-            raise UnexpectedResponseError('Unexpected response when creating PostgreSQL Database', json=result)
-
-        d = PostgreSQLDatabase(self.client, result['id'], result)
-        return d
-
-    def mongodb_instances(self, *filters):
-        """
-        Returns a list of Managed MongoDB Databases active on this account.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of MongoDB databases that matched the query.
-        :rtype: PaginatedList of MongoDBDatabase
-        """
-        return self.client._get_and_filter(MongoDBDatabase, *filters)
-
-    def mongodb_create(self, label, region, engine, ltype, **kwargs):
-        """
-        Creates an :any:`MongoDBDatabase` on this account with
-        the given label, region, engine, and node type.  For example::
-
-           client = LinodeClient(TOKEN)
-
-           # look up Region and Types to use.  In this example I'm just using
-           # the first ones returned.
-           region = client.regions().first()
-           node_type = client.database.types()[0]
-           engine = client.database.engines(DatabaseEngine.engine == 'mongodb')[0]
-
-           new_database = client.database.mongodb_create(
-               "example-database",
-               region,
-               engine.id,
-               type.id
-            )
-
-        :param label: The name for this cluster
-        :type label: str
-        :param region: The region to deploy this cluster in
-        :type region: str or Region
-        :param engine: The engine to deploy this cluster with
-        :type engine: str or Engine
-        :param ltype: The Linode Type to use for this cluster
-        :type ltype: str or Type
-        """
-
-        params = {
-            'label': label,
-            'region': region.id if issubclass(type(region), Base) else region,
-            'engine': engine.id if issubclass(type(engine), Base) else engine,
-            'type': ltype.id if issubclass(type(ltype), Base) else ltype,
-        }
-        params.update(kwargs)
-
-        result = self.client.post('/databases/mongodb/instances', data=params)
-
-        if 'id' not in result:
-            raise UnexpectedResponseError('Unexpected response when creating MongoDB Database', json=result)
-
-        d = MongoDBDatabase(self.client, result['id'], result)
-        return d
-
-
 class LinodeClient:
-    def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_interval=None):
+    def __init__(
+        self,
+        token,
+        base_url="https://api.linode.com/v4",
+        user_agent=None,
+        page_size=None,
+        retry_rate_limit_interval=None,
+    ):
         """
         The main interface to the Linode API.
 
@@ -1384,7 +68,9 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None,
             if not isinstance(self.retry_rate_limit_interval, int):
                 raise ValueError("retry_rate_limit_interval must be an int")
             if self.retry_rate_limit_interval < 1:
-                raise ValueError("retry_rate_limit_interval must not be less than 1")
+                raise ValueError(
+                    "retry_rate_limit_interval must not be less than 1"
+                )
 
         #: Access methods related to Linodes - see :any:`LinodeGroup` for
         #: more information
@@ -1420,12 +106,30 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None,
         #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information.
         self.database = DatabaseGroup(self)
 
+        #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information.
+        self.nodebalancers = NodeBalancerGroup(self)
+
+        #: Access methods related to Domains - see :any:`DomainGroup` for more information.
+        self.domains = DomainGroup(self)
+
+        #: Access methods related to Tags - See :any:`TagGroup` for more information.
+        self.tags = TagGroup(self)
+
+        #: Access methods related to Volumes - See :any:`VolumeGroup` for more information.
+        self.volumes = VolumeGroup(self)
+
+        #: Access methods related to Regions - See :any:`RegionGroup` for more information.
+        self.regions = RegionGroup(self)
+
+        #: Access methods related to Images - See :any:`ImageGroup` for more information.
+        self.images = ImageGroup(self)
+
     @property
     def _user_agent(self):
-        return '{}python-linode_api4/{} {}'.format(
-                '{} '.format(self._add_user_agent) if self._add_user_agent else '',
-                package_version,
-                requests.utils.default_user_agent()
+        return "{}python-linode_api4/{} {}".format(
+            "{} ".format(self._add_user_agent) if self._add_user_agent else "",
+            package_version,
+            requests.utils.default_user_agent(),
         )
 
     def load(self, target_type, target_id, target_parent_id=None):
@@ -1456,12 +160,16 @@ def load(self, target_type, target_id, target_parent_id=None):
         :rtype: target_type
         :raise ApiError: if the requested object could not be loaded.
         """
-        result = target_type.make_instance(target_id, self, parent_id=target_parent_id)
+        result = target_type.make_instance(
+            target_id, self, parent_id=target_parent_id
+        )
         result._api_get()
 
         return result
 
-    def _api_call(self, endpoint, model=None, method=None, data=None, filters=None):
+    def _api_call(
+        self, endpoint, model=None, method=None, data=None, filters=None
+    ):
         """
         Makes a call to the linode api.  Data should only be given if the method is
         POST or PUT, and should be a dictionary
@@ -1474,15 +182,15 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None):
 
         if model:
             endpoint = endpoint.format(**vars(model))
-        url = '{}{}'.format(self.base_url, endpoint)
+        url = "{}{}".format(self.base_url, endpoint)
         headers = {
-            'Authorization': "Bearer {}".format(self.token),
-            'Content-Type': 'application/json',
-            'User-Agent': self._user_agent,
+            "Authorization": "Bearer {}".format(self.token),
+            "Content-Type": "application/json",
+            "User-Agent": self._user_agent,
         }
 
         if filters:
-            headers['X-Filter'] = json.dumps(filters)
+            headers["X-Filter"] = json.dumps(filters)
 
         body = None
         if data is not None:
@@ -1493,28 +201,39 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None):
         for attempt in range(max_retries):
             response = method(url, headers=headers, data=body)
 
-            warning = response.headers.get('Warning', None)
+            warning = response.headers.get("Warning", None)
             if warning:
-                logger.warning('Received warning from server: {}'.format(warning))
+                logger.warning(
+                    "Received warning from server: {}".format(warning)
+                )
 
             # if we were configured to retry 429s, and we got a 429, sleep briefly and then retry
             if self.retry_rate_limit_interval and response.status_code == 429:
-                logger.warning("Received 429 response; waiting {} seconds and retrying request (attempt {}/{})".format(
-                    self.retry_rate_limit_interval, attempt, max_retries,
-                ))
+                logger.warning(
+                    "Received 429 response; waiting {} seconds and retrying request (attempt {}/{})".format(
+                        self.retry_rate_limit_interval,
+                        attempt,
+                        max_retries,
+                    )
+                )
                 time.sleep(self.retry_rate_limit_interval)
             else:
                 break
 
         if 399 < response.status_code < 600:
             j = None
-            error_msg = '{}: '.format(response.status_code)
+            error_msg = "{}: ".format(response.status_code)
             try:
                 j = response.json()
-                if 'errors' in j.keys():
-                    for e in j['errors']:
-                        error_msg += '{}; '.format(e['reason']) \
-                                if 'reason' in e.keys() else ''
+                if "errors" in j.keys():
+                    for e in j["errors"]:
+                        msg = e.get("reason", "")
+                        field = e.get("field", None)
+
+                        error_msg += "{}{}; ".format(
+                            f"[{field}] " if field is not None else "",
+                            msg,
+                        )
             except:
                 pass
             raise ApiError(error_msg, status=response.status_code, json=j)
@@ -1522,11 +241,13 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None):
         if response.status_code != 204:
             j = response.json()
         else:
-            j = None # handle no response body
+            j = None  # handle no response body
 
         return j
 
-    def _get_objects(self, endpoint, cls, model=None, parent_id=None, filters=None):
+    def _get_objects(
+        self, endpoint, cls, model=None, parent_id=None, filters=None
+    ):
         # handle non-default page sizes
         call_endpoint = endpoint
         if self.page_size is not None:
@@ -1535,17 +256,25 @@ def _get_objects(self, endpoint, cls, model=None, parent_id=None, filters=None):
         response_json = self.get(call_endpoint, model=model, filters=filters)
 
         if not "data" in response_json:
-            raise UnexpectedResponseError("Problem with response!", json=response_json)
+            raise UnexpectedResponseError(
+                "Problem with response!", json=response_json
+            )
 
-        if 'pages' in response_json:
+        if "pages" in response_json:
             formatted_endpoint = endpoint
             if model:
                 formatted_endpoint = formatted_endpoint.format(**vars(model))
-            return PaginatedList.make_paginated_list(response_json, self, cls,
-                    parent_id=parent_id, page_url=formatted_endpoint[1:],
-                    filters=filters)
-        return PaginatedList.make_list(response_json["data"], self, cls,
-                parent_id=parent_id)
+            return PaginatedList.make_paginated_list(
+                response_json,
+                self,
+                cls,
+                parent_id=parent_id,
+                page_url=formatted_endpoint[1:],
+                filters=filters,
+            )
+        return PaginatedList.make_list(
+            response_json["data"], self, cls, parent_id=parent_id
+        )
 
     def get(self, *args, **kwargs):
         return self._api_call(*args, method=self.session.get, **kwargs)
@@ -1559,354 +288,96 @@ def put(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
         return self._api_call(*args, method=self.session.delete, **kwargs)
 
-    # ungrouped list functions
-    def regions(self, *filters):
-        """
-        Returns the available Regions for Linode products.
-
-        :param filters: Any number of filters to apply to the query.
-
-        :returns: A list of available Regions.
-        :rtype: PaginatedList of Region
-        """
-        return self._get_and_filter(Region, *filters)
-
-    def images(self, *filters):
-        """
-        Retrieves a list of available Images, including public and private
-        Images available to the acting user.  You can filter this query to
-        retrieve only Images relevant to a specific query, for example::
-
-           debian_images = client.images(
-               Image.vendor == "debain")
-
-        :param filters: Any number of filters to apply to the query.
-
-        :returns: A list of available Images.
-        :rtype: PaginatedList of Image
-        """
-        return self._get_and_filter(Image, *filters)
-
     def image_create(self, disk, label=None, description=None):
         """
-        Creates a new Image from a disk you own.
-
-        :param disk: The Disk to imagize.
-        :type disk: Disk or int
-        :param label: The label for the resulting Image (defaults to the disk's
-                      label.
-        :type label: str
-        :param description: The description for the new Image.
-        :type description: str
-
-        :returns: The new Image.
-        :rtype: Image
-        """
-        params = {
-            "disk_id": disk.id if issubclass(type(disk), Base) else disk,
-        }
-
-        if label is not None:
-            params["label"] = label
-
-        if description is not None:
-            params["description"] = description
-
-        result = self.post('/images', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating an '
-                                          'Image from disk {}'.format(disk))
-
-        return Image(self, result['id'], result)
-
-    def image_create_upload(self, label: str, region: str, description: str=None) -> Tuple[Image, str]:
-        """
-        Creates a new Image and returns the corresponding upload URL.
-        https://www.linode.com/docs/api/images/#image-upload
-
-        :param label: The label of the Image to create.
-        :type label: str
-        :param region: The region to upload to. Once the image has been created, it can be used in any region.
-        :type region: str
-        :param description: The description for the new Image.
-        :type description: str
-
-        :returns: A tuple containing the new image and the image upload URL.
-        :rtype: (Image, str)
+        .. note:: This method is an alias to maintain backwards compatibility.
+                  Please use :meth:`LinodeClient.images.create(...) <.ImageGroup.create>` for all new projects.
         """
-        params = {
-            "label": label,
-            "region": region,
-            "description": description
-        }
-
-        result = self.post("/images/upload", data=drop_null_keys(params))
-
-        if "image" not in result:
-            raise UnexpectedResponseError('Unexpected response when creating an '
-                                          'Image upload URL')
+        return self.images.create(disk, label=label, description=description)
 
-        result_image = result["image"]
-        result_url = result["upload_to"]
-
-        return Image(self, result_image["id"], result_image), result_url
-
-    def image_upload(self, label: str, region: str, file: BinaryIO, description: str=None) -> Image:
-        """
-        Creates and uploads a new image.
-        https://www.linode.com/docs/api/images/#image-upload
-
-        :param label: The label of the Image to create.
-        :type label: str
-        :param region: The region to upload to. Once the image has been created, it can be used in any region.
-        :type region: str
-        :param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb").
-        :param description: The description for the new Image.
-        :type description: str
-
-        :returns: The resulting image.
-        :rtype: Image
+    def image_create_upload(
+        self, label: str, region: str, description: str = None
+    ) -> Tuple[Image, str]:
         """
-
-        image, url = self.image_create_upload(label, region, description=description)
-
-        requests.put(
-            url,
-            headers={"Content-Type": "application/octet-stream"},
-            data=file,
-        )
-
-        image._api_get()
-
-        return image
-
-    def domains(self, *filters):
+        .. note:: This method is an alias to maintain backwards compatibility.
+                  Please use :meth:`LinodeClient.images.create_upload(...) <.ImageGroup.create_upload>`
+                  for all new projects.
         """
-        Retrieves all of the Domains the acting user has access to.
 
-        :param filters: Any number of filters to apply to this query.
+        return self.images.create_upload(label, region, description=description)
 
-        :returns: A list of Domains the acting user can access.
-        :rtype: PaginatedList of Domain
+    def image_upload(
+        self, label: str, region: str, file: BinaryIO, description: str = None
+    ) -> Image:
         """
-        return self._get_and_filter(Domain, *filters)
-
-    def nodebalancers(self, *filters):
+        .. note:: This method is an alias to maintain backwards compatibility.
+                  Please use :meth:`LinodeClient.images.upload(...) <.ImageGroup.upload>` for all new projects.
         """
-        Retrieves all of the NodeBalancers the acting user has access to.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of NodeBalancers the acting user can access.
-        :rtype: PaginatedList of NodeBalancers
-        """
-        return self._get_and_filter(NodeBalancer, *filters)
+        return self.images.upload(label, region, file, description=description)
 
     def nodebalancer_create(self, region, **kwargs):
         """
-        Creates a new NodeBalancer in the given Region.
-
-        :param region: The Region in which to create the NodeBalancer.
-        :type region: Region or str
-
-        :returns: The new NodeBalancer
-        :rtype: NodeBalancer
+        .. note:: This method is an alias to maintain backwards compatibility.
+                  Please use
+                  :meth:`LinodeClient.nodebalancers.create(...) <.NodeBalancerGroup.create>`
+                  for all new projects.
         """
-        params = {
-            "region": region.id if isinstance(region, Base) else region,
-        }
-        params.update(kwargs)
-
-        result = self.post('/nodebalancers', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating Nodebalaner!', json=result)
-
-        n = NodeBalancer(self, result['id'], result)
-        return n
+        return self.nodebalancers.create(region, **kwargs)
 
     def domain_create(self, domain, master=True, **kwargs):
         """
-        Registers a new Domain on the acting user's account.  Make sure to point
-        your registrar to Linode's nameservers so that Linode's DNS manager will
-        correctly serve your domain.
-
-        :param domain: The domain to register to Linode's DNS manager.
-        :type domain: str
-        :param master: Whether this is a master (defaults to true)
-        :type master: bool
-        :param tags: A list of tags to apply to the new domain.  If any of the
-                     tags included do not exist, they will be created as part of
-                     this operation.
-        :type tags: list[str]
-
-        :returns: The new Domain object.
-        :rtype: Domain
-        """
-        params = {
-            'domain': domain,
-            'type': 'master' if master else 'slave',
-        }
-        params.update(kwargs)
-
-        result = self.post('/domains', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating Domain!', json=result)
-
-        d = Domain(self, result['id'], result)
-        return d
-
-    def tags(self, *filters):
-        """
-        Retrieves the Tags on your account.  This may only be attempted by
-        unrestricted users.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of Tags on the account.
-        :rtype: PaginatedList of Tag
-        """
-        return self._get_and_filter(Tag, *filters)
-
-    def tag_create(self, label, instances=None, domains=None, nodebalancers=None,
-                   volumes=None, entities=[]):
-        """
-        Creates a new Tag and optionally applies it to the given entities.
-
-        :param label: The label for the new Tag
-        :type label: str
-        :param entities: A list of objects to apply this Tag to upon creation.
-                         May only be taggable types (Linode Instances, Domains,
-                         NodeBalancers, or Volumes).  These are applied *in addition
-                         to* any IDs specified with ``instances``, ``domains``,
-                         ``nodebalancers``, or ``volumes``, and is a convenience
-                         for sending multiple entity types without sorting them
-                         yourself.
-        :type entities: list of Instance, Domain, NodeBalancer, and/or Volume
-        :param instances: A list of Linode Instances to apply this Tag to upon
-                        creation
-        :type instances: list of Instance or list of int
-        :param domains: A list of Domains to apply this Tag to upon
-                        creation
-        :type domains: list of Domain or list of int
-        :param nodebalancers: A list of NodeBalancers to apply this Tag to upon
-                        creation
-        :type nodebalancers: list of NodeBalancer or list of int
-        :param volumes: A list of Volumes to apply this Tag to upon
-                        creation
-        :type volumes: list of Volumes or list of int
-
-        :returns: The new Tag
-        :rtype: Tag
-        """
-        linode_ids, nodebalancer_ids, domain_ids, volume_ids = [], [], [], []
-
-        # filter input into lists of ids
-        sorter = zip((linode_ids, nodebalancer_ids, domain_ids, volume_ids),
-                     (instances, nodebalancers, domains, volumes))
-
-        for id_list, input_list in sorter:
-            # if we got something, we need to find its ID
-            if input_list is not None:
-                for cur in input_list:
-                    if isinstance(cur, int):
-                        id_list.append(cur)
-                    else:
-                        id_list.append(cur.id)
-
-        # filter entities into id lists too
-        type_map = {
-            Instance: linode_ids,
-            NodeBalancer: nodebalancer_ids,
-            Domain: domain_ids,
-            Volume: volume_ids,
-        }
-
-        for e in entities:
-            if type(e) in type_map:
-                type_map[type(e)].append(e.id)
-            else:
-                raise ValueError('Unsupported entity type {}'.format(type(e)))
-
-        # finally, omit all id lists that are empty
-        params = {
-            'label': label,
-            'linodes': linode_ids or None,
-            'nodebalancers': nodebalancer_ids or None,
-            'domains': domain_ids or None,
-            'volumes': volume_ids or None,
-        }
-
-        result = self.post('/tags', data=params)
-
-        if not 'label' in result:
-            raise UnexpectedResponseError('Unexpected response when creating Tag!', json=result)
-
-        t = Tag(self, result['label'], result)
-        return t
-
-    def volumes(self, *filters):
-        """
-        Retrieves the Block Storage Volumes your user has access to.
-
-        :param filters: Any number of filters to apply to this query.
-
-        :returns: A list of Volumes the acting user can access.
-        :rtype: PaginatedList of Volume
-        """
-        return self._get_and_filter(Volume, *filters)
+        .. note:: This method is an alias to maintain backwards compatibility.
+                  Please use :meth:`LinodeClient.domains.create(...) <.DomainGroup.create>` for all
+                  new projects.
+        """
+        return self.domains.create(domain, master=master, **kwargs)
+
+    def tag_create(
+        self,
+        label,
+        instances=None,
+        domains=None,
+        nodebalancers=None,
+        volumes=None,
+        entities=[],
+    ):
+        """
+        .. note:: This method is an alias to maintain backwards compatibility.
+                  Please use :meth:`LinodeClient.tags.create(...) <.TagGroup.create>` for all new projects.
+        """
+        return self.tags.create(
+            label,
+            instances=instances,
+            domains=domains,
+            nodebalancers=nodebalancers,
+            volumes=volumes,
+            entities=entities,
+        )
 
     def volume_create(self, label, region=None, linode=None, size=20, **kwargs):
         """
-        Creates a new Block Storage Volume, either in the given Region or
-        attached to the given Instance.
-
-        :param label: The label for the new Volume.
-        :type label: str
-        :param region: The Region to create this Volume in.  Not required if
-                       `linode` is provided.
-        :type region: Region or str
-        :param linode: The Instance to attach this Volume to.  If not given, the
-                       new Volume will not be attached to anything.
-        :type linode: Instance or int
-        :param size: The size, in GB, of the new Volume.  Defaults to 20.
-        :type size: int
-        :param tags: A list of tags to apply to the new volume.  If any of the
-                     tags included do not exist, they will be created as part of
-                     this operation.
-        :type tags: list[str]
-
-        :returns: The new Volume.
-        :rtype: Volume
+        .. note:: This method is an alias to maintain backwards compatibility.
+                  Please use :meth:`LinodeClient.volumes.create(...) <.VolumeGroup.create>` for all new projects.
         """
-        if not (region or linode):
-            raise ValueError('region or linode required!')
-
-        params = {
-            "label": label,
-            "size": size,
-            "region": region.id if issubclass(type(region), Base) else region,
-            "linode_id": linode.id if issubclass(type(linode), Base) else linode,
-        }
-        params.update(kwargs)
-
-        result = self.post('/volumes', data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating volume!', json=result)
-
-        v = Volume(self, result['id'], result)
-        return v
+        return self.volumes.create(
+            label, region=region, linode=linode, size=size, **kwargs
+        )
 
     # helper functions
-    def _get_and_filter(self, obj_type, *filters):
+    def _get_and_filter(self, obj_type, *filters, endpoint=None):
         parsed_filters = None
         if filters:
-            if(len(filters) > 1):
-                parsed_filters = and_(*filters).dct # pylint: disable=no-value-for-parameter
+            if len(filters) > 1:
+                parsed_filters = and_(
+                    *filters
+                ).dct  # pylint: disable=no-value-for-parameter
             else:
                 parsed_filters = filters[0].dct
 
-        return self._get_objects(obj_type.api_list(), obj_type, filters=parsed_filters)
+        # Use sepcified endpoint
+        if endpoint:
+            return self._get_objects(endpoint, obj_type, filters=parsed_filters)
+        else:
+            return self._get_objects(
+                obj_type.api_list(), obj_type, filters=parsed_filters
+            )
diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py
index 39fd1d604..7531a7725 100644
--- a/linode_api4/login_client.py
+++ b/linode_api4/login_client.py
@@ -1,3 +1,4 @@
+import re
 from datetime import datetime, timedelta
 from enum import Enum
 
@@ -6,17 +7,17 @@
 from linode_api4.errors import ApiError
 
 try:
-    from urllib.parse import urlparse
-    from urllib.parse import urlencode
-    from urllib.parse import urlunparse
+    from urllib.parse import urlencode, urlparse, urlunparse
 except ImportError:
-    from urlparse import urlparse
     from urllib import urlencode
-    from urlparse import urlunparse
 
-class AllWrapper():
+    from urlparse import urlparse, urlunparse
+
+
+class AllWrapper:
     def __repr__(self):
-        return '*'
+        return "*"
+
 
 class OAuthScopes:
     """
@@ -44,12 +45,13 @@ class Linodes(Enum):
         """
         Access to Linodes
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "linodes:*"
             return "linodes:{}".format(self.name)
 
@@ -57,12 +59,13 @@ class Domains(Enum):
         """
         Access to Domains
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "domains:*"
             return "domains:{}".format(self.name)
 
@@ -70,12 +73,13 @@ class StackScripts(Enum):
         """
         Access to private StackScripts
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "stackscripts:*"
             return "stackscripts:{}".format(self.name)
 
@@ -85,7 +89,7 @@ class Users(Enum):
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "users:*"
             return "users:{}".format(self.name)
 
@@ -93,12 +97,13 @@ class NodeBalancers(Enum):
         """
         Access to NodeBalancers
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "nodebalancers:*"
             return "nodebalancers:{}".format(self.name)
 
@@ -108,7 +113,7 @@ class Tokens(Enum):
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "tokens:*"
             return "tokens:{}".format(self.name)
 
@@ -116,12 +121,13 @@ class IPs(Enum):
         """
         Access to IPs and networking managements
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "ips:*"
             return "ips:{}".format(self.name)
 
@@ -129,12 +135,13 @@ class Firewalls(Enum):
         """
         Access to Firewalls
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "firewall:*"
             return "firewall:{}".format(self.name)
 
@@ -142,12 +149,13 @@ class Tickets(Enum):
         """
         Access to view, open, and respond to Support Tickets
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "tickets:*"
             return "tickets:{}".format(self.name)
 
@@ -157,7 +165,7 @@ class Clients(Enum):
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "clients:*"
             return "clients:{}".format(self.name)
 
@@ -166,12 +174,13 @@ class Account(Enum):
         Access to the user's account, including billing information, tokens
         management, user management, etc.
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "account:*"
             return "account:{}".format(self.name)
 
@@ -179,12 +188,13 @@ class Events(Enum):
         """
         Access to a user's Events
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "events:*"
             return "events:{}".format(self.name)
 
@@ -192,12 +202,13 @@ class Volumes(Enum):
         """
         Access to Block Storage Volumes
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "volumes:*"
             return "volumes:{}".format(self.name)
 
@@ -205,12 +216,13 @@ class LKE(Enum):
         """
         Access to LKE Endpoint
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "lke:*"
             return "lke:{}".format(self.name)
 
@@ -218,12 +230,13 @@ class ObjectStorage(Enum):
         """
         Access to Object Storage
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "object_storage:*"
             return "object_storage:{}".format(self.name)
 
@@ -231,32 +244,33 @@ class Longview(Enum):
         """
         Access to Longview
         """
+
         read_only = 0
         read_write = 1
         all = 2
 
         def __repr__(self):
-            if(self.name == 'all'):
+            if self.name == "all":
                 return "longview:*"
             return "longview:{}".format(self.name)
 
     _scope_families = {
-        'linodes': Linodes,
-        'domains': Domains,
-        'stackscripts': StackScripts,
-        'users': Users,
-        'tokens': Tokens,
-        'ips': IPs,
-        'firewall': Firewalls,
-        'tickets': Tickets,
-        'clients': Clients,
-        'account': Account,
-        'events': Events,
-        'volumes': Volumes,
-        'lke': LKE,
-        'object_storage': ObjectStorage,
-        'nodebalancers': NodeBalancers,
-        'longview': Longview,
+        "linodes": Linodes,
+        "domains": Domains,
+        "stackscripts": StackScripts,
+        "users": Users,
+        "tokens": Tokens,
+        "ips": IPs,
+        "firewall": Firewalls,
+        "tickets": Tickets,
+        "clients": Clients,
+        "account": Account,
+        "events": Events,
+        "volumes": Volumes,
+        "lke": LKE,
+        "object_storage": ObjectStorage,
+        "nodebalancers": NodeBalancers,
+        "longview": Longview,
     }
 
     @staticmethod
@@ -264,17 +278,19 @@ def parse(scopes):
         ret = []
 
         # special all-scope case
-        if scopes == '*':
-            return [ getattr(OAuthScopes._scope_families[s], 'all')
-                    for s in OAuthScopes._scope_families ] # pylint: disable=consider-using-dict-items
+        if scopes == "*":
+            return [
+                getattr(scope, "all")
+                for scope in OAuthScopes._scope_families.values()
+            ]
 
-        for scope in scopes.split(','):
+        for scope in re.split("[, ]", scopes):
             resource = access = None
-            if ':' in scope:
-                resource, access = scope.split(':')
+            if ":" in scope:
+                resource, access = scope.split(":")
             else:
                 resource = scope
-                access = '*'
+                access = "*"
 
             parsed_scope = OAuthScopes._get_parsed_scope(resource, access)
             if parsed_scope:
@@ -287,8 +303,8 @@ def _get_parsed_scope(resource, access):
         resource = resource.lower()
         access = access.lower()
         if resource in OAuthScopes._scope_families:
-            if access == '*':
-                access = 'delete'
+            if access == "*":
+                access = "all"
             if hasattr(OAuthScopes._scope_families[resource], access):
                 return getattr(OAuthScopes._scope_families[resource], access)
 
@@ -296,18 +312,20 @@ def _get_parsed_scope(resource, access):
 
     @staticmethod
     def serialize(scopes):
-        ret = ''
+        ret = ""
         if not type(scopes) is list:
-            scopes = [ scopes ]
+            scopes = [scopes]
         for scope in scopes:
             ret += "{},".format(repr(scope))
         if ret:
             ret = ret[:-1]
         return ret
 
+
 class LinodeLoginClient:
-    def __init__(self, client_id, client_secret,
-                 base_url="https://login.linode.com"):
+    def __init__(
+        self, client_id, client_secret, base_url="https://login.linode.com"
+    ):
         """
         Create a new LinodeLoginClient.  These clients do not make any requests
         on creation, and can safely be created and thrown away as needed.
@@ -356,7 +374,7 @@ def begin_oauth_login():
         split = list(urlparse(url))
         params = {
             "client_id": self.client_id,
-            "response_type": "code", # needed for all logins
+            "response_type": "code",  # needed for all logins
         }
         if scopes:
             params["scopes"] = OAuthScopes.serialize(scopes)
@@ -398,19 +416,26 @@ def oauth_redirect():
 
         :raise ApiError: If the OAuth exchange fails.
         """
-        r = requests.post(self._login_uri("/oauth/token"), data={
+        r = requests.post(
+            self._login_uri("/oauth/token"),
+            data={
                 "code": code,
                 "client_id": self.client_id,
-                "client_secret": self.client_secret
-            })
+                "client_secret": self.client_secret,
+            },
+        )
 
         if r.status_code != 200:
-            raise ApiError("OAuth token exchange failed", status=r.status_code, json=r.json())
+            raise ApiError(
+                "OAuth token exchange failed",
+                status=r.status_code,
+                json=r.json(),
+            )
 
         token = r.json()["access_token"]
         scopes = OAuthScopes.parse(r.json()["scopes"])
-        expiry = datetime.now() + timedelta(seconds=r.json()['expires_in'])
-        refresh_token = r.json()['refresh_token']
+        expiry = datetime.now() + timedelta(seconds=r.json()["expires_in"])
+        refresh_token = r.json()["refresh_token"]
 
         return token, scopes, expiry, refresh_token
 
@@ -434,20 +459,23 @@ def refresh_oauth_token(self, refresh_token):
 
         :raise ApiError: If the refresh fails..
         """
-        r = requests.post(self._login_uri("/oauth/token"), data={
-            "grant_type": "refresh_token",
-            "client_id": self.client_id,
-            "client_secret": self.client_secret,
-            "refresh_token": refresh_token,
-        })
+        r = requests.post(
+            self._login_uri("/oauth/token"),
+            data={
+                "grant_type": "refresh_token",
+                "client_id": self.client_id,
+                "client_secret": self.client_secret,
+                "refresh_token": refresh_token,
+            },
+        )
 
         if r.status_code != 200:
             raise ApiError("Refresh failed", r)
 
         token = r.json()["access_token"]
         scopes = OAuthScopes.parse(r.json()["scopes"])
-        expiry = datetime.now() + timedelta(seconds=r.json()['expires_in'])
-        refresh_token = r.json()['refresh_token']
+        expiry = datetime.now() + timedelta(seconds=r.json()["expires_in"])
+        refresh_token = r.json()["refresh_token"]
 
         return token, scopes, expiry, refresh_token
 
@@ -466,12 +494,14 @@ def expire_token(self, token):
 
         :raises ApiError: If the expiration attempt failed.
         """
-        r = requests.post(self._login_uri("/oauth/token/expire"),
+        r = requests.post(
+            self._login_uri("/oauth/token/expire"),
             data={
                 "client_id": self.client_id,
                 "client_secret": self.client_secret,
                 "token": token,
-            })
+            },
+        )
 
         if r.status_code != 200:
             raise ApiError("Failed to expire token!", r)
diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py
index e50853fac..256e0193d 100644
--- a/linode_api4/objects/__init__.py
+++ b/linode_api4/objects/__init__.py
@@ -1,4 +1,5 @@
-from .base import Base, Property, MappedObject, DATE_FORMAT
+# isort: skip_file
+from .base import Base, Property, MappedObject, DATE_FORMAT, ExplicitNullValue
 from .dbase import DerivedBase
 from .filtering import and_, or_
 from .region import Region
@@ -13,6 +14,6 @@
 from .profile import *
 from .longview import *
 from .tag import Tag
-from .object_storage import ObjectStorageCluster, ObjectStorageKeys
+from .object_storage import *
 from .lke import *
 from .database import *
diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py
index 126f0835d..4db05e802 100644
--- a/linode_api4/objects/account.py
+++ b/linode_api4/objects/account.py
@@ -1,16 +1,33 @@
 from datetime import datetime
+
 import requests
 
 from linode_api4.errors import ApiError, UnexpectedResponseError
-from linode_api4.objects import (Base, DerivedBase, Domain, Image, Instance,
-                                 Property, StackScript, Volume, DATE_FORMAT)
+from linode_api4.objects import (
+    DATE_FORMAT,
+    Base,
+    DerivedBase,
+    Domain,
+    Image,
+    Instance,
+    Property,
+    StackScript,
+    Volume,
+)
 from linode_api4.objects.longview import LongviewClient, LongviewSubscription
 from linode_api4.objects.nodebalancer import NodeBalancer
 from linode_api4.objects.support import SupportTicket
 
+
 class Account(Base):
+    """
+    The contact and billing information related to your Account.
+
+    API Documentation: https://www.linode.com/docs/api/account/#account-view
+    """
+
     api_endpoint = "/account"
-    id_attribute = 'email'
+    id_attribute = "email"
 
     properties = {
         "company": Property(mutable=True),
@@ -27,96 +44,273 @@ class Account(Base):
         "address_2": Property(mutable=True),
         "tax_id": Property(mutable=True),
         "capabilities": Property(),
-        'credit_card': Property(),
+        "credit_card": Property(),
+        "active_promotions": Property(),
+        "active_since": Property(),
+        "balance_uninvoiced": Property(),
+        "billing_source": Property(),
+        "euuid": Property(),
+    }
+
+
+class ServiceTransfer(Base):
+    """
+    A transfer request for transferring a service between Linode accounts.
+
+    API Documentation: https://www.linode.com/docs/api/account/#service-transfer-view
+    """
+
+    api_endpoint = "/account/service-transfers/{token}"
+    id_attribute = "token"
+    properties = {
+        "token": Property(identifier=True),
+        "created": Property(is_datetime=True),
+        "updated": Property(is_datetime=True),
+        "is_sender": Property(),
+        "expiry": Property(),
+        "status": Property(),
+        "entities": Property(),
+    }
+
+    def service_transfer_accept(self):
+        """
+        Accept a Service Transfer for the provided token to receive the services included in the transfer to your account.
+
+        API Documentation: https://www.linode.com/docs/api/account/#service-transfer-accept
+        """
+
+        resp = self._client.post(
+            "{}/accept".format(self.api_endpoint),
+            model=self,
+        )
+
+        if "errors" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when accepting service transfer!",
+                json=resp,
+            )
+
+
+class PaymentMethod(Base):
+    """
+    A payment method to be used on this Linode account.
+
+    API Documentation: https://www.linode.com/docs/api/account/#payment-method-view
+    """
+
+    api_endpoint = "/account/payment-methods/{id}"
+    properties = {
+        "id": Property(identifier=True),
+        "created": Property(is_datetime=True),
+        "is_default": Property(),
+        "type": Property(),
+        "data": Property(),
+    }
+
+    def payment_method_make_default(self):
+        """
+        Make this Payment Method the default method for automatically processing payments.
+
+        API Documentation: https://www.linode.com/docs/api/account/#payment-method-make-default
+        """
+
+        resp = self._client.post(
+            "{}/make-default".format(self.api_endpoint),
+            model=self,
+        )
+
+        if "errors" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when making payment method default!",
+                json=resp,
+            )
+
+
+class Login(Base):
+    """
+    A login entry for this account.
+
+    API Documentation: https://www.linode.com/docs/api/account/#login-view
+    """
+
+    api_endpoint = "/account/logins/{id}"
+    properties = {
+        "id": Property(identifier=True),
+        "datetime": Property(is_datetime=True),
+        "ip": Property(),
+        "restricted": Property(),
+        "status": Property(),
+        "username": Property(),
     }
 
 
 class AccountSettings(Base):
+    """
+    Information related to your Account settings.
+
+    API Documentation: https://www.linode.com/docs/api/account/#account-settings-view
+    """
+
     api_endpoint = "/account/settings"
-    id_attribute = 'managed' # this isn't actually used
+    id_attribute = "managed"  # this isn't actually used
 
     properties = {
         "network_helper": Property(mutable=True),
         "managed": Property(),
-        "longview_subscription": Property(slug_relationship=LongviewSubscription),
+        "longview_subscription": Property(
+            slug_relationship=LongviewSubscription
+        ),
         "object_storage": Property(),
+        "backups_enabled": Property(mutable=True),
     }
 
 
 class Event(Base):
-    api_endpoint = '/account/events/{id}'
+    """
+    An event object representing an event that took place on this account.
+
+    API Documentation: https://www.linode.com/docs/api/account/#event-view
+    """
+
+    api_endpoint = "/account/events/{id}"
     properties = {
-        'id': Property(identifier=True, filterable=True),
-        'percent_complete': Property(volatile=True),
-        'created': Property(is_datetime=True, filterable=True),
-        'updated': Property(is_datetime=True, filterable=True),
-        'seen': Property(),
-        'read': Property(),
-        'action': Property(),
-        'user_id': Property(),
-        'username': Property(),
-        'entity': Property(),
-        'time_remaining': Property(),
-        'rate': Property(),
-        'status': Property(),
+        "id": Property(identifier=True),
+        "percent_complete": Property(volatile=True),
+        "created": Property(is_datetime=True),
+        "updated": Property(is_datetime=True),
+        "seen": Property(),
+        "read": Property(),
+        "action": Property(),
+        "user_id": Property(),
+        "username": Property(),
+        "entity": Property(),
+        "time_remaining": Property(),
+        "rate": Property(),
+        "status": Property(),
+        "duration": Property(),
+        "secondary_entity": Property(),
+        "message": Property(),
     }
 
     @property
     def linode(self):
-        if self.entity and self.entity.type == 'linode':
+        """
+        Returns the Linode Instance referenced by this event.
+
+        :returns: The Linode Instance referenced by this event.
+        :rtype: Optional[Instance]
+        """
+
+        if self.entity and self.entity.type == "linode":
             return Instance(self._client, self.entity.id)
         return None
 
     @property
     def stackscript(self):
-        if self.entity and self.entity.type == 'stackscript':
+        """
+        Returns the Linode StackScript referenced by this event.
+
+        :returns: The Linode StackScript referenced by this event.
+        :rtype: Optional[StackScript]
+        """
+
+        if self.entity and self.entity.type == "stackscript":
             return StackScript(self._client, self.entity.id)
         return None
 
     @property
     def domain(self):
-        if self.entity and self.entity.type == 'domain':
+        """
+        Returns the Linode Domain referenced by this event.
+
+        :returns: The Linode Domain referenced by this event.
+        :rtype: Optional[Domain]
+        """
+
+        if self.entity and self.entity.type == "domain":
             return Domain(self._client, self.entity.id)
         return None
 
     @property
     def nodebalancer(self):
-        if self.entity and self.entity.type == 'nodebalancer':
+        """
+        Returns the Linode NodeBalancer referenced by this event.
+
+        :returns: The Linode NodeBalancer referenced by this event.
+        :rtype: Optional[NodeBalancer]
+        """
+
+        if self.entity and self.entity.type == "nodebalancer":
             return NodeBalancer(self._client, self.entity.id)
         return None
 
     @property
     def ticket(self):
-        if self.entity and self.entity.type == 'ticket':
+        """
+        Returns the Linode Support Ticket referenced by this event.
+
+        :returns: The Linode Support Ticket referenced by this event.
+        :rtype: Optional[SupportTicket]
+        """
+
+        if self.entity and self.entity.type == "ticket":
             return SupportTicket(self._client, self.entity.id)
         return None
 
     @property
     def volume(self):
-        if self.entity and self.entity.type == 'volume':
+        """
+        Returns the Linode Volume referenced by this event.
+
+        :returns: The Linode Volume referenced by this event.
+        :rtype: Optional[Volume]
+        """
+
+        if self.entity and self.entity.type == "volume":
             return Volume(self._client, self.entity.id)
         return None
 
     def mark_read(self):
-        self._client.post('{}/read'.format(Event.api_endpoint), model=self)
+        """
+        Marks a single Event as read.
+
+        API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-read
+        """
+
+        self._client.post("{}/read".format(Event.api_endpoint), model=self)
+
+    def mark_seen(self):
+        """
+        Marks a single Event as seen.
+
+        API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen
+        """
+
+        self._client.post("{}/seen".format(Event.api_endpoint), model=self)
 
 
 class InvoiceItem(DerivedBase):
-    api_endpoint = '/account/invoices/{invoice_id}/items'
-    derived_url_path = 'items'
-    parent_id_name='invoice_id'
-    id_attribute = 'label' # this has to be something
+    """
+    An individual invoice item under an :any:`Invoice` object.
+
+    API Documentation: https://www.linode.com/docs/api/account/#invoice-items-list
+    """
+
+    api_endpoint = "/account/invoices/{invoice_id}/items"
+    derived_url_path = "items"
+    parent_id_name = "invoice_id"
+    id_attribute = "label"  # this has to be something
 
     properties = {
-        'invoice_id': Property(identifier=True),
-        'unit_price': Property(),
-        'label': Property(),
-        'amount': Property(),
-        'quantity': Property(),
+        "invoice_id": Property(identifier=True),
+        "unit_price": Property(),
+        "label": Property(),
+        "amount": Property(),
+        "quantity": Property(),
         #'from_date': Property(is_datetime=True), this is populated below from the "from" attribute
-        'to': Property(is_datetime=True),
+        "to": Property(is_datetime=True),
         #'to_date': Property(is_datetime=True), this is populated below from the "to" attribute
-        'type': Property(),
+        "type": Property(),
     }
 
     def _populate(self, json):
@@ -126,11 +320,17 @@ def _populate(self, json):
         """
         super()._populate(json)
 
-        self.from_date = datetime.strptime(json['from'], DATE_FORMAT)
-        self.to_date = datetime.strptime(json['to'], DATE_FORMAT)
+        self.from_date = datetime.strptime(json["from"], DATE_FORMAT)
+        self.to_date = datetime.strptime(json["to"], DATE_FORMAT)
 
 
 class Invoice(Base):
+    """
+    A single invoice on this Linode account.
+
+    API Documentation: https://www.linode.com/docs/api/account/#invoice-view
+    """
+
     api_endpoint = "/account/invoices/{id}"
 
     properties = {
@@ -139,29 +339,45 @@ class Invoice(Base):
         "date": Property(is_datetime=True),
         "total": Property(),
         "items": Property(derived_class=InvoiceItem),
+        "tax": Property(),
+        "tax_summary": Property(),
+        "subtotal": Property(),
     }
 
 
 class OAuthClient(Base):
+    """
+    An OAuthClient object that can be used to authenticate apps with this account.
+
+    API Documentation: https://www.linode.com/docs/api/account/#oauth-client-view
+    """
+
     api_endpoint = "/account/oauth-clients/{id}"
 
     properties = {
         "id": Property(identifier=True),
-        "label": Property(mutable=True, filterable=True),
+        "label": Property(mutable=True),
         "secret": Property(),
         "redirect_uri": Property(mutable=True),
         "status": Property(),
-        "public": Property(),
+        "public": Property(mutable=True),
+        "thumbnail_url": Property(),
     }
 
     def reset_secret(self):
         """
         Resets the client secret for this client.
+
+        API Documentation: https://www.linode.com/docs/api/account/#oauth-client-secret-reset
         """
-        result = self._client.post("{}/reset_secret".format(OAuthClient.api_endpoint), model=self)
+        result = self._client.post(
+            "{}/reset_secret".format(OAuthClient.api_endpoint), model=self
+        )
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when resetting secret!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when resetting secret!", json=result
+            )
 
         self._populate(result)
         return self.secret
@@ -171,20 +387,26 @@ def thumbnail(self, dump_to=None):
         This returns binary data that represents a 128x128 image.
         If dump_to is given, attempts to write the image to a file
         at the given location.
+
+        API Documentation: https://www.linode.com/docs/api/account/#oauth-client-thumbnail-view
         """
-        headers = {
-            "Authorization": "token {}".format(self._client.token)
-        }
+        headers = {"Authorization": "token {}".format(self._client.token)}
 
-        result = requests.get('{}/{}/thumbnail'.format(self._client.base_url,
-                OAuthClient.api_endpoint.format(id=self.id)),
-                headers=headers)
+        result = requests.get(
+            "{}/{}/thumbnail".format(
+                self._client.base_url,
+                OAuthClient.api_endpoint.format(id=self.id),
+            ),
+            headers=headers,
+        )
 
         if not result.status_code == 200:
-            raise ApiError('No thumbnail found for OAuthClient {}'.format(self.id))
+            raise ApiError(
+                "No thumbnail found for OAuthClient {}".format(self.id)
+            )
 
         if dump_to:
-            with open(dump_to, 'wb+') as f:
+            with open(dump_to, "wb+") as f:
                 f.write(result.content)
         return result.content
 
@@ -193,6 +415,8 @@ def set_thumbnail(self, thumbnail):
         Sets the thumbnail for this OAuth Client.  If thumbnail is bytes,
         uploads it as a png.  Otherwise, assumes thumbnail is a path to the
         thumbnail and reads it in as bytes before uploading.
+
+        API Documentation: https://www.linode.com/docs/api/account/#oauth-client-thumbnail-update
         """
         headers = {
             "Authorization": "token {}".format(self._client.token),
@@ -201,24 +425,35 @@ def set_thumbnail(self, thumbnail):
 
         # TODO this check needs to be smarter - python2 doesn't do it right
         if not isinstance(thumbnail, bytes):
-            with open(thumbnail, 'rb') as f:
+            with open(thumbnail, "rb") as f:
                 thumbnail = f.read()
 
-        result = requests.put('{}/{}/thumbnail'.format(self._client.base_url,
-                OAuthClient.api_endpoint.format(id=self.id)),
-                headers=headers, data=thumbnail)
+        result = requests.put(
+            "{}/{}/thumbnail".format(
+                self._client.base_url,
+                OAuthClient.api_endpoint.format(id=self.id),
+            ),
+            headers=headers,
+            data=thumbnail,
+        )
 
         if not result.status_code == 200:
             errors = []
             j = result.json()
-            if 'errors' in j:
-                errors = [ e['reason'] for e in j['errors'] ]
-            raise ApiError('{}: {}'.format(result.status_code, errors), json=j)
+            if "errors" in j:
+                errors = [e["reason"] for e in j["errors"]]
+            raise ApiError("{}: {}".format(result.status_code, errors), json=j)
 
         return True
 
 
 class Payment(Base):
+    """
+    An object representing a single payment on the current Linode Account.
+
+    API Documentation: https://www.linode.com/docs/api/account/#payment-view
+    """
+
     api_endpoint = "/account/payments/{id}"
 
     properties = {
@@ -229,13 +464,21 @@ class Payment(Base):
 
 
 class User(Base):
+    """
+    An object representing a single user on this account.
+
+    API Documentation: https://www.linode.com/docs/api/account/#user-view
+    """
+
     api_endpoint = "/account/users/{id}"
-    id_attribute = 'username'
+    id_attribute = "username"
 
     properties = {
-        'email': Property(),
-        'username': Property(identifier=True, mutable=True),
-        'restricted': Property(mutable=True),
+        "email": Property(),
+        "username": Property(identifier=True, mutable=True),
+        "restricted": Property(mutable=True),
+        "ssh_keys": Property(),
+        "tfa_enabled": Property(),
     }
 
     @property
@@ -245,20 +488,27 @@ def grants(self):
         will result in an ApiError.  This is smart, and will only fetch from the
         api once unless the object is invalidated.
 
+        API Documentation: https://www.linode.com/docs/api/account/#users-grants-view
+
         :returns: The grants for this user.
         :rtype: linode.objects.account.UserGrants
         """
-        from linode_api4.objects.account import UserGrants # pylint: disable-all
-        if not hasattr(self, '_grants'):
-            resp = self._client.get(UserGrants.api_endpoint.format(username=self.username))
+        from linode_api4.objects.account import (  # pylint: disable-all
+            UserGrants,
+        )
+
+        if not hasattr(self, "_grants"):
+            resp = self._client.get(
+                UserGrants.api_endpoint.format(username=self.username)
+            )
 
             grants = UserGrants(self._client, self.username, resp)
-            self._set('_grants', grants)
+            self._set("_grants", grants)
 
         return self._grants
 
     def invalidate(self):
-        if hasattr(self, '_grants'):
+        if hasattr(self, "_grants"):
             del self._grants
         Base.invalidate(self)
 
@@ -267,13 +517,22 @@ def get_obj_grants():
     """
     Returns Grant keys mapped to Object types.
     """
-    return (('linode', Instance),
-            ('domain', Domain),
-            ('stackscript', StackScript),
-            ('nodebalancer', NodeBalancer),
-            ('volume', Volume),
-            ('image', Image),
-            ('longview', LongviewClient))
+    from linode_api4.objects import (  # pylint: disable=import-outside-toplevel
+        Database,
+        Firewall,
+    )
+
+    return (
+        ("linode", Instance),
+        ("domain", Domain),
+        ("stackscript", StackScript),
+        ("nodebalancer", NodeBalancer),
+        ("volume", Volume),
+        ("image", Image),
+        ("longview", LongviewClient),
+        ("database", Database),
+        ("firewall", Firewall),
+    )
 
 
 class Grant:
@@ -285,12 +544,13 @@ class Grant:
     Grants cannot be accessed or updated individually, and are only relevant in
     the context of a UserGrants object.
     """
+
     def __init__(self, client, cls, dct):
         self._client = client
         self.cls = cls
-        self.id = dct['id']
-        self.label = dct['label']
-        self.permissions = dct['permissions']
+        self.id = dct["id"]
+        self.label = dct["label"]
+        self.permissions = dct["permissions"]
 
     @property
     def entity(self):
@@ -304,7 +564,9 @@ def entity(self):
         """
         # there are no grants for derived types, so this shouldn't happen
         if not issubclass(self.cls, Base) or issubclass(self.cls, DerivedBase):
-            raise ValueError("Cannot get entity for non-base-class {}".format(self.cls))
+            raise ValueError(
+                "Cannot get entity for non-base-class {}".format(self.cls)
+            )
         return self.cls(self._client, self.id)
 
     def _serialize(self):
@@ -312,10 +574,7 @@ def _serialize(self):
         Returns this grant in as JSON the api will accept.  This is only relevant
         in the context of UserGrants.save
         """
-        return {
-            'permissions': self.permissions,
-            'id': self.id
-        }
+        return {"permissions": self.permissions, "id": self.id}
 
 
 class UserGrants:
@@ -327,9 +586,12 @@ class UserGrants:
     This is not an instance of Base because it lacks most of the attributes of
     a Base-like model (such as a unique, ID-based endpoint at which to access
     it), however it has some similarities so that its usage is familiar.
+
+    API Documentation: https://www.linode.com/docs/api/account/#users-grants-view
     """
+
     api_endpoint = "/account/users/{username}/grants"
-    parent_id_name = 'username'
+    parent_id_name = "username"
 
     def __init__(self, client, username, json=None):
         self._client = client
@@ -337,9 +599,9 @@ def __init__(self, client, username, json=None):
 
         if json is not None:
             self._populate(json)
-    
+
     def _populate(self, json):
-        self.global_grants = type('global_grants', (object,), json['global'])
+        self.global_grants = type("global_grants", (object,), json["global"])
 
         for key, cls in get_obj_grants():
             lst = []
@@ -348,8 +610,18 @@ def _populate(self, json):
             setattr(self, key, lst)
 
     def save(self):
+        """
+        Applies the grants to the parent user.
+
+        API Documentation: https://www.linode.com/docs/api/account/#users-grants-update
+        """
+
         req = {
-            'global': {k: v for k, v in vars(self.global_grants).items() if not k.startswith('_')},
+            "global": {
+                k: v
+                for k, v in vars(self.global_grants).items()
+                if not k.startswith("_")
+            },
         }
 
         for key, _ in get_obj_grants():
@@ -358,7 +630,9 @@ def save(self):
                 lst.append(cg._serialize())
             req[key] = lst
 
-        result = self._client.put(UserGrants.api_endpoint.format(username=self.username), data=req)
+        result = self._client.put(
+            UserGrants.api_endpoint.format(username=self.username), data=req
+        )
 
         self._populate(result)
 
diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py
index 5d1072cf4..26859087c 100644
--- a/linode_api4/objects/base.py
+++ b/linode_api4/objects/base.py
@@ -3,17 +3,34 @@
 
 from .filtering import FilterableMetaclass
 
-
 DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
 
 
 # The interval to reload volatile properties
 volatile_refresh_timeout = timedelta(seconds=15)
 
+
+class ExplicitNullValue:
+    """
+    An explicitly null value to set a property to.
+    Instances of `NullValue` differ from None as they will be explicitly
+    included in the resource PUT requests.
+    """
+
+
 class Property:
-    def __init__(self, mutable=False, identifier=False, volatile=False, relationship=None,
-            derived_class=None, is_datetime=False, filterable=False, id_relationship=False,
-            slug_relationship=False):
+    def __init__(
+        self,
+        mutable=False,
+        identifier=False,
+        volatile=False,
+        relationship=None,
+        derived_class=None,
+        is_datetime=False,
+        id_relationship=False,
+        slug_relationship=False,
+        nullable=False,
+    ):
         """
         A Property is an attribute returned from the API, and defines metadata
         about that value.  These are expected to be used as the values of a
@@ -26,10 +43,10 @@ def __init__(self, mutable=False, identifier=False, volatile=False, relationship
         relationship - The API Object this Property represents
         derived_class - The sub-collection type this Property represents
         is_datetime - True if this Property should be parsed as a datetime.datetime
-        filterable - True if the API allows filtering on this property
         id_relationship - This Property should create a relationship with this key as the ID
             (This should be used on fields ending with '_id' only)
         slug_relationship - This property is a slug related for a given type.
+        nullable - This property can be explicitly null on PUT requests.
         """
         self.mutable = mutable
         self.identifier = identifier
@@ -37,10 +54,10 @@ def __init__(self, mutable=False, identifier=False, volatile=False, relationship
         self.relationship = relationship
         self.derived_class = derived_class
         self.is_datetime = is_datetime
-        self.filterable = filterable
         self.id_relationship = id_relationship
         self.slug_relationship = slug_relationship
 
+
 class MappedObject:
     """
     Converts a dict into values accessible with the dot notation.
@@ -53,6 +70,7 @@ class MappedObject:
 
     object.this # "that"
     """
+
     def __init__(self, **vals):
         self._expand_vals(self.__dict__, **vals)
 
@@ -62,39 +80,44 @@ def _expand_vals(self, target, **vals):
                 vals[v] = MappedObject(**vals[v])
             elif type(vals[v]) is list:
                 # oh mama
-                vals[v] = [ MappedObject(**i) if type(i) is dict else i for i in vals[v] ]
+                vals[v] = [
+                    MappedObject(**i) if type(i) is dict else i for i in vals[v]
+                ]
         target.update(vals)
 
     def __repr__(self):
         return "Mapping containing {}".format(vars(self).keys())
-    
+
     @property
     def dict(self):
         return dict(self.__dict__)
 
+
 class Base(object, metaclass=FilterableMetaclass):
     """
     The Base class knows how to look up api properties of a model, and lazy-load them.
     """
+
     properties = {}
 
     def __init__(self, client, id, json={}):
-        self._set('_populated', False)
-        self._set('_last_updated', datetime.min)
-        self._set('_client', client)
+        self._set("_populated", False)
+        self._set("_last_updated", datetime.min)
+        self._set("_client", client)
+        self._set("_changed", False)
 
         #: self._raw_json is a copy of the json received from the API on population,
         #: and cannot be relied upon to be current.  Local changes to mutable fields
         #: that have not been saved will not be present, and volatile fields will not
         #: be updated on access.
-        self._set('_raw_json', None)
+        self._set("_raw_json", None)
 
         for prop in type(self).properties:
             self._set(prop, None)
 
-        self._set('id', id)
-        if hasattr(type(self), 'id_attribute'):
-            self._set(getattr(type(self), 'id_attribute'), id)
+        self._set("id", id)
+        if hasattr(type(self), "id_attribute"):
+            self._set(getattr(type(self), "id_attribute"), id)
 
         self._populate(json)
 
@@ -106,30 +129,48 @@ def __getattribute__(self, name):
         if name in type(self).properties.keys():
             # We are accessing a Property
             if type(self).properties[name].identifier:
-                pass # don't load identifiers from the server, we have those
-            elif (object.__getattribute__(self, name) is None and not self._populated \
-                    or type(self).properties[name].derived_class) \
-                    or (type(self).properties[name].volatile \
-                    and object.__getattribute__(self, '_last_updated')
-                    + volatile_refresh_timeout < datetime.now()):
+                pass  # don't load identifiers from the server, we have those
+            elif (
+                object.__getattribute__(self, name) is None
+                and not self._populated
+                or type(self).properties[name].derived_class
+            ) or (
+                type(self).properties[name].volatile
+                and object.__getattribute__(self, "_last_updated")
+                + volatile_refresh_timeout
+                < datetime.now()
+            ):
                 # needs to be loaded from the server
                 if type(self).properties[name].derived_class:
-                    #load derived object(s)
-                    self._set(name, type(self).properties[name].derived_class
-                            ._api_get_derived(self, getattr(self, '_client')))
+                    # load derived object(s)
+                    self._set(
+                        name,
+                        type(self)
+                        .properties[name]
+                        .derived_class._api_get_derived(
+                            self, getattr(self, "_client")
+                        ),
+                    )
                 else:
                     self._api_get()
         elif "{}_id".format(name) in type(self).properties.keys():
             # possible id-based relationship
-            related_type = type(self).properties['{}_id'.format(name)].id_relationship
+            related_type = (
+                type(self).properties["{}_id".format(name)].id_relationship
+            )
             if related_type:
                 # no id, no related object
                 if not getattr(self, "{}_id".format(name)):
                     return None
                 # it is a relationship
-                relcache_name = '_{}_relcache'.format(name)
+                relcache_name = "_{}_relcache".format(name)
                 if not hasattr(self, relcache_name):
-                    self._set(relcache_name, related_type(self._client, getattr(self, '{}_id'.format(name))))
+                    self._set(
+                        relcache_name,
+                        related_type(
+                            self._client, getattr(self, "{}_id".format(name))
+                        ),
+                    )
                 return object.__getattribute__(self, relcache_name)
 
         return object.__getattribute__(self, name)
@@ -144,20 +185,40 @@ def __setattr__(self, name, value):
         """
         Enforces allowing editing of only Properties defined as mutable
         """
-        if name in type(self).properties.keys() and not type(self).properties[name].mutable:
-            raise AttributeError("'{}' is not a mutable field of '{}'"
-                .format(name, type(self).__name__))
+        if name in type(self).properties.keys():
+            if not type(self).properties[name].mutable:
+                raise AttributeError(
+                    "'{}' is not a mutable field of '{}'".format(
+                        name, type(self).__name__
+                    )
+                )
+
+            self._changed = True
+
         self._set(name, value)
 
-    def save(self):
+    def save(self, force=True) -> bool:
         """
-        Send this object's mutable values to the server in a PUT request
+        Send this object's mutable values to the server in a PUT request.
+
+        :param force: If true, this method will always send a PUT request regardless of
+                      whether the field has been explicitly updated. For optimization
+                      purposes, this field should be set to false for typical update
+                      operations. (Defaults to True)
+        :type force: bool
         """
-        resp = self._client.put(type(self).api_endpoint, model=self,
-            data=self._serialize())
+        if not force and not self._changed:
+            return False
 
-        if 'error' in resp:
+        resp = self._client.put(
+            type(self).api_endpoint, model=self, data=self._serialize()
+        )
+
+        if "error" in resp:
             return False
+
+        self._set("_changed", False)
+
         return True
 
     def delete(self):
@@ -166,7 +227,7 @@ def delete(self):
         """
         resp = self._client.delete(type(self).api_endpoint, model=self)
 
-        if 'error' in resp:
+        if "error" in resp:
             return False
         self.invalidate()
         return True
@@ -176,24 +237,47 @@ def invalidate(self):
         Invalidates all non-identifier Properties this object has locally,
         causing the next access to re-fetch them from the server
         """
-        for key in [k for k in type(self).properties.keys()
-                if not type(self).properties[k].identifier]:
+        for key in [
+            k
+            for k in type(self).properties.keys()
+            if not type(self).properties[k].identifier
+        ]:
             self._set(key, None)
 
-        self._set('_populated', False)
+        self._set("_populated", False)
 
     def _serialize(self):
         """
         A helper method to build a dict of all mutable Properties of
         this object
         """
-        result = { a: getattr(self, a) for a in type(self).properties
-            if type(self).properties[a].mutable }
 
+        result = {}
+
+        # Aggregate mutable values into a dict
+        for k, v in type(self).properties.items():
+            if not v.mutable:
+                continue
+
+            value = getattr(self, k)
+
+            if not value:
+                continue
+
+            # Let's allow explicit null values as both classes and instances
+            if (
+                isinstance(value, ExplicitNullValue)
+                or value == ExplicitNullValue
+            ):
+                value = None
+
+            result[k] = value
+
+        # Resolve the underlying IDs of results
         for k, v in result.items():
             if isinstance(v, Base):
                 result[k] = v.id
-            elif isinstance(v,MappedObject):
+            elif isinstance(v, MappedObject):
                 result[k] = v.dict
 
         return result
@@ -215,46 +299,62 @@ def _populate(self, json):
             return
 
         # hide the raw JSON away in case someone needs it
-        self._set('_raw_json', json)
+        self._set("_raw_json", json)
+        self._set("_updated", False)
 
         for key in json:
-            if key in (k for k in type(self).properties.keys()
-                    if not type(self).properties[k].identifier):
-                if type(self).properties[key].relationship \
-                    and not json[key] is None:
+            if key in (
+                k
+                for k in type(self).properties.keys()
+                if not type(self).properties[k].identifier
+            ):
+                if (
+                    type(self).properties[key].relationship
+                    and not json[key] is None
+                ):
                     if isinstance(json[key], list):
                         objs = []
                         for d in json[key]:
-                            if not 'id' in d:
+                            if not "id" in d:
                                 continue
                             new_class = type(self).properties[key].relationship
-                            obj = new_class.make_instance(d['id'],
-                                    getattr(self,'_client'))
+                            obj = new_class.make_instance(
+                                d["id"], getattr(self, "_client")
+                            )
                             if obj:
                                 obj._populate(d)
                             objs.append(obj)
                         self._set(key, objs)
                     else:
                         if isinstance(json[key], dict):
-                            related_id = json[key]['id']
+                            related_id = json[key]["id"]
                         else:
                             related_id = json[key]
                         new_class = type(self).properties[key].relationship
-                        obj = new_class.make_instance(related_id, getattr(self,'_client'))
+                        obj = new_class.make_instance(
+                            related_id, getattr(self, "_client")
+                        )
                         if obj and isinstance(json[key], dict):
                             obj._populate(json[key])
                         self._set(key, obj)
-                elif  type(self).properties[key].slug_relationship \
-                        and not json[key] is None:
+                elif (
+                    type(self).properties[key].slug_relationship
+                    and not json[key] is None
+                ):
                     # create an object of the expected type with the given slug
-                    self._set(key, type(self).properties[key].slug_relationship(self._client, json[key]))
+                    self._set(
+                        key,
+                        type(self)
+                        .properties[key]
+                        .slug_relationship(self._client, json[key]),
+                    )
                 elif type(json[key]) is dict:
                     self._set(key, MappedObject(**json[key]))
                 elif type(json[key]) is list:
                     # we're going to use MappedObject's behavior with lists to
                     # expand these, then grab the resulting value to set
                     mapping = MappedObject(_list=json[key])
-                    self._set(key, mapping._list) # pylint: disable=no-member
+                    self._set(key, mapping._list)  # pylint: disable=no-member
                 elif type(self).properties[key].is_datetime:
                     try:
                         t = time.strptime(json[key], DATE_FORMAT)
@@ -267,8 +367,8 @@ def _populate(self, json):
                 else:
                     self._set(key, json[key])
 
-        self._set('_populated', True)
-        self._set('_last_updated', datetime.now())
+        self._set("_populated", True)
+        self._set("_last_updated", datetime.now())
 
     def _set(self, name, value):
         """
@@ -283,7 +383,7 @@ def api_list(cls):
         Returns a URL that will produce a list of JSON objects
         of this class' type
         """
-        return '/'.join(cls.api_endpoint.split('/')[:-1])
+        return "/".join(cls.api_endpoint.split("/")[:-1])
 
     @staticmethod
     def make(id, client, cls, parent_id=None, json=None):
@@ -298,7 +398,7 @@ def make(id, client, cls, parent_id=None, json=None):
 
         :returns: An instance of cls with the given id
         """
-        from .dbase import DerivedBase # pylint: disable-all
+        from .dbase import DerivedBase  # pylint: disable-all
 
         if issubclass(cls, DerivedBase):
             return cls(client, id, parent_id, json)
diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py
index 04c7997aa..4b50fa12c 100644
--- a/linode_api4/objects/database.py
+++ b/linode_api4/objects/database.py
@@ -1,17 +1,23 @@
-from linode_api4.objects import Base, Property, MappedObject, DerivedBase
+from linode_api4.objects import Base, DerivedBase, MappedObject, Property
 
 
 class DatabaseType(Base):
-    api_endpoint = '/databases/types/{id}'
+    """
+    The type of a managed database.
+
+    API Documentation: https://www.linode.com/docs/api/databases/#managed-database-type-view
+    """
+
+    api_endpoint = "/databases/types/{id}"
 
     properties = {
-        'deprecated': Property(filterable=True),
-        'disk': Property(),
-        'engines': Property(),
-        'id': Property(identifier=True),
-        'label': Property(),
-        'memory': Property(),
-        'vcpus': Property(),
+        "deprecated": Property(),
+        "disk": Property(),
+        "engines": Property(),
+        "id": Property(identifier=True),
+        "label": Property(),
+        "memory": Property(),
+        "vcpus": Property(),
         # type_class is populated from the 'class' attribute of the returned JSON
     }
 
@@ -21,19 +27,29 @@ def _populate(self, json):
         """
         super()._populate(json)
 
-        if 'class' in json:
-            setattr(self, 'type_class', json['class'])
+        if "class" in json:
+            setattr(self, "type_class", json["class"])
         else:
-            setattr(self, 'type_class', None)
+            setattr(self, "type_class", None)
 
 
 class DatabaseEngine(Base):
-    api_endpoint = '/databases/engines/{id}'
+    """
+    A managed database engine. The following database engines are available on Linode’s platform:
+
+        - MySQL
+        - PostgreSQL
+        - MongoDB
+
+    API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engine-view
+    """
+
+    api_endpoint = "/databases/engines/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'engine': Property(filterable=True),
-        'version': Property(filterable=True),
+        "id": Property(identifier=True),
+        "engine": Property(),
+        "version": Property(),
     }
 
     def invalidate(self):
@@ -41,7 +57,7 @@ def invalidate(self):
         Clear out cached properties.
         """
 
-        for attr in ['_instance']:
+        for attr in ["_instance"]:
             if hasattr(self, attr):
                 delattr(self, attr)
 
@@ -56,109 +72,182 @@ class DatabaseBackup(DerivedBase):
     Use the appropriate subclasses for the corresponding database engine. (e.g. MySQLDatabaseBackup)
     """
 
-    api_endpoint = ''
-    derived_url_path = 'backups'
-    parent_id_name = 'database_id'
+    api_endpoint = ""
+    derived_url_path = "backups"
+    parent_id_name = "database_id"
 
     properties = {
-        'created': Property(is_datetime=True),
-        'id': Property(identifier=True),
-        'label': Property(),
-        'type': Property(),
+        "created": Property(is_datetime=True),
+        "id": Property(identifier=True),
+        "label": Property(),
+        "type": Property(),
     }
 
     def restore(self):
         """
         Restore a backup to a Managed Database on your Account.
+
+        API Documentation:
+
+            - MongoDB: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-restore
+            - MySQL: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-restore
+            - PostgreSQL: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-restore
         """
 
-        return self._client.post('{}/restore'.format(self.api_endpoint), model=self)
+        return self._client.post(
+            "{}/restore".format(self.api_endpoint), model=self
+        )
 
 
 class MySQLDatabaseBackup(DatabaseBackup):
-    api_endpoint = '/databases/mysql/instances/{database_id}/backups/{id}'
+    """
+    A backup for an accessible Managed MySQL Database.
+
+    API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-view
+    """
+
+    api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}"
 
 
 class MongoDBDatabaseBackup(DatabaseBackup):
-    api_endpoint = '/databases/mongodb/instances/{database_id}/backups/{id}'
+    """
+    A backup for an accessible Managed MongoDB Database.
+
+    API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-view
+    """
+
+    api_endpoint = "/databases/mongodb/instances/{database_id}/backups/{id}"
 
 
 class PostgreSQLDatabaseBackup(DatabaseBackup):
-    api_endpoint = '/databases/postgresql/instances/{database_id}/backups/{id}'
+    """
+    A backup for an accessible Managed PostgreSQL Database.
+
+    API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-view
+    """
+
+    api_endpoint = "/databases/postgresql/instances/{database_id}/backups/{id}"
 
 
 class MySQLDatabase(Base):
-    api_endpoint = '/databases/mysql/instances/{id}'
+    """
+    An accessible Managed MySQL Database.
+
+    API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-view
+    """
+
+    api_endpoint = "/databases/mysql/instances/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'label': Property(mutable=True, filterable=True),
-        'allow_list': Property(mutable=True),
-        'backups': Property(derived_class=MySQLDatabaseBackup),
-        'cluster_size': Property(),
-        'created': Property(is_datetime=True),
-        'encrypted': Property(),
-        'engine': Property(filterable=True),
-        'hosts': Property(),
-        'port': Property(),
-        'region': Property(filterable=True),
-        'replication_type': Property(),
-        'ssl_connection': Property(),
-        'status': Property(volatile=True, filterable=True),
-        'type': Property(filterable=True),
-        'updated': Property(volatile=True, is_datetime=True),
-        'updates': Property(mutable=True),
-        'version': Property(filterable=True),
+        "id": Property(identifier=True),
+        "label": Property(mutable=True),
+        "allow_list": Property(mutable=True),
+        "backups": Property(derived_class=MySQLDatabaseBackup),
+        "cluster_size": Property(),
+        "created": Property(is_datetime=True),
+        "encrypted": Property(),
+        "engine": Property(),
+        "hosts": Property(),
+        "port": Property(),
+        "region": Property(),
+        "replication_type": Property(),
+        "ssl_connection": Property(),
+        "status": Property(volatile=True),
+        "type": Property(),
+        "updated": Property(volatile=True, is_datetime=True),
+        "updates": Property(mutable=True),
+        "version": Property(),
     }
 
     @property
     def credentials(self):
-        if not hasattr(self, '_credentials'):
-            resp = self._client.get('{}/credentials'.format(MySQLDatabase.api_endpoint), model=self)
-            self._set('_credentials', MappedObject(**resp))
+        """
+        Display the root username and password for an accessible Managed MySQL Database.
+        The Database must have an active status to perform this command.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-credentials-view
+
+        :returns: MappedObject containing credntials for this DB
+        :rtype: MappedObject
+        """
+
+        if not hasattr(self, "_credentials"):
+            resp = self._client.get(
+                "{}/credentials".format(MySQLDatabase.api_endpoint), model=self
+            )
+            self._set("_credentials", MappedObject(**resp))
 
         return self._credentials
 
     @property
     def ssl(self):
-        if not hasattr(self, '_ssl'):
-            resp = self._client.get('{}/ssl'.format(MySQLDatabase.api_endpoint), model=self)
-            self._set('_ssl', MappedObject(**resp))
+        """
+        Display the SSL CA certificate for an accessible Managed MySQL Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-ssl-certificate-view
+
+        :returns: MappedObject containing SSL CA certificate for this DB
+        :rtype: MappedObject
+        """
+
+        if not hasattr(self, "_ssl"):
+            resp = self._client.get(
+                "{}/ssl".format(MySQLDatabase.api_endpoint), model=self
+            )
+            self._set("_ssl", MappedObject(**resp))
 
         return self._ssl
 
     def credentials_reset(self):
         """
         Reset the root password for a Managed MySQL Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-credentials-reset
+
+        :returns: Response from the API call to reset credentials
+        :rtype: dict
         """
 
         self.invalidate()
 
-        return self._client.post('{}/credentials/reset'.format(MySQLDatabase.api_endpoint), model=self)
+        return self._client.post(
+            "{}/credentials/reset".format(MySQLDatabase.api_endpoint),
+            model=self,
+        )
 
     def patch(self):
         """
         Apply security patches and updates to the underlying operating system of the Managed MySQL Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-patch
+
+        :returns: Response from the API call to apply security patches
+        :rtype: dict
         """
 
         self.invalidate()
 
-        return self._client.post('{}/patch'.format(MySQLDatabase.api_endpoint), model=self)
+        return self._client.post(
+            "{}/patch".format(MySQLDatabase.api_endpoint), model=self
+        )
 
     def backup_create(self, label, **kwargs):
         """
         Creates a snapshot backup of a Managed MySQL Database.
 
-        :param label: The name for this backup
-        :type label: str
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-snapshot-create
         """
 
         params = {
-            'label': label,
+            "label": label,
         }
         params.update(kwargs)
 
-        self._client.post('{}/backups'.format(MySQLDatabase.api_endpoint), model=self, data=params)
+        self._client.post(
+            "{}/backups".format(MySQLDatabase.api_endpoint),
+            model=self,
+            data=params,
+        )
         self.invalidate()
 
     def invalidate(self):
@@ -166,7 +255,7 @@ def invalidate(self):
         Clear out cached properties.
         """
 
-        for attr in ['_ssl', '_credentials']:
+        for attr in ["_ssl", "_credentials"]:
             if hasattr(self, attr):
                 delattr(self, attr)
 
@@ -174,75 +263,126 @@ def invalidate(self):
 
 
 class PostgreSQLDatabase(Base):
-    api_endpoint = '/databases/postgresql/instances/{id}'
+    """
+    An accessible Managed PostgreSQL Database.
+
+    API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-view
+    """
+
+    api_endpoint = "/databases/postgresql/instances/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'label': Property(mutable=True, filterable=True),
-        'allow_list': Property(mutable=True),
-        'backups': Property(derived_class=PostgreSQLDatabaseBackup),
-        'cluster_size': Property(),
-        'created': Property(is_datetime=True),
-        'encrypted': Property(),
-        'engine': Property(filterable=True),
-        'hosts': Property(),
-        'port': Property(),
-        'region': Property(filterable=True),
-        'replication_commit_type': Property(),
-        'replication_type': Property(),
-        'ssl_connection': Property(),
-        'status': Property(volatile=True, filterable=True),
-        'type': Property(filterable=True),
-        'updated': Property(volatile=True, is_datetime=True),
-        'updates': Property(mutable=True),
-        'version': Property(filterable=True),
+        "id": Property(identifier=True),
+        "label": Property(mutable=True),
+        "allow_list": Property(mutable=True),
+        "backups": Property(derived_class=PostgreSQLDatabaseBackup),
+        "cluster_size": Property(),
+        "created": Property(is_datetime=True),
+        "encrypted": Property(),
+        "engine": Property(),
+        "hosts": Property(),
+        "port": Property(),
+        "region": Property(),
+        "replication_commit_type": Property(),
+        "replication_type": Property(),
+        "ssl_connection": Property(),
+        "status": Property(volatile=True),
+        "type": Property(),
+        "updated": Property(volatile=True, is_datetime=True),
+        "updates": Property(mutable=True),
+        "version": Property(),
     }
 
     @property
     def credentials(self):
-        if not hasattr(self, '_credentials'):
-            resp = self._client.get('{}/credentials'.format(PostgreSQLDatabase.api_endpoint), model=self)
-            self._set('_credentials', MappedObject(**resp))
+        """
+        Display the root username and password for an accessible Managed PostgreSQL Database.
+        The Database must have an active status to perform this command.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-credentials-view
+
+        :returns: MappedObject containing credntials for this DB
+        :rtype: MappedObject
+        """
+
+        if not hasattr(self, "_credentials"):
+            resp = self._client.get(
+                "{}/credentials".format(PostgreSQLDatabase.api_endpoint),
+                model=self,
+            )
+            self._set("_credentials", MappedObject(**resp))
 
         return self._credentials
 
     @property
     def ssl(self):
-        if not hasattr(self, '_ssl'):
-            resp = self._client.get('{}/ssl'.format(PostgreSQLDatabase.api_endpoint), model=self)
-            self._set('_ssl', MappedObject(**resp))
+        """
+        Display the SSL CA certificate for an accessible Managed PostgreSQL Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-ssl-certificate-view
+
+        :returns: MappedObject containing SSL CA certificate for this DB
+        :rtype: MappedObject
+        """
+
+        if not hasattr(self, "_ssl"):
+            resp = self._client.get(
+                "{}/ssl".format(PostgreSQLDatabase.api_endpoint), model=self
+            )
+            self._set("_ssl", MappedObject(**resp))
 
         return self._ssl
 
     def credentials_reset(self):
         """
         Reset the root password for a Managed PostgreSQL Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-credentials-reset
+
+        :returns: Response from the API call to reset credentials
+        :rtype: dict
         """
 
         self.invalidate()
 
-        return self._client.post('{}/credentials/reset'.format(PostgreSQLDatabase.api_endpoint), model=self)
+        return self._client.post(
+            "{}/credentials/reset".format(PostgreSQLDatabase.api_endpoint),
+            model=self,
+        )
 
     def patch(self):
         """
         Apply security patches and updates to the underlying operating system of the Managed PostgreSQL Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-patch
+
+        :returns: Response from the API call to apply security patches
+        :rtype: dict
         """
 
         self.invalidate()
 
-        return self._client.post('{}/patch'.format(PostgreSQLDatabase.api_endpoint), model=self)
+        return self._client.post(
+            "{}/patch".format(PostgreSQLDatabase.api_endpoint), model=self
+        )
 
     def backup_create(self, label, **kwargs):
         """
         Creates a snapshot backup of a Managed PostgreSQL Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-snapshot-create
         """
 
         params = {
-            'label': label,
+            "label": label,
         }
         params.update(kwargs)
 
-        self._client.post('{}/backups'.format(PostgreSQLDatabase.api_endpoint), model=self, data=params)
+        self._client.post(
+            "{}/backups".format(PostgreSQLDatabase.api_endpoint),
+            model=self,
+            data=params,
+        )
         self.invalidate()
 
     def invalidate(self):
@@ -250,7 +390,7 @@ def invalidate(self):
         Clear out cached properties.
         """
 
-        for attr in ['_ssl', '_credentials']:
+        for attr in ["_ssl", "_credentials"]:
             if hasattr(self, attr):
                 delattr(self, attr)
 
@@ -258,77 +398,128 @@ def invalidate(self):
 
 
 class MongoDBDatabase(Base):
-    api_endpoint = '/databases/mongodb/instances/{id}'
+    """
+    An accessible Managed MongoDB Database.
+
+    API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-view
+    """
+
+    api_endpoint = "/databases/mongodb/instances/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'label': Property(mutable=True, filterable=True),
-        'allow_list': Property(mutable=True),
-        'backups': Property(derived_class=MongoDBDatabaseBackup),
-        'cluster_size': Property(),
-        'compression_type': Property(),
-        'created': Property(is_datetime=True),
-        'encrypted': Property(),
-        'engine': Property(filterable=True),
-        'hosts': Property(),
-        'peers': Property(),
-        'port': Property(),
-        'region': Property(filterable=True),
-        'replica_set': Property(),
-        'ssl_connection': Property(),
-        'status': Property(volatile=True, filterable=True),
-        'storage_engine': Property(),
-        'type': Property(filterable=True),
-        'updated': Property(volatile=True, is_datetime=True),
-        'updates': Property(mutable=True),
-        'version': Property(filterable=True),
+        "id": Property(identifier=True),
+        "label": Property(mutable=True),
+        "allow_list": Property(mutable=True),
+        "backups": Property(derived_class=MongoDBDatabaseBackup),
+        "cluster_size": Property(),
+        "compression_type": Property(),
+        "created": Property(is_datetime=True),
+        "encrypted": Property(),
+        "engine": Property(),
+        "hosts": Property(),
+        "peers": Property(),
+        "port": Property(),
+        "region": Property(),
+        "replica_set": Property(),
+        "ssl_connection": Property(),
+        "status": Property(volatile=True),
+        "storage_engine": Property(),
+        "type": Property(),
+        "updated": Property(volatile=True, is_datetime=True),
+        "updates": Property(mutable=True),
+        "version": Property(),
     }
 
     @property
     def credentials(self):
-        if not hasattr(self, '_credentials'):
-            resp = self._client.get('{}/credentials'.format(MongoDBDatabase.api_endpoint), model=self)
-            self._set('_credentials', MappedObject(**resp))
+        """
+        Display the root username and password for an accessible Managed MongoDB Database.
+        The Database must have an active status to perform this command.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-credentials-view
+
+        :returns: MappedObject containing credntials for this DB
+        :rtype: MappedObject
+        """
+
+        if not hasattr(self, "_credentials"):
+            resp = self._client.get(
+                "{}/credentials".format(MongoDBDatabase.api_endpoint),
+                model=self,
+            )
+            self._set("_credentials", MappedObject(**resp))
 
         return self._credentials
 
     @property
     def ssl(self):
-        if not hasattr(self, '_ssl'):
-            resp = self._client.get('{}/ssl'.format(MongoDBDatabase.api_endpoint), model=self)
-            self._set('_ssl', MappedObject(**resp))
+        """
+        Display the SSL CA certificate for an accessible Managed MongoDB Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-ssl-certificate-view
+
+        :returns: MappedObject containing SSL CA certificate for this DB
+        :rtype: MappedObject
+        """
+
+        if not hasattr(self, "_ssl"):
+            resp = self._client.get(
+                "{}/ssl".format(MongoDBDatabase.api_endpoint), model=self
+            )
+            self._set("_ssl", MappedObject(**resp))
 
         return self._ssl
 
     def credentials_reset(self):
         """
         Reset the root password for a Managed MongoDB Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-credentials-reset
+
+        :returns: Response from the API call to reset credentials
+        :rtype: dict
         """
 
         self.invalidate()
 
-        return self._client.post('{}/credentials/reset'.format(MongoDBDatabase.api_endpoint), model=self)
+        return self._client.post(
+            "{}/credentials/reset".format(MongoDBDatabase.api_endpoint),
+            model=self,
+        )
 
     def patch(self):
         """
         Apply security patches and updates to the underlying operating system of the Managed MongoDB Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-patch
+
+        :returns: Response from the API call to apply security patches
+        :rtype: dict
         """
 
         self.invalidate()
 
-        return self._client.post('{}/patch'.format(MongoDBDatabase.api_endpoint), model=self)
+        return self._client.post(
+            "{}/patch".format(MongoDBDatabase.api_endpoint), model=self
+        )
 
     def backup_create(self, label, **kwargs):
         """
         Creates a snapshot backup of a Managed MongoDB Database.
+
+        API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-snapshot-create
         """
 
         params = {
-            'label': label,
+            "label": label,
         }
         params.update(kwargs)
 
-        self._client.post('{}/backups'.format(MongoDBDatabase.api_endpoint), model=self, data=params)
+        self._client.post(
+            "{}/backups".format(MongoDBDatabase.api_endpoint),
+            model=self,
+            data=params,
+        )
         self.invalidate()
 
     def invalidate(self):
@@ -336,7 +527,7 @@ def invalidate(self):
         Clear out cached properties.
         """
 
-        for attr in ['_ssl', '_credentials']:
+        for attr in ["_ssl", "_credentials"]:
             if hasattr(self, attr):
                 delattr(self, attr)
 
@@ -344,35 +535,38 @@ def invalidate(self):
 
 
 ENGINE_TYPE_TRANSLATION = {
-    'mysql': MySQLDatabase,
-    'postgresql': PostgreSQLDatabase,
-    'mongodb': MongoDBDatabase,
+    "mysql": MySQLDatabase,
+    "postgresql": PostgreSQLDatabase,
+    "mongodb": MongoDBDatabase,
 }
 
 
 class Database(Base):
     """
     A generic Database instance.
+
+    Note: This class does not have a corresponding GET endpoint. For detailed information
+    about the database, use the .instance() property method instead.
     """
 
-    api_endpoint = '/databases/instances/{id}'
+    api_endpoint = "/databases/instances/{id}"
 
     properties = {
-        'id': Property(),
-        'label': Property(),
-        'allow_list': Property(),
-        'cluster_size': Property(),
-        'created': Property(),
-        'encrypted': Property(),
-        'engine': Property(),
-        'hosts': Property(),
-        'instance_uri': Property(),
-        'region': Property(),
-        'status': Property(),
-        'type': Property(),
-        'updated': Property(),
-        'updates': Property(),
-        'version': Property(),
+        "id": Property(),
+        "label": Property(),
+        "allow_list": Property(),
+        "cluster_size": Property(),
+        "created": Property(),
+        "encrypted": Property(),
+        "engine": Property(),
+        "hosts": Property(),
+        "instance_uri": Property(),
+        "region": Property(),
+        "status": Property(),
+        "type": Property(),
+        "updated": Property(),
+        "updates": Property(),
+        "version": Property(),
     }
 
     @property
@@ -391,10 +585,17 @@ def instance(self):
                 print(f"{db.hosts.primary}: {db.instance.credentials.username} {db.instance.credentials.password}")
         """
 
-        if not hasattr(self, '_instance'):
+        if not hasattr(self, "_instance"):
             if self.engine not in ENGINE_TYPE_TRANSLATION:
                 return None
 
-            self._set('_instance', ENGINE_TYPE_TRANSLATION[self.engine](self._client, self.id))
+            self._set(
+                "_instance",
+                ENGINE_TYPE_TRANSLATION[self.engine](self._client, self.id),
+            )
 
         return self._instance
+
+    # Since this class doesn't have a corresponding GET endpoint, this prevents an accidental call to the nonexisting endpoint.
+    def _api_get(self):
+        return
diff --git a/linode_api4/objects/dbase.py b/linode_api4/objects/dbase.py
index e4817ccf2..3bd5bb7df 100644
--- a/linode_api4/objects/dbase.py
+++ b/linode_api4/objects/dbase.py
@@ -7,8 +7,9 @@ class DerivedBase(Base):
     (for example, a disk belongs to a linode).  These objects have their own endpoints,
     but they are below another object in the hierarchy (i.e. /linodes/lnde_123/disks/disk_123)
     """
-    derived_url_path = '' #override in child classes
-    parent_id_name = 'parent_id' #override in child classes
+
+    derived_url_path = ""  # override in child classes
+    parent_id_name = "parent_id"  # override in child classes
 
     def __init__(self, client, id, parent_id, json={}):
         Base.__init__(self, client, id, json=json)
@@ -17,6 +18,10 @@ def __init__(self, client, id, parent_id, json={}):
 
     @classmethod
     def _api_get_derived(cls, parent, client):
-        base_url = "{}/{}".format(type(parent).api_endpoint, cls.derived_url_path)
-         
-        return client._get_objects(base_url, cls, model=parent, parent_id=parent.id)
+        base_url = "{}/{}".format(
+            type(parent).api_endpoint, cls.derived_url_path
+        )
+
+        return client._get_objects(
+            base_url, cls, model=parent, parent_id=parent.id
+        )
diff --git a/linode_api4/objects/domain.py b/linode_api4/objects/domain.py
index 01adb0a0f..82035b87e 100644
--- a/linode_api4/objects/domain.py
+++ b/linode_api4/objects/domain.py
@@ -3,58 +3,156 @@
 
 
 class DomainRecord(DerivedBase):
+    """
+    A single record on a Domain.
+
+    API Documentation: https://www.linode.com/docs/api/domains/#domain-record-view
+    """
+
     api_endpoint = "/domains/{domain_id}/records/{id}"
     derived_url_path = "records"
     parent_id_name = "domain_id"
 
     properties = {
-        'id': Property(identifier=True),
-        'domain_id': Property(identifier=True),
-        'type': Property(),
-        'name': Property(mutable=True, filterable=True),
-        'target': Property(mutable=True, filterable=True),
-        'priority': Property(mutable=True),
-        'weight': Property(mutable=True),
-        'port': Property(mutable=True),
-        'service': Property(mutable=True),
-        'protocol': Property(mutable=True),
-        'ttl_sec': Property(mutable=True),
-        'tag': Property(mutable=True),
+        "id": Property(identifier=True),
+        "domain_id": Property(identifier=True),
+        "type": Property(),
+        "name": Property(mutable=True),
+        "target": Property(mutable=True),
+        "priority": Property(mutable=True),
+        "weight": Property(mutable=True),
+        "port": Property(mutable=True),
+        "service": Property(mutable=True),
+        "protocol": Property(mutable=True),
+        "ttl_sec": Property(mutable=True),
+        "tag": Property(mutable=True),
+        "created": Property(),
+        "updated": Property(),
     }
 
 
 class Domain(Base):
+    """
+    A single Domain that you have registered in Linode’s DNS Manager.
+    Linode is not a registrar, and in order for this Domain record to work
+    you must own the domain and point your registrar at Linode’s nameservers.
+
+    API Documentation: https://www.linode.com/docs/api/domains/#domain-view
+    """
+
     api_endpoint = "/domains/{id}"
     properties = {
-        'id': Property(identifier=True),
-        'domain': Property(mutable=True, filterable=True),
-        'group': Property(mutable=True, filterable=True),
-        'description': Property(mutable=True),
-        'status': Property(mutable=True),
-        'soa_email': Property(mutable=True),
-        'retry_sec': Property(mutable=True),
-        'master_ips': Property(mutable=True, filterable=True),
-        'axfr_ips': Property(mutable=True),
-        'expire_sec': Property(mutable=True),
-        'refresh_sec': Property(mutable=True),
-        'ttl_sec': Property(mutable=True),
-        'records': Property(derived_class=DomainRecord),
-        'type': Property(mutable=True),
-        'tags': Property(mutable=True),
+        "id": Property(identifier=True),
+        "domain": Property(mutable=True),
+        "group": Property(mutable=True),
+        "description": Property(mutable=True),
+        "status": Property(mutable=True),
+        "soa_email": Property(mutable=True),
+        "retry_sec": Property(mutable=True),
+        "master_ips": Property(mutable=True),
+        "axfr_ips": Property(mutable=True),
+        "expire_sec": Property(mutable=True),
+        "refresh_sec": Property(mutable=True),
+        "ttl_sec": Property(mutable=True),
+        "records": Property(derived_class=DomainRecord),
+        "type": Property(mutable=True),
+        "tags": Property(mutable=True),
     }
 
     def record_create(self, record_type, **kwargs):
+        """
+        Adds a new Domain Record to the zonefile this Domain represents.
+        Each domain can have up to 12,000 active records.
+
+        API Documentation: https://www.linode.com/docs/api/domains/#domain-record-create
+
+        :param record_type: The type of Record this is in the DNS system. Can be one of:
+                            A, AAAA, NS, MX, CNAME, TXT, SRV, PTR, CAA.
+        :type: record_type: str
+
+        :param kwargs: Additional optional parameters for creating a domain record. Valid parameters
+                       are: name, target, priority, weight, port, service, protocol, ttl_sec. Descriptions
+                       of these parameters can be found in the API Documentation above.
+        :type: record_type: dict
+
+        :returns: The newly created Domain Record
+        :rtype: DomainRecord
+        """
 
         params = {
             "type": record_type,
         }
         params.update(kwargs)
 
-        result = self._client.post("{}/records".format(Domain.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/records".format(Domain.api_endpoint), model=self, data=params
+        )
         self.invalidate()
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response creating domain record!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response creating domain record!", json=result
+            )
 
-        zr = DomainRecord(self._client, result['id'], self.id, result)
+        zr = DomainRecord(self._client, result["id"], self.id, result)
         return zr
+
+    def zone_file_view(self):
+        """
+        Returns the zone file for the last rendered zone for the specified domain.
+
+        API Documentation: https://www.linode.com/docs/api/domains/#domain-zone-file-view
+
+        :returns: The zone file for the last rendered zone for the specified domain in the form
+                  of a list of the lines of the zone file.
+        :rtype: List[str]
+        """
+
+        result = self._client.get(
+            "{}/zone-file".format(self.api_endpoint), model=self
+        )
+
+        return result["zone_file"]
+
+    def clone(self, domain: str):
+        """
+        Clones a Domain and all associated DNS records from a Domain that is registered in Linode’s DNS manager.
+
+        API Documentation: https://www.linode.com/docs/api/domains/#domain-clone
+
+        :param domain: The new domain for the clone. Domain labels cannot be longer
+                       than 63 characters and must conform to RFC1035. Domains must be
+                       unique on Linode’s platform, including across different Linode
+                       accounts; there cannot be two Domains representing the same domain.
+        :type: domain: str
+        """
+        params = {"domain": domain}
+
+        self._client.post(
+            "{}/clone".format(self.api_endpoint), model=self, data=params
+        )
+
+    def domain_import(self, domain, remote_nameserver):
+        """
+        Imports a domain zone from a remote nameserver. Your nameserver must
+        allow zone transfers (AXFR) from the following IPs:
+            - 96.126.114.97
+            - 96.126.114.98
+            - 2600:3c00::5e
+            = 2600:3c00::5f
+
+        API Documentation: https://www.linode.com/docs/api/domains/#domain-import
+
+        :param domain: The domain to import.
+        :type: domain: str
+
+        :param remote_nameserver: The remote nameserver that allows zone transfers (AXFR).
+        :type: remote_nameserver: str
+        """
+
+        params = {
+            "domain": domain.domain if isinstance(domain, Domain) else domain,
+            "remote_nameserver": remote_nameserver,
+        }
+
+        self._client.post("/domains/import", model=self, data=params)
diff --git a/linode_api4/objects/filtering.py b/linode_api4/objects/filtering.py
index f8a97fd54..c017a8f1f 100644
--- a/linode_api4/objects/filtering.py
+++ b/linode_api4/objects/filtering.py
@@ -120,77 +120,80 @@ class Filter:
     between class attributes of filterable classes (see above).  Filters can
     be combined with :any:`and_` and :any:`or_`.
     """
+
     def __init__(self, dct):
         self.dct = dct
 
     def __or__(self, other):
         if not isinstance(other, Filter):
             raise TypeError("You can only or Filter types!")
-        if '+or' in self.dct:
-            return Filter({ '+or': self.dct['+or'] + [ other.dct ] })
+        if "+or" in self.dct:
+            return Filter({"+or": self.dct["+or"] + [other.dct]})
         else:
-            return Filter({ '+or': [self.dct, other.dct] })
+            return Filter({"+or": [self.dct, other.dct]})
 
     def __and__(self, other):
         if not isinstance(other, Filter):
             raise TypeError("You can only and Filter types!")
-        if '+and' in self.dct:
-            return Filter({ '+and': self.dct['+and'] + [ other.dct ] })
+        if "+and" in self.dct:
+            return Filter({"+and": self.dct["+and"] + [other.dct]})
         else:
-            return Filter({ '+and': [self.dct, other.dct] })
+            return Filter({"+and": [self.dct, other.dct]})
 
     def order_by(self, field, desc=False):
         # we can't include two order_bys
-        if '+order_by' in self.dct:
+        if "+order_by" in self.dct:
             raise AssertionError("You may only order by once!")
 
         if not isinstance(field, FilterableAttribute):
             raise TypeError("Can only order by filterable attributes!")
 
-        self.dct['+order_by'] = field.name
+        self.dct["+order_by"] = field.name
         if desc:
-            self.dct['+order'] = 'desc'
+            self.dct["+order"] = "desc"
 
         return self
 
     def limit(self, limit):
         # we can't limit twice
-        if '+limit' in self.dct:
+        if "+limit" in self.dct:
             raise AssertionError("You may only limit once!")
 
         if not type(limit) == int:
             raise TypeError("Limit must be an int!")
 
-        self.dct['+limit'] = limit
+        self.dct["+limit"] = limit
 
         return self
 
+
 class FilterableAttribute:
     def __init__(self, name):
         self.name = name
 
     def __eq__(self, other):
-        return Filter({ self.name: other })
+        return Filter({self.name: other})
 
     def __ne__(self, other):
-        return Filter({ self.name: { "+neq": other } })
+        return Filter({self.name: {"+neq": other}})
 
-    # "in" evaluates the return value - have to use 
+    # "in" evaluates the return value - have to use
     # type.contains instead
     def contains(self, other):
-        return Filter({ self.name: { "+contains": other } })
+        return Filter({self.name: {"+contains": other}})
 
     def __gt__(self, other):
-        return Filter({ self.name: { "+gt": other } })
+        return Filter({self.name: {"+gt": other}})
 
     def __lt__(self, other):
-        return Filter({ self.name: { "+lt": other } })
+        return Filter({self.name: {"+lt": other}})
 
     def __ge__(self, other):
-        return Filter({ self.name: { "+gte": other } })
+        return Filter({self.name: {"+gte": other}})
 
     def __le__(self, other):
-        return Filter({ self.name: { "+lte": other } })
+        return Filter({self.name: {"+lte": other}})
+
 
 class NonFilterableAttribute:
     def __init__(self, clsname, atrname):
@@ -198,29 +201,44 @@ def __init__(self, clsname, atrname):
         self.atrname = atrname
 
     def __eq__(self, other):
-        raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname))
+        raise AttributeError(
+            "{} cannot be filtered by {}".format(self.clsname, self.atrname)
+        )
 
     def __ne__(self, other):
-        raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname))
+        raise AttributeError(
+            "{} cannot be filtered by {}".format(self.clsname, self.atrname)
+        )
 
     def contains(self, other):
-        raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname))
+        raise AttributeError(
+            "{} cannot be filtered by {}".format(self.clsname, self.atrname)
+        )
 
     def __gt__(self, other):
-        raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname))
+        raise AttributeError(
+            "{} cannot be filtered by {}".format(self.clsname, self.atrname)
+        )
 
     def __lt__(self, other):
-        raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname))
+        raise AttributeError(
+            "{} cannot be filtered by {}".format(self.clsname, self.atrname)
+        )
 
     def __ge__(self, other):
-        raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname))
+        raise AttributeError(
+            "{} cannot be filtered by {}".format(self.clsname, self.atrname)
+        )
 
     def __le__(self, other):
-        raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname))
+        raise AttributeError(
+            "{} cannot be filtered by {}".format(self.clsname, self.atrname)
+        )
+
 
 class FilterableMetaclass(type):
     def __init__(cls, name, bases, dct):
-        if hasattr(cls, 'properties'):
+        if hasattr(cls, "properties"):
             for key in cls.properties.keys():
                 setattr(cls, key, FilterableAttribute(key))
 
diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py
index a44246ae8..381bb00e6 100644
--- a/linode_api4/objects/image.py
+++ b/linode_api4/objects/image.py
@@ -4,8 +4,11 @@
 class Image(Base):
     """
     An Image is something a Linode Instance or Disk can be deployed from.
+
+    API Documentation: https://www.linode.com/docs/api/images/#image-view
     """
-    api_endpoint = '/images/{id}'
+
+    api_endpoint = "/images/{id}"
 
     properties = {
         "id": Property(identifier=True),
@@ -21,5 +24,5 @@ class Image(Base):
         "is_public": Property(),
         "vendor": Property(),
         "size": Property(),
-        "deprecated": Property()
+        "deprecated": Property(),
     }
diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py
index 261a6727d..cf78ec4fd 100644
--- a/linode_api4/objects/linode.py
+++ b/linode_api4/objects/linode.py
@@ -5,6 +5,7 @@
 from os import urandom
 from random import randint
 
+from linode_api4 import util
 from linode_api4.common import load_and_validate_keys
 from linode_api4.errors import UnexpectedResponseError
 from linode_api4.objects import Base, DerivedBase, Image, Property, Region
@@ -17,82 +18,134 @@
 
 
 class Backup(DerivedBase):
-    api_endpoint = '/linode/instances/{linode_id}/backups/{id}'
-    derived_url_path = 'backups'
-    parent_id_name='linode_id'
+    """
+    A Backup of a Linode Instance.
+
+    API Documentation: https://www.linode.com/docs/api/linode-instances/#backup-view
+    """
+
+    api_endpoint = "/linode/instances/{linode_id}/backups/{id}"
+    derived_url_path = "backups"
+    parent_id_name = "linode_id"
 
     properties = {
-        'id': Property(identifier=True),
-        'created': Property(is_datetime=True),
-        'duration': Property(),
-        'updated': Property(is_datetime=True),
-        'finished': Property(is_datetime=True),
-        'message': Property(),
-        'status': Property(volatile=True),
-        'type': Property(),
-        'linode_id': Property(identifier=True),
-        'label': Property(),
-        'configs': Property(),
-        'disks': Property(),
-        'region': Property(slug_relationship=Region),
+        "id": Property(identifier=True),
+        "created": Property(is_datetime=True),
+        "duration": Property(),
+        "updated": Property(is_datetime=True),
+        "finished": Property(is_datetime=True),
+        "message": Property(),
+        "status": Property(volatile=True),
+        "type": Property(),
+        "linode_id": Property(identifier=True),
+        "label": Property(),
+        "configs": Property(),
+        "disks": Property(),
+        "region": Property(slug_relationship=Region),
+        "available": Property(),
     }
 
     def restore_to(self, linode, **kwargs):
+        """
+        Restores a Linode’s Backup to the specified Linode.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#backup-restore
+
+        :param linode: The id of the Instance or the Instance to share the IPAddresses with.
+                       This Instance will be able to bring up the given addresses.
+        :type: linode: int or Instance
+
+        :param kwargs: A dict containing the The ID of the Linode to restore a Backup to and
+                       a boolean that, if True, deletes all Disks and Configs on
+                       the target Linode before restoring.
+        :type: kwargs: dict
+
+        Example usage:
+           kwargs = {
+                "linode_id": 123,
+                "overwrite": true
+            }
+
+        :returns: Returns true if the operation was successful
+        :rtype: bool
+        """
+
         d = {
-            "linode_id": linode.id if issubclass(type(linode), Base) else linode,
+            "linode_id": linode.id
+            if issubclass(type(linode), Base)
+            else linode,
         }
         d.update(kwargs)
 
-        self._client.post("{}/restore".format(Backup.api_endpoint), model=self,
-            data=d)
+        self._client.post(
+            "{}/restore".format(Backup.api_endpoint), model=self, data=d
+        )
         return True
 
 
 class Disk(DerivedBase):
-    api_endpoint = '/linode/instances/{linode_id}/disks/{id}'
-    derived_url_path = 'disks'
-    parent_id_name='linode_id'
+    """
+    A Disk for the storage space on a Compute Instance.
+
+    API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-view
+    """
+
+    api_endpoint = "/linode/instances/{linode_id}/disks/{id}"
+    derived_url_path = "disks"
+    parent_id_name = "linode_id"
 
     properties = {
-        'id': Property(identifier=True),
-        'created': Property(is_datetime=True),
-        'label': Property(mutable=True, filterable=True),
-        'size': Property(filterable=True),
-        'status': Property(filterable=True, volatile=True),
-        'filesystem': Property(),
-        'updated': Property(is_datetime=True),
-        'linode_id': Property(identifier=True),
+        "id": Property(identifier=True),
+        "created": Property(is_datetime=True),
+        "label": Property(mutable=True),
+        "size": Property(),
+        "status": Property(volatile=True),
+        "filesystem": Property(),
+        "updated": Property(is_datetime=True),
+        "linode_id": Property(identifier=True),
     }
 
-
     def duplicate(self):
-        result = self._client.post(Disk.api_endpoint, model=self, data={})
+        """
+        Copies a disk, byte-for-byte, into a new Disk belonging to the same Linode. The Linode must have enough
+        storage space available to accept a new Disk of the same size as this one or this operation will fail.
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response duplicating disk!', json=result)
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-clone
 
-        d = Disk(self._client, result['id'], self.linode_id, result)
-        return d
+        :returns: A Disk object representing the cloned Disk
+        :rtype: Disk
+        """
+
+        d = self._client.post("{}/clone".format(Disk.api_endpoint), model=self)
 
+        if not "id" in d:
+            raise UnexpectedResponseError(
+                "Unexpected response duplicating disk!", json=d
+            )
+
+        return Disk(self._client, d["id"], self.linode_id)
 
     def reset_root_password(self, root_password=None):
+        """
+        Resets the password of a Disk you have permission to read_write.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-root-password-reset
+
+        :param root_password: The new root password for the OS installed on this Disk. The password must meet the complexity
+                              strength validation requirements for a strong password.
+        :type: root_password: str
+        """
         rpass = root_password
         if not rpass:
             rpass = Instance.generate_root_password()
 
         params = {
-            'password': rpass,
+            "password": rpass,
         }
 
-        result = self._client.post(Disk.api_endpoint, model=self, data=params)
-
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response duplicating disk!', json=result)
-
-        self._populate(result)
-        if not root_password:
-            return True, rpass
-        return True
+        self._client.post(
+            "{}/password".format(Disk.api_endpoint), model=self, data=params
+        )
 
     def resize(self, new_size):
         """
@@ -104,47 +157,86 @@ def resize(self, new_size):
         fit on the new disk size.  You may need to resize the filesystem on the
         disk first before performing this action.
 
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-resize
+
         :param new_size: The intended new size of the disk, in MB
         :type new_size: int
 
         :returns: True if the resize was initiated successfully.
         :rtype: bool
         """
-        self._client.post('{}/resize'.format(Disk.api_endpoint), model=self, data={"size": new_size})
+        self._client.post(
+            "{}/resize".format(Disk.api_endpoint),
+            model=self,
+            data={"size": new_size},
+        )
 
         return True
 
 
 class Kernel(Base):
-    api_endpoint="/linode/kernels/{id}"
+    """
+    The primary component of every Linux system. The kernel interfaces
+    with the system’s hardware and it controls the operating system’s core functionality.
+
+    Your Compute Instance is capable of running one of three kinds of kernels:
+
+        - Upstream kernel (or distribution-supplied kernel): This kernel is maintained
+          and provided by your Linux distribution. A major benefit of this kernel is that the
+          distribution was designed with this kernel in mind and all updates are managed through
+          the distributions package management system. It also may support features not present
+          in the Linode kernel (for example, SELinux).
+
+        - Linode kernel: Linode also maintains kernels that can be used on a Compute Instance.
+          If selected, these kernels are provided to your Compute Instance at boot
+          (not directly installed on your system). The Current Kernels page displays a
+          list of all the available Linode kernels.
+
+        - Custom-compiled kernel: A kernel that you compile from source. Compiling a kernel
+          can let you use features not available in the upstream or Linode kernels, but it takes longer
+          to compile the kernel from source than to download it from your package manager. For more
+          information on custom compiled kernels, review our guides for Debian, Ubuntu, and CentOS.
+
+    API Documentation: https://www.linode.com/docs/api/linode-instances/#kernel-view
+    """
+
+    api_endpoint = "/linode/kernels/{id}"
     properties = {
         "created": Property(is_datetime=True),
-        "deprecated": Property(filterable=True),
+        "deprecated": Property(),
         "description": Property(),
         "id": Property(identifier=True),
-        "kvm": Property(filterable=True),
-        "label": Property(filterable=True),
+        "kvm": Property(),
+        "label": Property(),
         "updates": Property(),
-        "version": Property(filterable=True),
-        "architecture": Property(filterable=True),
-        "xen": Property(filterable=True),
+        "version": Property(),
+        "architecture": Property(),
+        "xen": Property(),
+        "built": Property(),
+        "pvops": Property(),
     }
 
 
 class Type(Base):
+    """
+    Linode Plan type to specify the resources available to a Linode Instance.
+
+    API Documentation: https://www.linode.com/docs/api/linode-types/#type-view
+    """
+
     api_endpoint = "/linode/types/{id}"
     properties = {
-        'disk': Property(filterable=True),
-        'id': Property(identifier=True),
-        'label': Property(filterable=True),
-        'network_out': Property(filterable=True),
-        'price': Property(),
-        'addons': Property(),
-        'memory': Property(filterable=True),
-        'transfer': Property(filterable=True),
-        'vcpus': Property(filterable=True),
-        'gpus': Property(filterable=True),
-        'successor': Property(),
+        "disk": Property(),
+        "id": Property(identifier=True),
+        "label": Property(),
+        "network_out": Property(),
+        "price": Property(),
+        "addons": Property(),
+        "memory": Property(),
+        "transfer": Property(),
+        "vcpus": Property(),
+        "gpus": Property(),
+        "successor": Property(),
         # type_class is populated from the 'class' attribute of the returned JSON
     }
 
@@ -154,13 +246,13 @@ def _populate(self, json):
         """
         super()._populate(json)
 
-        if 'class' in json:
-            setattr(self, 'type_class', json['class'])
+        if "class" in json:
+            setattr(self, "type_class", json["class"])
         else:
-            setattr(self, 'type_class', None)
+            setattr(self, "type_class", None)
 
     # allow filtering on this converted type
-    type_class = FilterableAttribute('class')
+    type_class = FilterableAttribute("class")
 
 
 class ConfigInterface:
@@ -168,6 +260,7 @@ class ConfigInterface:
     This is a helper class used to populate 'interfaces' in the Config calss
     below.
     """
+
     def __init__(self, purpose, label="", ipam_address=""):
         """
         Creates a new ConfigInterface
@@ -201,28 +294,34 @@ def _serialize(self):
         }
 
 
-
 class Config(DerivedBase):
-    api_endpoint="/linode/instances/{linode_id}/configs/{id}"
-    derived_url_path="configs"
-    parent_id_name="linode_id"
+    """
+    A Configuration Profile for a Linode Instance.
+
+    API Documentation: https://www.linode.com/docs/api/linode-instances/#configuration-profile-view
+    """
+
+    api_endpoint = "/linode/instances/{linode_id}/configs/{id}"
+    derived_url_path = "configs"
+    parent_id_name = "linode_id"
 
     properties = {
         "id": Property(identifier=True),
         "linode_id": Property(identifier=True),
-        "helpers": Property(),#TODO: mutable=True),
+        "helpers": Property(),  # TODO: mutable=True),
         "created": Property(is_datetime=True),
         "root_device": Property(mutable=True),
-        "kernel": Property(relationship=Kernel, mutable=True, filterable=True),
-        "devices": Property(filterable=True),#TODO: mutable=True),
+        "kernel": Property(relationship=Kernel, mutable=True),
+        "devices": Property(),  # TODO: mutable=True),
         "initrd": Property(relationship=Disk),
         "updated": Property(),
-        "comments": Property(mutable=True, filterable=True),
-        "label": Property(mutable=True, filterable=True),
-        "run_level": Property(mutable=True, filterable=True),
-        "virt_mode": Property(mutable=True, filterable=True),
-        "memory_limit": Property(mutable=True, filterable=True),
-        "interfaces": Property(mutable=True), # gets setup in _populate below
+        "comments": Property(mutable=True),
+        "label": Property(mutable=True),
+        "run_level": Property(mutable=True),
+        "virt_mode": Property(mutable=True),
+        "memory_limit": Property(mutable=True),
+        "interfaces": Property(mutable=True),  # gets setup in _populate below
+        "helpers": Property(mutable=True),
     }
 
     def _populate(self, json):
@@ -230,31 +329,37 @@ def _populate(self, json):
         Map devices more nicely while populating.
         """
         # needed here to avoid circular imports
-        from .volume import Volume # pylint: disable=import-outside-toplevel
+        from .volume import Volume  # pylint: disable=import-outside-toplevel
 
         DerivedBase._populate(self, json)
 
         devices = {}
-        for device_index, device in json['devices'].items():
+        for device_index, device in json["devices"].items():
             if not device:
                 devices[device_index] = None
                 continue
 
             dev = None
-            if 'disk_id' in device and device['disk_id']: # this is a disk
-                dev = Disk.make_instance(device['disk_id'], self._client,
-                        parent_id=self.linode_id)
+            if "disk_id" in device and device["disk_id"]:  # this is a disk
+                dev = Disk.make_instance(
+                    device["disk_id"], self._client, parent_id=self.linode_id
+                )
             else:
-                dev = Volume.make_instance(device['volume_id'], self._client,
-                        parent_id=self.linode_id)
+                dev = Volume.make_instance(
+                    device["volume_id"], self._client, parent_id=self.linode_id
+                )
             devices[device_index] = dev
 
-        self._set('devices', MappedObject(**devices))
+        self._set("devices", MappedObject(**devices))
 
         interfaces = []
         if "interfaces" in json:
             interfaces = [
-                ConfigInterface(c["purpose"], label=c["label"], ipam_address=c["ipam_address"])
+                ConfigInterface(
+                    c["purpose"],
+                    label=c["label"],
+                    ipam_address=c["ipam_address"],
+                )
                 for c in json["interfaces"]
             ]
 
@@ -278,26 +383,34 @@ def _serialize(self):
 
 
 class Instance(Base):
-    api_endpoint = '/linode/instances/{id}'
+    """
+    A Linode Instance.
+
+    API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-view
+    """
+
+    api_endpoint = "/linode/instances/{id}"
     properties = {
-        'id': Property(identifier=True, filterable=True),
-        'label': Property(mutable=True, filterable=True),
-        'group': Property(mutable=True, filterable=True),
-        'status': Property(volatile=True),
-        'created': Property(is_datetime=True),
-        'updated': Property(volatile=True, is_datetime=True),
-        'region': Property(slug_relationship=Region, filterable=True),
-        'alerts': Property(mutable=True),
-        'image': Property(slug_relationship=Image, filterable=True),
-        'disks': Property(derived_class=Disk),
-        'configs': Property(derived_class=Config),
-        'type': Property(slug_relationship=Type),
-        'backups': Property(),
-        'ipv4': Property(),
-        'ipv6': Property(),
-        'hypervisor': Property(),
-        'specs': Property(),
-        'tags': Property(mutable=True),
+        "id": Property(identifier=True),
+        "label": Property(mutable=True),
+        "group": Property(mutable=True),
+        "status": Property(volatile=True),
+        "created": Property(is_datetime=True),
+        "updated": Property(volatile=True, is_datetime=True),
+        "region": Property(slug_relationship=Region),
+        "alerts": Property(mutable=True),
+        "image": Property(slug_relationship=Image),
+        "disks": Property(derived_class=Disk),
+        "configs": Property(derived_class=Config),
+        "type": Property(slug_relationship=Type),
+        "backups": Property(),
+        "ipv4": Property(),
+        "ipv6": Property(),
+        "hypervisor": Property(),
+        "specs": Property(),
+        "tags": Property(mutable=True),
+        "host_uuid": Property(),
+        "watchdog_enabled": Property(),
     }
 
     @property
@@ -305,51 +418,72 @@ def ips(self):
         """
         The ips related collection is not normalized like the others, so we have to
         make an ad-hoc object to return for its response
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#networking-information-list
+
+        :returns: A List of the ips of the Linode Instance.
+        :rtype: List[IPAddress]
         """
-        if not hasattr(self, '_ips'):
-            result = self._client.get("{}/ips".format(Instance.api_endpoint), model=self)
+        if not hasattr(self, "_ips"):
+            result = self._client.get(
+                "{}/ips".format(Instance.api_endpoint), model=self
+            )
 
             if not "ipv4" in result:
-                raise UnexpectedResponseError('Unexpected response loading IPs', json=result)
+                raise UnexpectedResponseError(
+                    "Unexpected response loading IPs", json=result
+                )
 
             v4pub = []
-            for c in result['ipv4']['public']:
-                i = IPAddress(self._client, c['address'], c)
+            for c in result["ipv4"]["public"]:
+                i = IPAddress(self._client, c["address"], c)
                 v4pub.append(i)
 
             v4pri = []
-            for c in result['ipv4']['private']:
-                i = IPAddress(self._client, c['address'], c)
+            for c in result["ipv4"]["private"]:
+                i = IPAddress(self._client, c["address"], c)
                 v4pri.append(i)
 
             shared_ips = []
-            for c in result['ipv4']['shared']:
-                i = IPAddress(self._client, c['address'], c)
+            for c in result["ipv4"]["shared"]:
+                i = IPAddress(self._client, c["address"], c)
                 shared_ips.append(i)
 
-            slaac = IPAddress(self._client, result['ipv6']['slaac']['address'],
-                              result['ipv6']['slaac'])
-            link_local = IPAddress(self._client, result['ipv6']['link_local']['address'],
-                                   result['ipv6']['link_local'])
-
-            pools = []
-            for p in result['ipv6']['global']:
-                pools.append(IPv6Pool(self._client, p['range']))
-
-            ips = MappedObject(**{
-                "ipv4": {
-                    "public": v4pub,
-                    "private": v4pri,
-                    "shared": shared_ips,
-                },
-                "ipv6": {
-                    "slaac": slaac,
-                    "link_local": link_local,
-                    "pools": pools,
-                },
-            })
+            reserved = []
+            for c in result["ipv4"]["reserved"]:
+                i = IPAddress(self._client, c["address"], c)
+                reserved.append(i)
+
+            slaac = IPAddress(
+                self._client,
+                result["ipv6"]["slaac"]["address"],
+                result["ipv6"]["slaac"],
+            )
+            link_local = IPAddress(
+                self._client,
+                result["ipv6"]["link_local"]["address"],
+                result["ipv6"]["link_local"],
+            )
+
+            pools = [IPv6Pool(self._client, result["ipv6"]["global"]["range"])]
+
+            ips = MappedObject(
+                **{
+                    "ipv4": {
+                        "public": v4pub,
+                        "private": v4pri,
+                        "shared": shared_ips,
+                        "reserved": reserved,
+                    },
+                    "ipv6": {
+                        "slaac": slaac,
+                        "link_local": link_local,
+                        "pools": pools,
+                    },
+                }
+            )
 
-            self._set('_ips', ips)
+            self._set("_ips", ips)
 
         return self._ips
 
@@ -357,109 +491,264 @@ def ips(self):
     def available_backups(self):
         """
         The backups response contains what backups are available to be restored.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-list
+
+        :returns: A List of the available backups for the Linode Instance.
+        :rtype: List[Backup]
         """
-        if not hasattr(self, '_avail_backups'):
-            result = self._client.get("{}/backups".format(Instance.api_endpoint), model=self)
+        if not hasattr(self, "_avail_backups"):
+            result = self._client.get(
+                "{}/backups".format(Instance.api_endpoint), model=self
+            )
 
-            if not 'automatic' in result:
-                raise UnexpectedResponseError('Unexpected response loading available backups!', json=result)
+            if not "automatic" in result:
+                raise UnexpectedResponseError(
+                    "Unexpected response loading available backups!",
+                    json=result,
+                )
 
             automatic = []
-            for a in result['automatic']:
-                cur = Backup(self._client, a['id'], self.id, a)
+            for a in result["automatic"]:
+                cur = Backup(self._client, a["id"], self.id, a)
                 automatic.append(cur)
 
             snap = None
-            if result['snapshot']['current']:
-                snap = Backup(self._client, result['snapshot']['current']['id'], self.id,
-                        result['snapshot']['current'])
+            if result["snapshot"]["current"]:
+                snap = Backup(
+                    self._client,
+                    result["snapshot"]["current"]["id"],
+                    self.id,
+                    result["snapshot"]["current"],
+                )
 
             psnap = None
-            if result['snapshot']['in_progress']:
-                psnap = Backup(self._client, result['snapshot']['in_progress']['id'], self.id,
-                        result['snapshot']['in_progress'])
-
-            self._set('_avail_backups', MappedObject(**{
-                "automatic": automatic,
-                "snapshot": {
-                    "current": snap,
-                    "in_progress": psnap,
-                }
-            }))
+            if result["snapshot"]["in_progress"]:
+                psnap = Backup(
+                    self._client,
+                    result["snapshot"]["in_progress"]["id"],
+                    self.id,
+                    result["snapshot"]["in_progress"],
+                )
+
+            self._set(
+                "_avail_backups",
+                MappedObject(
+                    **{
+                        "automatic": automatic,
+                        "snapshot": {
+                            "current": snap,
+                            "in_progress": psnap,
+                        },
+                    }
+                ),
+            )
 
         return self._avail_backups
 
+    def reset_instance_root_password(self, root_password=None):
+        """
+        Resets the root password for this Linode.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-root-password-reset
+
+        :param root_password: The root user’s password on this Linode. Linode passwords must
+                              meet a password strength score requirement that is calculated internally
+                              by the API. If the strength requirement is not met, you will receive a
+                              Password does not meet strength requirement error.
+        :type: root_password: str
+        """
+        rpass = root_password
+        if not rpass:
+            rpass = Instance.generate_root_password()
+
+        params = {
+            "password": rpass,
+        }
+
+        self._client.post(
+            "{}/password".format(Instance.api_endpoint), model=self, data=params
+        )
+
+    def transfer_year_month(self, year, month):
+        """
+        Get per-linode transfer for specified month
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#network-transfer-view-yearmonth
+
+        :param year: Numeric value representing the year to look up.
+        :type: year: int
+
+        :param month: Numeric value representing the month to look up.
+        :type: month: int
+
+        :returns: The network transfer statistics for the specified month.
+        :rtype: MappedObject
+        """
+
+        result = self._client.get(
+            "{}/transfer/{}/{}".format(Instance.api_endpoint, year, month),
+            model=self,
+        )
+
+        return MappedObject(**result)
+
     @property
     def transfer(self):
         """
         Get per-linode transfer
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#network-transfer-view
+
+        :returns: The network transfer statistics for the current month.
+        :rtype: MappedObject
         """
-        if not hasattr(self, '_transfer'):
-            result = self._client.get("{}/transfer".format(Instance.api_endpoint), model=self)
+        if not hasattr(self, "_transfer"):
+            result = self._client.get(
+                "{}/transfer".format(Instance.api_endpoint), model=self
+            )
 
-            if not 'used' in result:
-                raise UnexpectedResponseError('Unexpected response when getting Transfer Pool!')
+            if not "used" in result:
+                raise UnexpectedResponseError(
+                    "Unexpected response when getting Transfer Pool!"
+                )
 
             mapped = MappedObject(**result)
 
-            setattr(self, '_transfer', mapped)
+            setattr(self, "_transfer", mapped)
 
         return self._transfer
 
     def _populate(self, json):
         if json is not None:
             # fixes ipv4 and ipv6 attribute of json to make base._populate work
-            if 'ipv4' in json and 'address' in json['ipv4']:
-                json['ipv4']['id'] = json['ipv4']['address']
-            if 'ipv6' in json and isinstance(json['ipv6'], list):
-                for j in json['ipv6']:
-                    j['id'] = j['range']
+            if "ipv4" in json and "address" in json["ipv4"]:
+                json["ipv4"]["id"] = json["ipv4"]["address"]
+            if "ipv6" in json and isinstance(json["ipv6"], list):
+                for j in json["ipv6"]:
+                    j["id"] = j["range"]
 
         Base._populate(self, json)
 
     def invalidate(self):
-        """ Clear out cached properties """
-        if hasattr(self, '_avail_backups'):
+        """Clear out cached properties"""
+        if hasattr(self, "_avail_backups"):
             del self._avail_backups
-        if hasattr(self, '_ips'):
+        if hasattr(self, "_ips"):
             del self._ips
-        if hasattr(self, '_transfer'):
+        if hasattr(self, "_transfer"):
             del self._transfer
 
         Base.invalidate(self)
 
     def boot(self, config=None):
-        resp = self._client.post("{}/boot".format(Instance.api_endpoint), model=self, data={'config_id': config.id} if config else None)
+        """
+        Boots a Linode you have permission to modify. If no parameters are given, a Config
+        profile will be chosen for this boot based on the following criteria:
+
+            - If there is only one Config profile for this Linode, it will be used.
+            - If there is more than one Config profile, the last booted config will be used.
+            - If there is more than one Config profile and none were the last to be booted
+              (because the Linode was never booted or the last booted config was deleted)
+              an error will be returned.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-boot
+
+        :param config: The Linode Config ID to boot into.
+        :type: config: int
+
+        :returns: True if the operation was successful.
+        :rtype: bool
+        """
+
+        resp = self._client.post(
+            "{}/boot".format(Instance.api_endpoint),
+            model=self,
+            data={"config_id": config.id} if config else None,
+        )
 
-        if 'error' in resp:
+        if "error" in resp:
             return False
         return True
 
     def shutdown(self):
-        resp = self._client.post("{}/shutdown".format(Instance.api_endpoint), model=self)
+        """
+        Shuts down a Linode you have permission to modify. If any actions
+        are currently running or queued, those actions must be completed
+        first before you can initiate a shutdown.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-shut-down
+
+        :returns: True if the operation was successful.
+        :rtype: bool
+        """
+
+        resp = self._client.post(
+            "{}/shutdown".format(Instance.api_endpoint), model=self
+        )
 
-        if 'error' in resp:
+        if "error" in resp:
             return False
         return True
 
     def reboot(self):
-        resp = self._client.post("{}/reboot".format(Instance.api_endpoint), model=self)
+        """
+        Reboots a Linode you have permission to modify. If any actions are currently running
+        or queued, those actions must be completed first before you can initiate a reboot.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-reboot
+
+        :returns: True if the operation was successful.
+        :rtype: bool
+        """
+
+        resp = self._client.post(
+            "{}/reboot".format(Instance.api_endpoint), model=self
+        )
 
-        if 'error' in resp:
+        if "error" in resp:
             return False
         return True
 
-    def resize(self, new_type, **kwargs):
+    def resize(self, new_type, allow_auto_disk_resize=True, **kwargs):
+        """
+        Resizes a Linode you have the read_write permission to a different Type. If any
+        actions are currently running or queued, those actions must be completed first
+        before you can initiate a resize. Additionally, the following criteria must be
+        met in order to resize a Linode:
+
+            - The Linode must not have a pending migration.
+            - Your Account cannot have an outstanding balance.
+            - The Linode must not have more disk allocation than the new Type allows.
+                - In that situation, you must first delete or resize the disk to be smaller.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-resize
+
+        :param new_type: The Linode Type or the id representing it.
+        :type: new_type: Type or int
+
+        :param allow_auto_disk_resize: Automatically resize disks when resizing a Linode.
+                                       When resizing down to a smaller plan your Linode’s
+                                       data must fit within the smaller disk size. Defaults to true.
+        :type: allow_auto_disk_resize: bool
+
+        :returns: True if the operation was successful.
+        :rtype: bool
+        """
+
         new_type = new_type.id if issubclass(type(new_type), Base) else new_type
 
         params = {
             "type": new_type,
+            "allow_auto_disk_resize": allow_auto_disk_resize,
         }
         params.update(kwargs)
 
-        resp = self._client.post("{}/resize".format(Instance.api_endpoint), model=self, data=params)
+        resp = self._client.post(
+            "{}/resize".format(Instance.api_endpoint), model=self, data=params
+        )
 
-        if 'error' in resp:
+        if "error" in resp:
             return False
         return True
 
@@ -467,13 +756,15 @@ def resize(self, new_type, **kwargs):
     def generate_root_password():
         def _func(value):
             if sys.version_info[0] < 3:
-                value = int(value.encode('hex'), 16)
+                value = int(value.encode("hex"), 16)
             return value
 
-        password = ''.join([
-            PASSWORD_CHARS[_func(c) % len(PASSWORD_CHARS)]
-            for c in urandom(randint(50, 110))
-        ])
+        password = "".join(
+            [
+                PASSWORD_CHARS[_func(c) % len(PASSWORD_CHARS)]
+                for c in urandom(randint(50, 110))
+            ]
+        )
 
         # ensure the generated password is not too long
         if len(password) > 110:
@@ -482,11 +773,20 @@ def _func(value):
         return password
 
     # create derived objects
-    def config_create(self, kernel=None, label=None, devices=[], disks=[],
-            volumes=[], **kwargs):
+    def config_create(
+        self,
+        kernel=None,
+        label=None,
+        devices=[],
+        disks=[],
+        volumes=[],
+        **kwargs,
+    ):
         """
         Creates a Linode Config with the given attributes.
 
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#configuration-profile-create
+
         :param kernel: The kernel to boot with.
         :param label: The config label
         :param disks: The list of disks, starting at sda, to map to this config.
@@ -500,15 +800,21 @@ def config_create(self, kernel=None, label=None, devices=[], disks=[],
         :returns: A new Linode Config
         """
         # needed here to avoid circular imports
-        from .volume import Volume # pylint: disable=import-outside-toplevel
-
-        hypervisor_prefix = 'sd' if self.hypervisor == 'kvm' else 'xvd'
-        device_names = [hypervisor_prefix + string.ascii_lowercase[i] for i in range(0, 8)]
-        device_map = {device_names[i]: None for i in range(0, len(device_names))}
+        from .volume import Volume  # pylint: disable=import-outside-toplevel
+
+        hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd"
+        device_names = [
+            hypervisor_prefix + string.ascii_lowercase[i] for i in range(0, 8)
+        ]
+        device_map = {
+            device_names[i]: None for i in range(0, len(device_names))
+        }
 
         if devices and (disks or volumes):
-            raise ValueError('You may not call config_create with "devices" and '
-                    'either of "disks" or "volumes" specified!')
+            raise ValueError(
+                'You may not call config_create with "devices" and '
+                'either of "disks" or "volumes" specified!'
+            )
 
         if not devices:
             if not isinstance(disks, list):
@@ -535,39 +841,58 @@ def config_create(self, kernel=None, label=None, devices=[], disks=[],
                     devices.append(Volume(self._client, int(v)))
 
         if not devices:
-            raise ValueError('Must include at least one disk or volume!')
+            raise ValueError("Must include at least one disk or volume!")
 
         for i, d in enumerate(devices):
             if d is None:
                 pass
             elif isinstance(d, Disk):
-                device_map[device_names[i]] = {'disk_id': d.id }
+                device_map[device_names[i]] = {"disk_id": d.id}
             elif isinstance(d, Volume):
-                device_map[device_names[i]] = {'volume_id': d.id }
+                device_map[device_names[i]] = {"volume_id": d.id}
             else:
-                raise TypeError('Disk or Volume expected!')
+                raise TypeError("Disk or Volume expected!")
 
         params = {
-            'kernel': kernel.id if issubclass(type(kernel), Base) else kernel,
-            'label': label if label else "{}_config_{}".format(self.label, len(self.configs)),
-            'devices': device_map,
+            "kernel": kernel.id if issubclass(type(kernel), Base) else kernel,
+            "label": label
+            if label
+            else "{}_config_{}".format(self.label, len(self.configs)),
+            "devices": device_map,
         }
         params.update(kwargs)
 
-        result = self._client.post("{}/configs".format(Instance.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/configs".format(Instance.api_endpoint), model=self, data=params
+        )
         self.invalidate()
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response creating config!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response creating config!", json=result
+            )
 
-        c = Config(self._client, result['id'], self.id, result)
+        c = Config(self._client, result["id"], self.id, result)
         return c
 
-    def disk_create(self, size, label=None, filesystem=None, read_only=False, image=None,
-            root_pass=None, authorized_keys=None, authorized_users=None, stackscript=None, **stackscript_args):
+    def disk_create(
+        self,
+        size,
+        label=None,
+        filesystem=None,
+        read_only=False,
+        image=None,
+        root_pass=None,
+        authorized_keys=None,
+        authorized_users=None,
+        stackscript=None,
+        **stackscript_args,
+    ):
         """
         Creates a new Disk for this Instance.
 
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-create
+
         :param size: The size of the disk, in MB
         :param label: The label of the disk.  If not given, a default label will be generated.
         :param filesystem: The filesystem type for the disk.  If not given, the default
@@ -592,7 +917,7 @@ def disk_create(self, size, label=None, filesystem=None, read_only=False, image=
 
         gen_pass = None
         if image and not root_pass:
-            gen_pass  = Instance.generate_root_password()
+            gen_pass = Instance.generate_root_password()
             root_pass = gen_pass
 
         authorized_keys = load_and_validate_keys(authorized_keys)
@@ -601,32 +926,42 @@ def disk_create(self, size, label=None, filesystem=None, read_only=False, image=
             label = "My {} Disk".format(image.label)
 
         params = {
-            'size': size,
-            'label': label if label else "{}_disk_{}".format(self.label, len(self.disks)),
-            'read_only': read_only,
-            'filesystem': filesystem,
-            'authorized_keys': authorized_keys,
-            'authorized_users': authorized_users,
+            "size": size,
+            "label": label
+            if label
+            else "{}_disk_{}".format(self.label, len(self.disks)),
+            "read_only": read_only,
+            "filesystem": filesystem,
+            "authorized_keys": authorized_keys,
+            "authorized_users": authorized_users,
         }
 
         if image:
-            params.update({
-                'image': image.id if issubclass(type(image), Base) else image,
-                'root_pass': root_pass,
-            })
+            params.update(
+                {
+                    "image": image.id
+                    if issubclass(type(image), Base)
+                    else image,
+                    "root_pass": root_pass,
+                }
+            )
 
         if stackscript:
-            params['stackscript_id'] = stackscript.id
+            params["stackscript_id"] = stackscript.id
             if stackscript_args:
-                params['stackscript_data'] = stackscript_args
+                params["stackscript_data"] = stackscript_args
 
-        result = self._client.post("{}/disks".format(Instance.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/disks".format(Instance.api_endpoint), model=self, data=params
+        )
         self.invalidate()
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response creating disk!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response creating disk!", json=result
+            )
 
-        d = Disk(self._client, result['id'], self.id, result)
+        d = Disk(self._client, result["id"], self.id, result)
 
         if gen_pass:
             return d, gen_pass
@@ -637,11 +972,16 @@ def enable_backups(self):
         Enable Backups for this Instance.  When enabled, we will automatically
         backup your Instance's data so that it can be restored at a later date.
         For more information on Instance's Backups service and pricing, see our
-        `Backups Page`_
+        Backups Page: https://www.linode.com/backups
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-enable
 
-        .. _Backups Page: https://www.linode.com/backups
+        :returns: True if the operation was successful.
+        :rtype: bool
         """
-        self._client.post("{}/backups/enable".format(Instance.api_endpoint), model=self)
+        self._client.post(
+            "{}/backups/enable".format(Instance.api_endpoint), model=self
+        )
         self.invalidate()
         return True
 
@@ -650,23 +990,50 @@ def cancel_backups(self):
         Cancels Backups for this Instance.  All existing Backups will be lost,
         including any snapshots that have been taken.  This cannot be undone,
         but Backups can be re-enabled at a later date.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-cancel
+
+        :returns: True if the operation was successful.
+        :rtype: bool
         """
-        self._client.post("{}/backups/cancel".format(Instance.api_endpoint), model=self)
+        self._client.post(
+            "{}/backups/cancel".format(Instance.api_endpoint), model=self
+        )
         self.invalidate()
         return True
 
     def snapshot(self, label=None):
-        result = self._client.post("{}/backups".format(Instance.api_endpoint), model=self,
-                                   data={ "label": label })
+        """
+        Creates a snapshot Backup of a Linode.
+
+        Important: If you already have a snapshot of this Linode, this
+        is a destructive action. The previous snapshot will be deleted.
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response taking snapshot!', json=result)
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#snapshot-create
+
+        :param label: The label for the new snapshot.
+        :type: label: str
+
+        :returns: The snapshot Backup created.
+        :rtype: Backup
+        """
+
+        result = self._client.post(
+            "{}/backups".format(Instance.api_endpoint),
+            model=self,
+            data={"label": label},
+        )
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response taking snapshot!", json=result
+            )
 
         # so the changes show up the next time they're accessed
-        if hasattr(self, '_avail_backups'):
+        if hasattr(self, "_avail_backups"):
             del self._avail_backups
 
-        b = Backup(self._client, result['id'], self.id, result)
+        b = Backup(self._client, result["id"], self.id, result)
         return b
 
     def ip_allocate(self, public=False):
@@ -676,6 +1043,8 @@ def ip_allocate(self, public=False):
         before you can add one.  You may only have, at most, one private IP per
         Instance.
 
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#ipv4-address-allocate
+
         :param public: If the new IP should be public or private.  Defaults to
                        private.
         :type public: bool
@@ -689,13 +1058,15 @@ def ip_allocate(self, public=False):
             data={
                 "type": "ipv4",
                 "public": public,
-            })
+            },
+        )
 
-        if not 'address' in result:
-            raise UnexpectedResponseError('Unexpected response allocating IP!',
-                                          json=result)
+        if not "address" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response allocating IP!", json=result
+            )
 
-        i = IPAddress(self._client, result['address'], result)
+        i = IPAddress(self._client, result["address"], result)
         return i
 
     def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs):
@@ -704,6 +1075,8 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs):
         a new :any:`Image` to it.  This can be used to reset an existing
         Instance or to install an Image on an empty Instance.
 
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-rebuild
+
         :param image: The Image to deploy to this Instance
         :type image: str or Image
         :param root_pass: The root password for the newly rebuilt Instance.  If
@@ -727,16 +1100,20 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs):
         authorized_keys = load_and_validate_keys(authorized_keys)
 
         params = {
-             'image': image.id if issubclass(type(image), Base) else image,
-             'root_pass': root_pass,
-             'authorized_keys': authorized_keys,
-         }
+            "image": image.id if issubclass(type(image), Base) else image,
+            "root_pass": root_pass,
+            "authorized_keys": authorized_keys,
+        }
         params.update(kwargs)
 
-        result = self._client.post('{}/rebuild'.format(Instance.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/rebuild".format(Instance.api_endpoint), model=self, data=params
+        )
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response issuing rebuild!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response issuing rebuild!", json=result
+            )
 
         # update ourself with the newly-returned information
         self._populate(result)
@@ -747,60 +1124,244 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs):
             return ret_pass
 
     def rescue(self, *disks):
+        """
+        Rescue Mode is a safe environment for performing many system recovery and disk management
+        tasks. Rescue Mode is based on the Finnix recovery distribution, a self-contained and bootable
+        Linux distribution. You can also use Rescue Mode for tasks other than disaster recovery,
+        such as formatting disks to use different filesystems, copying data between disks, and
+        downloading files from a disk via SSH and SFTP.
+
+        Note that “sdh” is reserved and unavailable during rescue.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-boot-into-rescue-mode
+
+        :param disks: Devices that are either Disks or Volumes
+        :type: disks: dict
+
+        Example usage:
+            disks = {
+                "sda": {
+                    "disk_id": 124458,
+                    "volume_id": null
+                },
+                "sdb": {
+                    "disk_id": null,
+                    "volume_id": null
+                }
+            }
+        """
+
         if disks:
-            disks = { x: { 'disk_id': y } for x,y in zip(('sda','sdb','sdc','sdd','sde','sdf','sdg'), disks) }
+            disks = {
+                x: {"disk_id": y}
+                for x, y in zip(
+                    ("sda", "sdb", "sdc", "sdd", "sde", "sdf", "sdg"), disks
+                )
+            }
         else:
-            disks=None
+            disks = None
 
-        result = self._client.post('{}/rescue'.format(Instance.api_endpoint), model=self,
-                                   data={ "devices": disks })
+        result = self._client.post(
+            "{}/rescue".format(Instance.api_endpoint),
+            model=self,
+            data={"devices": disks},
+        )
 
         return result
 
-    def kvmify(self):
+    def mutate(self, allow_auto_disk_resize=True):
         """
-        Converts this linode to KVM from Xen
-        """
-        self._client.post('{}/kvmify'.format(Instance.api_endpoint), model=self)
+        Upgrades this Instance to the latest generation type
 
-        return True
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-upgrade
 
-    def mutate(self):
-        """
-        Upgrades this Instance to the latest generation type
+        :param allow_auto_disk_resize: Automatically resize disks when resizing a Linode.
+                                       When resizing down to a smaller plan your Linode’s
+                                       data must fit within the smaller disk size. Defaults to true.
+        :type: allow_auto_disk_resize: bool
+
+        :returns: True if the operation was successful.
+        :rtype: bool
         """
-        self._client.post('{}/mutate'.format(Instance.api_endpoint), model=self)
+
+        params = {"allow_auto_disk_resize": allow_auto_disk_resize}
+
+        self._client.post(
+            "{}/mutate".format(Instance.api_endpoint), model=self, data=params
+        )
 
         return True
 
-    def initiate_migration(self):
+    def initiate_migration(self, region=None, upgrade=None):
         """
         Initiates a pending migration that is already scheduled for this Linode
         Instance
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#dc-migrationpending-host-migration-initiate
+
+        :param region: The region to which the Linode will be migrated. Must be a valid region slug.
+                       A list of regions can be viewed by using the GET /regions endpoint. A cross data
+                       center migration will cancel a pending migration that has not yet been initiated.
+                       A cross data center migration will initiate a linode_migrate_datacenter_create event.
+        :type: region: str
+
+        :param upgrade: When initiating a cross DC migration, setting this value to true will also ensure
+                        that the Linode is upgraded to the latest generation of hardware that corresponds to
+                        your Linode’s Type, if any free upgrades are available for it. If no free upgrades
+                        are available, and this value is set to true, then the endpoint will return a 400
+                        error code and the migration will not be performed. If the data center set in the
+                        region field does not allow upgrades, then the endpoint will return a 400 error
+                        code and the migration will not be performed.
+        :type: upgrade: bool
+        """
+        params = {
+            "region": region.id if issubclass(type(region), Base) else region,
+            "upgrade": upgrade,
+        }
+
+        util.drop_null_keys(params)
+
+        self._client.post(
+            "{}/migrate".format(Instance.api_endpoint), model=self, data=params
+        )
+
+    def firewalls(self):
+        """
+        View Firewall information for Firewalls associated with this Linode.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#firewalls-list
+
+        :returns: A List of Firewalls of the Linode Instance.
+        :rtype: List[Firewall]
+        """
+        from linode_api4.objects import (  # pylint: disable=import-outside-toplevel
+            Firewall,
+        )
+
+        result = self._client.get(
+            "{}/firewalls".format(Instance.api_endpoint), model=self
+        )
+
+        return [
+            Firewall(self._client, firewall["id"])
+            for firewall in result["data"]
+        ]
+
+    def nodebalancers(self):
+        """
+        View a list of NodeBalancers that are assigned to this Linode and readable by the requesting User.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-nodebalancers-view
+
+        :returns: A List of Nodebalancers of the Linode Instance.
+        :rtype: List[Nodebalancer]
+        """
+        from linode_api4.objects import (  # pylint: disable=import-outside-toplevel
+            NodeBalancer,
+        )
+
+        result = self._client.get(
+            "{}/nodebalancers".format(Instance.api_endpoint), model=self
+        )
+
+        return [
+            NodeBalancer(self._client, nodebalancer["id"])
+            for nodebalancer in result["data"]
+        ]
+
+    def volumes(self):
+        """
+        View Block Storage Volumes attached to this Linode.
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-volumes-list
+
+        :returns: A List of Volumes of the Linode Instance.
+        :rtype: List[Volume]
+        """
+        from linode_api4.objects import (  # pylint: disable=import-outside-toplevel
+            Volume,
+        )
+
+        result = self._client.get(
+            "{}/volumes".format(Instance.api_endpoint), model=self
+        )
+
+        return [Volume(self._client, volume["id"]) for volume in result["data"]]
+
+    def clone(
+        self,
+        to_linode=None,
+        region=None,
+        instance_type=None,
+        configs=[],
+        disks=[],
+        label=None,
+        group=None,
+        with_backups=None,
+    ):
         """
-        self._client.post('{}/migrate'.format(Instance.api_endpoint), model=self)
+        Clones this linode into a new linode or into a new linode in the given region
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-clone
+
+        :param to_linode: If an existing Linode is the target for the clone, the ID of that
+                          Linode. The existing Linode must have enough resources to accept the clone.
+        :type: to_linode: int
+
+        :param region: This is the Region where the Linode will be deployed. Region can only be
+                       provided and is required when cloning to a new Linode.
+        :type: region: str
+
+        :param instance_type: A Linode’s Type determines what resources are available to it, including disk space,
+                              memory, and virtual cpus. The amounts available to a specific Linode are
+                              returned as specs on the Linode object.
+        :type: instance_type: str
+
+        :param configs: An array of configuration profile IDs.
+        :type: configs: List of int
 
-    def clone(self, to_linode=None, region=None, service=None, configs=[], disks=[],
-            label=None, group=None, with_backups=None):
-        """ Clones this linode into a new linode or into a new linode in the given region """
+        :param disks: An array of disk IDs.
+        :type: disks: List of int
+
+        :param label: The label to assign this Linode when cloning to a new Linode.
+        :type: label: str
+
+        :param group: A label used to group Linodes for display. Linodes are not required to have a group.
+        :type: group: str
+
+        :param with_backups: If this field is set to true, the created Linode will automatically be
+                             enrolled in the Linode Backup service. This will incur an additional charge.
+        :type: with_backups: bool
+
+        :returns: The cloned Instance.
+        :rtype: Instance
+        """
         if to_linode and region:
-            raise ValueError('You may only specify one of "to_linode" and "region"')
+            raise ValueError(
+                'You may only specify one of "to_linode" and "region"'
+            )
 
-        if region and not service:
+        if region and not type:
             raise ValueError('Specifying a region requires a "service" as well')
 
-        if not isinstance(configs, list) and not isinstance(configs, PaginatedList):
+        if not isinstance(configs, list) and not isinstance(
+            configs, PaginatedList
+        ):
             configs = [configs]
         if not isinstance(disks, list) and not isinstance(disks, PaginatedList):
             disks = [disks]
 
-        cids = [ c.id if issubclass(type(c), Base) else c for c in configs ]
-        dids = [ d.id if issubclass(type(d), Base) else d for d in disks ]
+        cids = [c.id if issubclass(type(c), Base) else c for c in configs]
+        dids = [d.id if issubclass(type(d), Base) else d for d in disks]
 
         params = {
-            "linode_id": to_linode.id if issubclass(type(to_linode), Base) else to_linode,
+            "linode_id": to_linode.id
+            if issubclass(type(to_linode), Base)
+            else to_linode,
             "region": region.id if issubclass(type(region), Base) else region,
-            "type": service.id if issubclass(type(service), Base) else service,
+            "type": instance_type.id
+            if issubclass(type(instance_type), Base)
+            else instance_type,
             "configs": cids if cids else None,
             "disks": dids if dids else None,
             "label": label,
@@ -808,30 +1369,52 @@ def clone(self, to_linode=None, region=None, service=None, configs=[], disks=[],
             "with_backups": with_backups,
         }
 
-        result = self._client.post('{}/clone'.format(Instance.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/clone".format(Instance.api_endpoint), model=self, data=params
+        )
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response cloning Instance!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response cloning Instance!", json=result
+            )
 
-        l = Instance(self._client, result['id'], result)
+        l = Instance(self._client, result["id"], result)
         return l
 
     @property
     def stats(self):
         """
         Returns the JSON stats for this Instance
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-statistics-view
+
+        :returns: The JSON stats for this Instance
+        :rtype: dict
         """
         # TODO - this would be nicer if we formatted the stats
-        return self._client.get('{}/stats'.format(Instance.api_endpoint), model=self)
+        return self._client.get(
+            "{}/stats".format(Instance.api_endpoint), model=self
+        )
 
     def stats_for(self, dt):
         """
         Returns stats for the month containing the given datetime
+
+        API Documentation: https://www.linode.com/docs/api/linode-instances/#statistics-view-yearmonth
+
+        :param dt: A Datetime for which to return statistics
+        :type: dt: Datetime
+
+        :returns: The JSON stats for this Instance at the specified Datetime
+        :rtype: dict
         """
         # TODO - this would be nicer if we formatted the stats
         if not isinstance(dt, datetime):
-            raise TypeError('stats_for requires a datetime object!')
-        return self._client.get('{}/stats/{}'.format(Instance.api_endpoint, dt.strftime('%Y/%m')), model=self)
+            raise TypeError("stats_for requires a datetime object!")
+        return self._client.get(
+            "{}/stats/{}".format(Instance.api_endpoint, dt.strftime("%Y/%m")),
+            model=self,
+        )
 
 
 class UserDefinedFieldType(Enum):
@@ -839,7 +1422,8 @@ class UserDefinedFieldType(Enum):
     select_one = 2
     select_many = 3
 
-class UserDefinedField():
+
+class UserDefinedField:
     def __init__(self, name, label, example, field_type, choices=None):
         self.name = name
         self.label = label
@@ -848,24 +1432,35 @@ def __init__(self, name, label, example, field_type, choices=None):
         self.choices = choices
 
     def __repr__(self):
-        return "{}({}): {}".format(self.label, self.field_type.name, self.example)
+        return "{}({}): {}".format(
+            self.label, self.field_type.name, self.example
+        )
+
 
 class StackScript(Base):
-    api_endpoint = '/linode/stackscripts/{id}'
+    """
+    A script allowing users to reproduce specific software configurations
+    when deploying Compute Instances, with more user control than static system images.
+
+    API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-view
+    """
+
+    api_endpoint = "/linode/stackscripts/{id}"
     properties = {
         "user_defined_fields": Property(),
-        "label": Property(mutable=True, filterable=True),
+        "label": Property(mutable=True),
         "rev_note": Property(mutable=True),
-        "username": Property(filterable=True),
+        "username": Property(),
         "user_gravatar_id": Property(),
-        "is_public": Property(mutable=True, filterable=True),
+        "is_public": Property(mutable=True),
         "created": Property(is_datetime=True),
         "deployments_active": Property(),
         "script": Property(mutable=True),
-        "images": Property(mutable=True, filterable=True), # TODO make slug_relationship
+        "images": Property(mutable=True),  # TODO make slug_relationship
         "deployments_total": Property(),
-        "description": Property(mutable=True, filterable=True),
+        "description": Property(mutable=True),
         "updated": Property(is_datetime=True),
+        "mine": Property(),
     }
 
     def _populate(self, json):
@@ -879,23 +1474,28 @@ def _populate(self, json):
         for udf in self.user_defined_fields:
             t = UserDefinedFieldType.text
             choices = None
-            if hasattr(udf, 'oneof'):
+            if hasattr(udf, "oneof"):
                 t = UserDefinedFieldType.select_one
-                choices = udf.oneof.split(',')
-            elif hasattr(udf, 'manyof'):
+                choices = udf.oneof.split(",")
+            elif hasattr(udf, "manyof"):
                 t = UserDefinedFieldType.select_many
-                choices = udf.manyof.split(',')
-
-            mapped_udfs.append(UserDefinedField(udf.name,
-                    udf.label if hasattr(udf, 'label') else None,
-                    udf.example if hasattr(udf, 'example') else None,
-                    t, choices=choices))
-
-        self._set('user_defined_fields', mapped_udfs)
-        ndist = [ Image(self._client, d) for d in self.images ]
-        self._set('images', ndist)
+                choices = udf.manyof.split(",")
+
+            mapped_udfs.append(
+                UserDefinedField(
+                    udf.name,
+                    udf.label if hasattr(udf, "label") else None,
+                    udf.example if hasattr(udf, "example") else None,
+                    t,
+                    choices=choices,
+                )
+            )
+
+        self._set("user_defined_fields", mapped_udfs)
+        ndist = [Image(self._client, d) for d in self.images]
+        self._set("images", ndist)
 
     def _serialize(self):
         dct = Base._serialize(self)
-        dct['images'] = [ d.id for d in self.images ]
+        dct["images"] = [d.id for d in self.images]
         return dct
diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py
index b25cb9af7..a1ca206e5 100644
--- a/linode_api4/objects/lke.py
+++ b/linode_api4/objects/lke.py
@@ -1,12 +1,22 @@
 from linode_api4.errors import UnexpectedResponseError
 from linode_api4.objects import (
-    Base, DerivedBase, Property, Region, Type, Instance, MappedObject
+    Base,
+    DerivedBase,
+    Instance,
+    MappedObject,
+    Property,
+    Region,
+    Type,
 )
 
+
 class KubeVersion(Base):
     """
     A KubeVersion is a version of Kubernetes that can be deployed on LKE.
+
+    API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-version-view
     """
+
     api_endpoint = "/lke/versions/{id}"
 
     properties = {
@@ -14,18 +24,21 @@ class KubeVersion(Base):
     }
 
 
-class LKENodePoolNode():
+class LKENodePoolNode:
     """
     AN LKE Node Pool Node is a helper class that is used to populate the "nodes"
     array of an LKE Node Pool, and set up an automatic relationship with the
     Linode Instance the Node represented.
     """
+
     def __init__(self, client, json):
         """
         Creates this NodePoolNode
         """
         #: The ID of this Node Pool Node
-        self.id = json.get("id") # why do these have an ID if they don't have an endpoint of their own?
+        self.id = json.get(
+            "id"
+        )  # why do these have an ID if they don't have an endpoint of their own?
 
         #: The ID of the Linode Instance this Node represents
         self.instance_id = json.get("instance_id")
@@ -41,9 +54,12 @@ class LKENodePool(DerivedBase):
     """
     An LKE Node Pool describes a pool of Linode Instances that exist within an
     LKE Cluster.
+
+    API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-view
     """
+
     api_endpoint = "/lke/clusters/{cluster_id}/pools/{id}"
-    derived_url_path = 'pools'
+    derived_url_path = "pools"
     parent_id_name = "cluster_id"
 
     properties = {
@@ -52,14 +68,18 @@ class LKENodePool(DerivedBase):
         "type": Property(slug_relationship=Type),
         "disks": Property(),
         "count": Property(mutable=True),
-        "nodes": Property(volatile=True), # this is formatted in _populate below
+        "nodes": Property(
+            volatile=True
+        ),  # this is formatted in _populate below
+        "autoscaler": Property(),
+        "tags": Property(mutable=True),
     }
 
     def _populate(self, json):
         """
         Parse Nodes into more useful LKENodePoolNode objects
         """
-        if json is not None:
+        if json != {}:
             new_nodes = [
                 LKENodePoolNode(self._client, c) for c in json["nodes"]
             ]
@@ -72,40 +92,55 @@ def recycle(self):
         Deleted and recreates all Linodes in this Node Pool in a rolling fashion.
         Completing this operation may take several minutes.  This operation will
         cause all local data on Linode Instances in this pool to be lost.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-recycle
         """
-        self._client.post("{}/recycle".format(LKENodePool.api_endpoint), model=self)
+        self._client.post(
+            "{}/recycle".format(LKENodePool.api_endpoint), model=self
+        )
         self.invalidate()
 
 
 class LKECluster(Base):
     """
     An LKE Cluster is a single k8s cluster deployed via Linode Kubernetes Engine.
+
+    API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-view
     """
+
     api_endpoint = "/lke/clusters/{id}"
 
     properties = {
-      "id": Property(identifier=True),
-      "created": Property(is_datetime=True),
-      "label": Property(mutable=True),
-      "tags": Property(mutable=True),
-      "updated": Property(is_datetime=True),
-      "region": Property(slug_relationship=Region),
-      "k8s_version": Property(slug_relationship=KubeVersion),
-      "pools": Property(derived_class=LKENodePool),
+        "id": Property(identifier=True),
+        "created": Property(is_datetime=True),
+        "label": Property(mutable=True),
+        "tags": Property(mutable=True),
+        "updated": Property(is_datetime=True),
+        "region": Property(slug_relationship=Region),
+        "k8s_version": Property(slug_relationship=KubeVersion),
+        "pools": Property(derived_class=LKENodePool),
+        "control_plane": Property(),
     }
 
     @property
     def api_endpoints(self):
         """
         A list of API Endpoints for this Cluster.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-api-endpoints-list
+
+        :returns: A list of MappedObjects of the API Endpoints
+        :rtype: List[MappedObject]
         """
         # This result appears to be a PaginatedList, but objects in the list don't
         # have IDs and can't be retrieved on their own, and it doesn't accept normal
         # pagination properties, so we're converting this to a list of strings.
         if not hasattr(self, "_api_endpoints"):
-            results = self._client.get("{}/api-endpoints".format(LKECluster.api_endpoint), model=self)
+            results = self._client.get(
+                "{}/api-endpoints".format(LKECluster.api_endpoint), model=self
+            )
 
-            self._api_endpoints = [MappedObject(**c) for c in results["data"]] # pylint: disable=attribute-defined-outside-init
+            self._api_endpoints = [MappedObject(**c) for c in results["data"]]
 
         return self._api_endpoints
 
@@ -129,12 +164,18 @@ def kubeconfig(self):
 
         It may take a few minutes for a config to be ready when creating a new
         cluster; during that time this request may fail.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubeconfig-view
+
+        :returns: The Kubeconfig file for this Cluster.
+        :rtype: str
         """
         if not hasattr(self, "_kubeconfig"):
-            result = self._client.get("{}/kubeconfig".format(LKECluster.api_endpoint), model=self)
-
-            self._kubeconfig = result["kubeconfig"] # pylint: disable=attribute-defined-outside-init
+            result = self._client.get(
+                "{}/kubeconfig".format(LKECluster.api_endpoint), model=self
+            )
 
+            self._kubeconfig = result["kubeconfig"]
 
         return self._kubeconfig
 
@@ -142,6 +183,8 @@ def node_pool_create(self, node_type, node_count, **kwargs):
         """
         Creates a new :any:`LKENodePool` for this cluster.
 
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-create
+
         :param node_type: The type of nodes to create in this pool.
         :type node_type: :any:`Type` or str
         :param node_count: The number of nodes to create in this pool.
@@ -158,10 +201,122 @@ def node_pool_create(self, node_type, node_count, **kwargs):
         }
         params.update(kwargs)
 
-        result = self._client.post("{}/pools".format(LKECluster.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/pools".format(LKECluster.api_endpoint), model=self, data=params
+        )
         self.invalidate()
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response creating node pool!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response creating node pool!", json=result
+            )
 
         return LKENodePool(self._client, result["id"], self.id, result)
+
+    def cluster_dashboard_url_view(self):
+        """
+        Get a Kubernetes Dashboard access URL for this Cluster.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-dashboard-url-view
+
+        :returns: The Kubernetes Dashboard access URL for this Cluster.
+        :rtype: str
+        """
+
+        result = self._client.get(
+            "{}/dashboard".format(LKECluster.api_endpoint), model=self
+        )
+
+        return result["url"]
+
+    def kubeconfig_delete(self):
+        """
+        Delete and regenerate the Kubeconfig file for a Cluster.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubeconfig-delete
+        """
+
+        self._client.delete(
+            "{}/kubeconfig".format(LKECluster.api_endpoint), model=self
+        )
+
+    def node_view(self, nodeId):
+        """
+        Get a specific Node by ID.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-view
+
+        :param nodeId: ID of the Node to look up.
+        :type nodeId: str
+
+        :returns: The specified Node
+        :rtype: LKENodePoolNode
+        """
+
+        node = self._client.get(
+            "{}/nodes/{}".format(LKECluster.api_endpoint, nodeId), model=self
+        )
+
+        return LKENodePoolNode(self._client, node)
+
+    def node_delete(self, nodeId):
+        """
+        Delete a specific Node from a Node Pool.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-delete
+
+        :param nodeId: ID of the Node to delete.
+        :type nodeId: str
+        """
+
+        self._client.delete(
+            "{}/nodes/{}".format(LKECluster.api_endpoint, nodeId), model=self
+        )
+
+    def node_recycle(self, nodeId):
+        """
+        Recycle a specific Node from an LKE cluster.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-recycle
+
+        :param nodeId: ID of the Node to recycle.
+        :type nodeId: str
+        """
+
+        self._client.post(
+            "{}/nodes/{}/recycle".format(LKECluster.api_endpoint, nodeId),
+            model=self,
+        )
+
+    def cluster_nodes_recycle(self):
+        """
+        Recycles all nodes in all pools of a designated Kubernetes Cluster.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#cluster-nodes-recycle
+        """
+
+        self._client.post(
+            "{}/recycle".format(LKECluster.api_endpoint), model=self
+        )
+
+    def cluster_regenerate(self):
+        """
+        Regenerate the Kubeconfig file and/or the service account token for a Cluster.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-regenerate
+        """
+
+        self._client.post(
+            "{}/regenerate".format(LKECluster.api_endpoint), model=self
+        )
+
+    def service_token_delete(self):
+        """
+        Delete and regenerate the service account token for a Cluster.
+
+        API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#service-token-delete
+        """
+
+        self._client.delete(
+            "{}/servicetoken".format(LKECluster.api_endpoint), model=self
+        )
diff --git a/linode_api4/objects/longview.py b/linode_api4/objects/longview.py
index ceeac219c..9d883693a 100644
--- a/linode_api4/objects/longview.py
+++ b/linode_api4/objects/longview.py
@@ -2,14 +2,19 @@
 
 
 class LongviewClient(Base):
+    """
+    A Longview Client that is accessible for use. Longview is Linode’s system data graphing service.
 
-    api_endpoint = '/longview/clients/{id}'
+    API Documentation: https://www.linode.com/docs/api/longview/#longview-client-view
+    """
 
-    properties= {
+    api_endpoint = "/longview/clients/{id}"
+
+    properties = {
         "id": Property(identifier=True),
         "created": Property(is_datetime=True),
         "updated": Property(is_datetime=True),
-        "label": Property(mutable=True, filterable=True),
+        "label": Property(mutable=True),
         "install_code": Property(),
         "apps": Property(),
         "api_key": Property(),
@@ -17,10 +22,34 @@ class LongviewClient(Base):
 
 
 class LongviewSubscription(Base):
-    api_endpoint = 'longview/subscriptions/{id}'
+    """
+    Contains the Longview Plan details for a specific subscription id.
+
+    API Documentation: https://www.linode.com/docs/api/longview/#longview-subscription-view
+    """
+
+    api_endpoint = "/longview/subscriptions/{id}"
+
+    properties = {
+        "id": Property(identifier=True),
+        "label": Property(),
+        "clients_included": Property(),
+        "price": Property(),
+    }
+
+
+class LongviewPlan(Base):
+    """
+    The current Longview Plan an account is using.
+
+    API Documentation: https://www.linode.com/docs/api/longview/#longview-plan-view
+    """
+
+    api_endpoint = "/longview/plan"
+
     properties = {
         "id": Property(identifier=True),
         "label": Property(),
         "clients_included": Property(),
-        "price": Property()
+        "price": Property(),
     }
diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py
index bd74bf945..2d427d3b9 100644
--- a/linode_api4/objects/networking.py
+++ b/linode_api4/objects/networking.py
@@ -3,28 +3,57 @@
 
 
 class IPv6Pool(Base):
-    api_endpoint = '/networking/ipv6/pools/{}'
-    id_attribute = 'range'
+    """
+    DEPRECATED
+    """
+
+    api_endpoint = "/networking/ipv6/pools/{}"
+    id_attribute = "range"
 
     properties = {
-        'range': Property(identifier=True),
-        'region': Property(slug_relationship=Region, filterable=True),
+        "range": Property(identifier=True),
+        "region": Property(slug_relationship=Region),
     }
 
 
 class IPv6Range(Base):
-    api_endpoint = '/networking/ipv6/ranges/{}'
-    id_attribute = 'range'
+    """
+    An instance of a Linode IPv6 Range.
+
+    API Documentation: https://www.linode.com/docs/api/networking/#ipv6-range-view
+    """
+
+    api_endpoint = "/networking/ipv6/ranges/{range}"
+    id_attribute = "range"
 
     properties = {
-        'range': Property(identifier=True),
-        'region': Property(slug_relationship=Region, filterable=True),
+        "range": Property(identifier=True),
+        "region": Property(slug_relationship=Region),
+        "prefix": Property(),
+        "route_target": Property(),
     }
 
 
 class IPAddress(Base):
-    api_endpoint = '/networking/ips/{address}'
-    id_attribute = 'address'
+    """
+    note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
+
+    Represents a Linode IP address object.
+
+    When attempting to reset the `rdns` field to default, consider using the ExplicitNullValue class::
+
+        ip = IPAddress(client, "127.0.0.1")
+        ip.rdns = ExplicitNullValue
+        ip.save()
+
+        # Re-populate all attributes with new information from the API
+        ip.invalidate()
+
+    API Documentation: https://www.linode.com/docs/api/networking/#ip-address-view
+    """
+
+    api_endpoint = "/networking/ips/{address}"
+    id_attribute = "address"
 
     properties = {
         "address": Property(identifier=True),
@@ -35,14 +64,15 @@ class IPAddress(Base):
         "public": Property(),
         "rdns": Property(mutable=True),
         "linode_id": Property(),
-        "region": Property(slug_relationship=Region, filterable=True),
+        "region": Property(slug_relationship=Region),
     }
 
     @property
     def linode(self):
-        from .linode import Instance # pylint: disable-all
-        if not hasattr(self, '_linode'):
-            self._set('_linode', Instance(self._client, self.linode_id))
+        from .linode import Instance  # pylint: disable-all
+
+        if not hasattr(self, "_linode"):
+            self._set("_linode", Instance(self._client, self.linode_id))
         return self._linode
 
     def to(self, linode):
@@ -51,70 +81,111 @@ def to(self, linode):
         of that context.  It's used to cleanly build an IP Assign request with
         pretty python syntax.
         """
-        from .linode import Instance # pylint: disable-all
+        from .linode import Instance  # pylint: disable-all
+
         if not isinstance(linode, Instance):
             raise ValueError("IP Address can only be assigned to a Linode!")
-        return { "address": self.address, "linode_id": linode.id }
 
+        return {"address": self.address, "linode_id": linode.id}
 
 
 class VLAN(Base):
     """
     .. note:: At this time, the Linode API only supports listing VLANs.
     .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
+
+    An instance of a Linode VLAN.
+    VLANs provide a mechanism for secure communication between two or more Linodes that are assigned to the same VLAN.
+    VLANs are implicitly created during Instance or Instance Config creation.
+
+    API Documentation: https://www.linode.com/docs/api/networking/#vlans-list
     """
-    api_endpoint = '/networking/vlans/{}'
-    id_attribute = 'label'
+
+    api_endpoint = "/networking/vlans/{}"
+    id_attribute = "label"
 
     properties = {
-        'label': Property(identifier=True),
-        'created': Property(is_datetime=True),
-        'linodes': Property(filterable=True),
-        'region': Property(slug_relationship=Region, filterable=True)
+        "label": Property(identifier=True),
+        "created": Property(is_datetime=True),
+        "linodes": Property(),
+        "region": Property(slug_relationship=Region),
     }
 
 
 class FirewallDevice(DerivedBase):
-    api_endpoint = '/networking/firewalls/{firewall_id}/devices/{id}'
-    derived_url_path = 'devices'
-    parent_id_name = 'firewall_id'
+    """
+    An object representing the assignment between a Linode Firewall and another Linode resource.
+
+    API Documentation: https://www.linode.com/docs/api/networking/#firewall-device-view
+    """
+
+    api_endpoint = "/networking/firewalls/{firewall_id}/devices/{id}"
+    derived_url_path = "devices"
+    parent_id_name = "firewall_id"
 
     properties = {
-        'created': Property(filterable=True, is_datetime=True),
-        'updated': Property(filterable=True, is_datetime=True),
-        'entity': Property(),
-        'id': Property(identifier=True)
+        "created": Property(is_datetime=True),
+        "updated": Property(is_datetime=True),
+        "entity": Property(),
+        "id": Property(identifier=True),
     }
 
 
 class Firewall(Base):
     """
     .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`.
+
+    An instance of a Linode Cloud Firewall.
+
+    API Documentation: https://www.linode.com/docs/api/networking/#firewall-view
     """
 
     api_endpoint = "/networking/firewalls/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'label': Property(mutable=True, filterable=True),
-        'tags': Property(mutable=True, filterable=True),
-        'status': Property(mutable=True),
-        'created': Property(filterable=True, is_datetime=True),
-        'updated': Property(filterable=True, is_datetime=True),
-        'devices': Property(derived_class=FirewallDevice),
-        'rules': Property(),
+        "id": Property(identifier=True),
+        "label": Property(mutable=True),
+        "tags": Property(mutable=True),
+        "status": Property(mutable=True),
+        "created": Property(is_datetime=True),
+        "updated": Property(is_datetime=True),
+        "devices": Property(derived_class=FirewallDevice),
+        "rules": Property(),
     }
+
     def update_rules(self, rules):
         """
-        Sets the JSON rules for this Firewall
+        Sets the JSON rules for this Firewall.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#firewall-rules-update__request-samples
+
+        :param rules: The rules to apply to this Firewall.
+        :type rules: dict
         """
-        self._client.put('{}/rules'.format(self.api_endpoint), model=self, data=rules)
+        self._client.put(
+            "{}/rules".format(self.api_endpoint), model=self, data=rules
+        )
         self.invalidate()
 
-    def device_create(self, id, type='linode', **kwargs):
+    def get_rules(self):
+        """
+        Gets the JSON rules for this Firewall.
+
+        API Documentation: https://www.linode.com/docs/api/networking/#firewall-rules-update__request-samples
+
+        :returns: The rules that this Firewall is currently configured with.
+        :rtype: dict
+        """
+        return self._client.get(
+            "{}/rules".format(self.api_endpoint), model=self
+        )
+
+    def device_create(self, id, type="linode", **kwargs):
         """
         Creates and attaches a device to this Firewall
 
+        API Documentation: https://www.linode.com/docs/api/networking/#firewall-device-create
+
         :param id: The ID of the entity to create a device for.
         :type id: int
 
@@ -122,16 +193,20 @@ def device_create(self, id, type='linode', **kwargs):
         :type type: str
         """
         params = {
-            'id': id,
-            'type': type,
+            "id": id,
+            "type": type,
         }
         params.update(kwargs)
 
-        result = self._client.post("{}/devices".format(Firewall.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/devices".format(Firewall.api_endpoint), model=self, data=params
+        )
         self.invalidate()
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response creating device!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response creating device!", json=result
+            )
 
-        c = FirewallDevice(self._client, result['id'], self.id, result)
+        c = FirewallDevice(self._client, result["id"], self.id, result)
         return c
diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py
index 9924843d4..9b08bf769 100644
--- a/linode_api4/objects/nodebalancer.py
+++ b/linode_api4/objects/nodebalancer.py
@@ -6,20 +6,28 @@
 
 
 class NodeBalancerNode(DerivedBase):
-    api_endpoint = '/nodebalancers/{nodebalancer_id}/configs/{config_id}/nodes/{id}'
-    derived_url_path = 'nodes'
-    parent_id_name='config_id'
+    """
+    The information about a single Node, a backend for this NodeBalancer’s configured port.
+
+    API documentation: https://www.linode.com/docs/api/nodebalancers/#node-view
+    """
+
+    api_endpoint = (
+        "/nodebalancers/{nodebalancer_id}/configs/{config_id}/nodes/{id}"
+    )
+    derived_url_path = "nodes"
+    parent_id_name = "config_id"
 
     properties = {
-        'id': Property(identifier=True),
-        'config_id': Property(identifier=True),
-        'nodebalancer_id': Property(identifier=True),
+        "id": Property(identifier=True),
+        "config_id": Property(identifier=True),
+        "nodebalancer_id": Property(identifier=True),
         "label": Property(mutable=True),
         "address": Property(mutable=True),
         "weight": Property(mutable=True),
         "mode": Property(mutable=True),
         "status": Property(),
-        'tags': Property(mutable=True),
+        "tags": Property(mutable=True),
     }
 
     def __init__(self, client, id, parent_id, nodebalancer_id=None, json=None):
@@ -28,8 +36,10 @@ def __init__(self, client, id, parent_id, nodebalancer_id=None, json=None):
         has a parent itself.
         """
         if not nodebalancer_id and not isinstance(parent_id, tuple):
-            raise ValueError('NodeBalancerNode must either be created with a nodebalancer_id or a tuple of '
-                    '(config_id, nodebalancer_id) for parent_id!')
+            raise ValueError(
+                "NodeBalancerNode must either be created with a nodebalancer_id or a tuple of "
+                "(config_id, nodebalancer_id) for parent_id!"
+            )
 
         if isinstance(parent_id, tuple):
             nodebalancer_id = parent_id[1]
@@ -37,17 +47,23 @@ def __init__(self, client, id, parent_id, nodebalancer_id=None, json=None):
 
         DerivedBase.__init__(self, client, id, parent_id, json=json)
 
-        self._set('nodebalancer_id', nodebalancer_id)
+        self._set("nodebalancer_id", nodebalancer_id)
 
 
 class NodeBalancerConfig(DerivedBase):
-    api_endpoint = '/nodebalancers/{nodebalancer_id}/configs/{id}'
-    derived_url_path = 'configs'
-    parent_id_name='nodebalancer_id'
+    """
+    The configuration information for a single port of this NodeBalancer.
+
+    API documentation: https://www.linode.com/docs/api/nodebalancers/#config-view
+    """
+
+    api_endpoint = "/nodebalancers/{nodebalancer_id}/configs/{id}"
+    derived_url_path = "configs"
+    parent_id_name = "nodebalancer_id"
 
     properties = {
-        'id': Property(identifier=True),
-        'nodebalancer_id': Property(identifier=True),
+        "id": Property(identifier=True),
+        "nodebalancer_id": Property(identifier=True),
         "port": Property(mutable=True),
         "protocol": Property(mutable=True),
         "algorithm": Property(mutable=True),
@@ -65,7 +81,7 @@ class NodeBalancerConfig(DerivedBase):
         "ssl_fingerprint": Property(),
         "cipher_suite": Property(mutable=True),
         "nodes_status": Property(),
-        'proxy_protocol': Property(mutable=True),
+        "proxy_protocol": Property(mutable=True),
     }
 
     @property
@@ -73,30 +89,72 @@ def nodes(self):
         """
         This is a special derived_class relationship because NodeBalancerNode is the
         only api object that requires two parent_ids
-        """
-        if not hasattr(self, '_nodes'):
-            base_url = "{}/{}".format(NodeBalancerConfig.api_endpoint, NodeBalancerNode.derived_url_path)
-            result = self._client._get_objects(base_url, NodeBalancerNode, model=self, parent_id=(self.id, self.nodebalancer_id))
 
-            self._set('_nodes', result)
+        Returns a paginated list of NodeBalancer nodes associated with this Config.
+        These are the backends that will be sent traffic for this port.
+
+        API documentation: https://www.linode.com/docs/api/nodebalancers/#nodes-list
+
+        :returns: A paginated list of NodeBalancer nodes.
+        :rtype: PaginatedList of NodeBalancerNode
+        """
+        if not hasattr(self, "_nodes"):
+            base_url = "{}/{}".format(
+                NodeBalancerConfig.api_endpoint,
+                NodeBalancerNode.derived_url_path,
+            )
+            result = self._client._get_objects(
+                base_url,
+                NodeBalancerNode,
+                model=self,
+                parent_id=(self.id, self.nodebalancer_id),
+            )
+
+            self._set("_nodes", result)
 
         return self._nodes
 
     def node_create(self, label, address, **kwargs):
+        """
+        Creates a NodeBalancer Node, a backend that can accept traffic for this
+        NodeBalancer Config. Nodes are routed requests on the configured port based
+        on their status.
+
+        API documentation: https://www.linode.com/docs/api/nodebalancers/#node-create
+
+        :param address: The private IP Address where this backend can be reached.
+                        This must be a private IP address.
+        :type address: str
+
+        :param label: The label for this node. This is for display purposes only.
+                      Must have a length between 2 and 32 characters.
+        :type label: str
+
+        :returns: The node which is created successfully.
+        :rtype: NodeBalancerNode
+        """
         params = {
             "label": label,
             "address": address,
         }
         params.update(kwargs)
 
-        result = self._client.post("{}/nodes".format(NodeBalancerConfig.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/nodes".format(NodeBalancerConfig.api_endpoint),
+            model=self,
+            data=params,
+        )
         self.invalidate()
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response creating node!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response creating node!", json=result
+            )
 
         # this is three levels deep, so we need a special constructor
-        n = NodeBalancerNode(self._client, result['id'], self.id, self.nodebalancer_id, result)
+        n = NodeBalancerNode(
+            self._client, result["id"], self.id, self.nodebalancer_id, result
+        )
         return n
 
     def load_ssl_data(self, cert_file, key_file):
@@ -124,40 +182,60 @@ def load_ssl_data(self, cert_file, key_file):
         # through linode.objects.Base, and pylint isn't privy
         if os.path.isfile(os.path.expanduser(cert_file)):
             with open(os.path.expanduser(cert_file)) as f:
-                self.ssl_cert = f.read() # pylint: disable=attribute-defined-outside-init
+                self.ssl_cert = f.read()
 
         if os.path.isfile(os.path.expanduser(key_file)):
             with open(os.path.expanduser(key_file)) as f:
-                self.ssl_key = f.read() # pylint: disable=attribute-defined-outside-init
+                self.ssl_key = f.read()
 
 
 class NodeBalancer(Base):
-    api_endpoint = '/nodebalancers/{id}'
+    """
+    A single NodeBalancer you can access.
+
+    API documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-view
+    """
+
+    api_endpoint = "/nodebalancers/{id}"
     properties = {
-        'id': Property(identifier=True),
-        'label': Property(mutable=True),
-        'hostname': Property(),
-        'client_conn_throttle': Property(mutable=True),
-        'status': Property(),
-        'created': Property(is_datetime=True),
-        'updated': Property(is_datetime=True),
-        'ipv4': Property(relationship=IPAddress),
-        'ipv6': Property(),
-        'region': Property(slug_relationship=Region, filterable=True),
-        'configs': Property(derived_class=NodeBalancerConfig),
+        "id": Property(identifier=True),
+        "label": Property(mutable=True),
+        "hostname": Property(),
+        "client_conn_throttle": Property(mutable=True),
+        "status": Property(),
+        "created": Property(is_datetime=True),
+        "updated": Property(is_datetime=True),
+        "ipv4": Property(relationship=IPAddress),
+        "ipv6": Property(),
+        "region": Property(slug_relationship=Region),
+        "configs": Property(derived_class=NodeBalancerConfig),
     }
 
     # create derived objects
-    def config_create(self, label=None, **kwargs):
+    def config_create(self, **kwargs):
+        """
+        Creates a NodeBalancer Config, which allows the NodeBalancer to accept traffic
+        on a new port. You will need to add NodeBalancer Nodes to the new Config before
+        it can actually serve requests.
+
+        API documentation: https://www.linode.com/docs/api/nodebalancers/#config-create
+
+        :returns: The config that created successfully.
+        :rtype: NodeBalancerConfig
+        """
         params = kwargs
-        if label:
-            params['label'] = label
 
-        result = self._client.post("{}/configs".format(NodeBalancer.api_endpoint), model=self, data=params)
+        result = self._client.post(
+            "{}/configs".format(NodeBalancer.api_endpoint),
+            model=self,
+            data=params,
+        )
         self.invalidate()
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response creating config!', json=result)
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response creating config!", json=result
+            )
 
-        c = NodeBalancerConfig(self._client, result['id'], self.id, result)
+        c = NodeBalancerConfig(self._client, result["id"], self.id, result)
         return c
diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py
index 9abcfefde..d96256c6b 100644
--- a/linode_api4/objects/object_storage.py
+++ b/linode_api4/objects/object_storage.py
@@ -1,30 +1,514 @@
-from linode_api4.objects import Base, DerivedBase, Image, Property, Region
+from linode_api4.errors import UnexpectedResponseError
+from linode_api4.objects import (
+    Base,
+    DerivedBase,
+    MappedObject,
+    Property,
+    Region,
+)
+from linode_api4.util import drop_null_keys
+
+
+class ObjectStorageACL:
+    PRIVATE = "private"
+    PUBLIC_READ = "public-read"
+    AUTHENTICATED_READ = "authenticated-read"
+    PUBLIC_READ_WRITE = "public-read-write"
+    CUSTOM = "custom"
+
+
+class ObjectStorageBucket(DerivedBase):
+    """
+    A bucket where objects are stored in.
+
+    API documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-view
+    """
+
+    api_endpoint = "/object-storage/buckets/{cluster}/{label}"
+    parent_id_name = "cluster"
+    id_attribute = "label"
+
+    properties = {
+        "cluster": Property(),
+        "created": Property(is_datetime=True),
+        "hostname": Property(),
+        "label": Property(),
+        "objects": Property(),
+        "size": Property(),
+    }
+
+    @classmethod
+    def api_list(cls):
+        """
+        Override this method to return the correct URL that will produce
+        a list of JSON objects of this class' type - Object Storage Bucket.
+        """
+        return "/".join(cls.api_endpoint.split("/")[:-2])
+
+    @classmethod
+    def make_instance(cls, id, client, parent_id=None, json=None):
+        """
+        Override this method to pass in the parent_id from the _raw_json object
+        when it's available.
+        """
+        if json is None:
+            return None
+        if parent_id is None and json["cluster"]:
+            parent_id = json["cluster"]
+
+        if parent_id:
+            return super().make(id, client, cls, parent_id=parent_id, json=json)
+        else:
+            raise UnexpectedResponseError(
+                "Unexpected json response when making a new Object Storage Bucket instance."
+            )
+
+    def access_modify(
+        self,
+        acl: ObjectStorageACL = None,
+        cors_enabled=None,
+    ):
+        """
+        Allows changing basic Cross-origin Resource Sharing (CORS) and Access Control
+        Level (ACL) settings. Only allows enabling/disabling CORS for all origins,
+        and/or setting canned ACLs. For more fine-grained control of both systems,
+        please use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-modify
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :param acl: The Access Control Level of the bucket using a canned ACL string.
+                    For more fine-grained control of ACLs, use the S3 API directly.
+        :type acl: str
+                   Enum: private,public-read,authenticated-read,public-read-write
+
+        :param cors_enabled: If true, the bucket will be created with CORS enabled for
+                             all origins. For more fine-grained controls of CORS, use
+                             the S3 API directly.
+        :type cors_enabled: bool
+        """
+        params = {
+            "acl": acl,
+            "cors_enabled": cors_enabled,
+        }
+
+        resp = self._client.post(
+            "/object-storage/buckets/{}/{}/access".format(
+                self.cluster, self.id
+            ),
+            data=drop_null_keys(params),
+        )
+
+        if "errors" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when modifying the access to a bucket!",
+                json=resp,
+            )
+        return True
+
+    def access_update(
+        self,
+        acl: ObjectStorageACL = None,
+        cors_enabled=None,
+    ):
+        """
+        Allows changing basic Cross-origin Resource Sharing (CORS) and Access Control
+        Level (ACL) settings. Only allows enabling/disabling CORS for all origins,
+        and/or setting canned ACLs. For more fine-grained control of both systems,
+        please use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-update
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :param acl: The Access Control Level of the bucket using a canned ACL string.
+                    For more fine-grained control of ACLs, use the S3 API directly.
+        :type acl: str
+                   Enum: private,public-read,authenticated-read,public-read-write
+
+        :param cors_enabled: If true, the bucket will be created with CORS enabled for
+                             all origins. For more fine-grained controls of CORS,
+                             use the S3 API directly.
+        :type cors_enabled: bool
+        """
+        params = {
+            "acl": acl,
+            "cors_enabled": cors_enabled,
+        }
+
+        resp = self._client.put(
+            "/object-storage/buckets/{}/{}/access".format(
+                self.cluster, self.id
+            ),
+            data=drop_null_keys(params),
+        )
+
+        if "errors" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when updating the access to a bucket!",
+                json=resp,
+            )
+        return True
+
+    def ssl_cert_delete(self):
+        """
+        Deletes this Object Storage bucket’s user uploaded TLS/SSL certificate
+        and private key.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-delete
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :returns: True if the TLS/SSL certificate and private key in the bucket were successfully deleted.
+        :rtype: bool
+        """
+
+        resp = self._client.delete(
+            "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id)
+        )
+
+        if "error" in resp:
+            raise UnexpectedResponseError(
+                "Unexpected response when deleting a bucket!",
+                json=resp,
+            )
+        return True
+
+    def ssl_cert(self):
+        """
+        Returns a result object which wraps a bool value indicating
+        if this bucket has a corresponding TLS/SSL certificate that
+        was uploaded by an Account user.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-view
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :returns: A result object which has a bool field indicating if this Bucket has a corresponding
+                  TLS/SSL certificate that was uploaded by an Account user.
+        :rtype: MappedObject
+        """
+        result = self._client.get(
+            "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id)
+        )
+
+        if not "ssl" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when getting the TLS/SSL certs indicator of a bucket!",
+                json=result,
+            )
+
+        return MappedObject(**result)
+
+    def ssl_cert_upload(self, certificate, private_key):
+        """
+        Upload a TLS/SSL certificate and private key to be served when you
+        visit your Object Storage bucket via HTTPS. Your TLS/SSL certificate and
+        private key are stored encrypted at rest.
+
+        To replace an expired certificate, delete your current certificate and
+        upload a new one.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-upload
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :param certificate: Your Base64 encoded and PEM formatted SSL certificate.
+                            Line breaks must be represented as “\n” in the string
+                            for requests (but not when using the Linode CLI)
+        :type certificate: str
+
+        :param private_key: The private key associated with this TLS/SSL certificate.
+                            Line breaks must be represented as “\n” in the string
+                            for requests (but not when using the Linode CLI)
+        :type private_key: str
+
+        :returns: A result object which has a bool field indicating if this Bucket has a corresponding
+                  TLS/SSL certificate that was uploaded by an Account user.
+        :rtype: MappedObject
+        """
+        params = {
+            "certificate": certificate,
+            "private_key": private_key,
+        }
+        result = self._client.post(
+            "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id),
+            data=params,
+        )
+
+        if not "ssl" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when uploading TLS/SSL certs!",
+                json=result,
+            )
+
+        return MappedObject(**result)
+
+    def contents(
+        self,
+        marker=None,
+        delimiter=None,
+        prefix=None,
+        page_size=100,
+    ):
+        """
+        Returns the contents of a bucket.
+        The contents are paginated using a marker, which is the name of the last object
+        on the previous page. Objects may be filtered by prefix and delimiter as well;
+        see Query Parameters for more information.
+
+        This endpoint is available for convenience.
+        It is recommended that instead you use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-contents-list
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :param marker: The “marker” for this request, which can be used to paginate
+                       through large buckets. Its value should be the value of the
+                       next_marker property returned with the last page. Listing
+                       bucket contents does not support arbitrary page access. See the
+                       next_marker property in the responses section for more details.
+        :type marker: str
+
+        :param delimiter: The delimiter for object names; if given, object names will
+                          be returned up to the first occurrence of this character.
+                          This is most commonly used with the / character to allow
+                          bucket transversal in a manner similar to a filesystem,
+                          however any delimiter may be used. Use in conjunction with
+                          prefix to see object names past the first occurrence of
+                          the delimiter.
+        :type delimiter: str
+
+        :param prefix: Filters objects returned to only those whose name start with
+                       the given prefix. Commonly used in conjunction with delimiter
+                       to allow transversal of bucket contents in a manner similar to
+                       a filesystem.
+        :type perfix: str
+
+        :param page_size: The number of items to return per page. Defaults to 100.
+        :type page_size: int 25..500
+
+        :returns: A list of the MappedObject of the requested bucket's contents.
+        :rtype: [MappedObject]
+        """
+        params = {
+            "marker": marker,
+            "delimiter": delimiter,
+            "prefix": prefix,
+            "page_size": page_size,
+        }
+        result = self._client.get(
+            "/object-storage/buckets/{}/{}/object-list".format(
+                self.cluster, self.id
+            ),
+            data=drop_null_keys(params),
+        )
+
+        if not "data" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when getting the contents of a bucket!",
+                json=result,
+            )
+
+        return [MappedObject(**c) for c in result["data"]]
+
+    def object_acl_config(self, name=None):
+        """
+        View an Object’s configured Access Control List (ACL) in this Object Storage
+        bucket. ACLs define who can access your buckets and objects and specify the
+        level of access granted to those users.
+
+        This endpoint is available for convenience.
+        It is recommended that instead you use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-view
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :param name: The name of the object for which to retrieve its Access Control
+                     List (ACL). Use the Object Storage Bucket Contents List endpoint
+                     to access all object names in a bucket.
+        :type name: str
+
+        :returns: The Object's canned ACL and policy.
+        :rtype: MappedObject
+        """
+        params = {
+            "name": name,
+        }
+
+        result = self._client.get(
+            f"{ObjectStorageBucket.api_endpoint}/object-acl",
+            model=self,
+            data=drop_null_keys(params),
+        )
+
+        if not "acl" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when viewing Object’s configured ACL!",
+                json=result,
+            )
+
+        return MappedObject(**result)
+
+    def object_acl_config_update(self, acl: ObjectStorageACL, name):
+        """
+        Update an Object’s configured Access Control List (ACL) in this Object Storage
+        bucket. ACLs define who can access your buckets and objects and specify the
+        level of access granted to those users.
+
+        This endpoint is available for convenience.
+        It is recommended that instead you use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-update
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :param bucket: The bucket name.
+        :type bucket: str
+
+        :param acl: The Access Control Level of the bucket, as a canned ACL string.
+                    For more fine-grained control of ACLs, use the S3 API directly.
+        :type acl: str
+                   Enum: private,public-read,authenticated-read,public-read-write,custom
+
+        :param name: The name of the object for which to retrieve its Access Control
+                     List (ACL). Use the Object Storage Bucket Contents List endpoint
+                     to access all object names in a bucket.
+        :type name: str
+
+        :returns: The Object's canned ACL and policy.
+        :rtype: MappedObject
+        """
+        params = {
+            "acl": acl,
+            "name": name,
+        }
+
+        result = self._client.put(
+            f"{ObjectStorageBucket.api_endpoint}/object-acl",
+            model=self,
+            data=params,
+        )
+
+        if not "acl" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when updating Object’s configured ACL!",
+                json=result,
+            )
+
+        return MappedObject(**result)
+
+    def access(self, cluster, bucket_name, permissions):
+        """
+        Returns a dict formatted to be included in the `bucket_access` argument
+        of :any:`keys_create`.  See the docs for that method for an example of
+        usage.
+
+        :param cluster: The Object Storage cluster to grant access in.
+        :type cluster: :any:`ObjectStorageCluster` or str
+        :param bucket_name: The name of the bucket to grant access to.
+        :type bucket_name: str
+        :param permissions: The permissions to grant.  Should be one of "read_only"
+                            or "read_write".
+        :type permissions: str
+
+        :returns: A dict formatted correctly for specifying bucket access for
+                  new keys.
+        :rtype: dict
+        """
+        return {
+            "cluster": cluster,
+            "bucket_name": bucket_name,
+            "permissions": permissions,
+        }
 
 
 class ObjectStorageCluster(Base):
     """
     A cluster where Object Storage is available.
+
+    API documentation: https://www.linode.com/docs/api/object-storage/#cluster-view
     """
-    api_endpoint = '/object-storage/clusters/{id}'
+
+    api_endpoint = "/object-storage/clusters/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'region': Property(slug_relationship=Region),
-        'status': Property(),
-        'domain': Property(),
-        'static_site_domain': Property(),
+        "id": Property(identifier=True),
+        "region": Property(slug_relationship=Region),
+        "status": Property(),
+        "domain": Property(),
+        "static_site_domain": Property(),
     }
 
+    def buckets_in_cluster(self, *filters):
+        """
+        Returns a list of Buckets in this cluster belonging to this Account.
+
+        This endpoint is available for convenience.
+        It is recommended that instead you use the more fully-featured S3 API directly.
+
+        API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-in-cluster-list
+
+        :param cluster_id: The ID of the cluster this bucket exists in.
+        :type cluster_id: str
+
+        :returns: A list of Object Storage Buckets that in the requested cluster.
+        :rtype: PaginatedList of ObjectStorageBucket
+        """
+
+        return self._client._get_and_filter(
+            ObjectStorageBucket,
+            *filters,
+            endpoint="/object-storage/buckets/{}".format(self.id),
+        )
+
 
 class ObjectStorageKeys(Base):
     """
     A keypair that allows third-party applications to access Linode Object Storage.
+
+    API documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-view
     """
-    api_endpoint = '/object-storage/keys/{id}'
+
+    api_endpoint = "/object-storage/keys/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'label': Property(mutable=True),
-        'access_key': Property(),
-        'secret_key': Property(),
+        "id": Property(identifier=True),
+        "label": Property(mutable=True),
+        "access_key": Property(),
+        "secret_key": Property(),
+        "bucket_access": Property(),
+        "limited": Property(),
     }
diff --git a/linode_api4/objects/profile.py b/linode_api4/objects/profile.py
index 7c64340e5..1b9be8305 100644
--- a/linode_api4/objects/profile.py
+++ b/linode_api4/objects/profile.py
@@ -3,6 +3,12 @@
 
 
 class AuthorizedApp(Base):
+    """
+    An application with authorized access to an account.
+
+    API Documentation: https://www.linode.com/docs/api/profile/#authorized-app-view
+    """
+
     api_endpoint = "/profile/apps/{id}"
 
     properties = {
@@ -17,6 +23,12 @@ class AuthorizedApp(Base):
 
 
 class PersonalAccessToken(Base):
+    """
+    A Person Access Token associated with a Profile.
+
+    API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-view
+    """
+
     api_endpoint = "/profile/tokens/{id}"
 
     properties = {
@@ -30,58 +42,90 @@ class PersonalAccessToken(Base):
 
 
 class WhitelistEntry(Base):
+    """
+    DEPRECATED: Limited to customers with a feature tag
+    """
+
     api_endpoint = "/profile/whitelist/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'address': Property(),
-        'netmask': Property(),
-        'note': Property(),
+        "id": Property(identifier=True),
+        "address": Property(),
+        "netmask": Property(),
+        "note": Property(),
     }
 
 
 class Profile(Base):
+    """
+    A Profile containing information about the current User.
+
+    API Documentation: https://www.linode.com/docs/api/profile/#profile-view
+    """
+
     api_endpoint = "/profile"
-    id_attribute = 'username'
+    id_attribute = "username"
 
     properties = {
-        'username': Property(identifier=True),
-        'uid': Property(),
-        'email': Property(mutable=True),
-        'timezone': Property(mutable=True),
-        'email_notifications': Property(mutable=True),
-        'referrals': Property(),
-        'ip_whitelist_enabled': Property(mutable=True),
-        'lish_auth_method': Property(mutable=True),
-        'authorized_keys': Property(mutable=True),
-        'two_factor_auth': Property(),
-        'restricted': Property(),
+        "username": Property(identifier=True),
+        "uid": Property(),
+        "email": Property(mutable=True),
+        "timezone": Property(mutable=True),
+        "email_notifications": Property(mutable=True),
+        "referrals": Property(),
+        "ip_whitelist_enabled": Property(mutable=True),
+        "lish_auth_method": Property(mutable=True),
+        "authorized_keys": Property(mutable=True),
+        "two_factor_auth": Property(),
+        "restricted": Property(),
+        "authentication_type": Property(),
+        "authorized_keys": Property(),
+        "verified_phone_number": Property(),
     }
 
     def enable_tfa(self):
         """
         Enables TFA for the token's user.  This requies a follow-up request
         to confirm TFA.  Returns the TFA secret that needs to be confirmed.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#two-factor-secret-create
+
+        :returns: The TFA secret
+        :rtype: str
         """
-        result = self._client.post('/profile/tfa-enable')
+        result = self._client.post("/profile/tfa-enable")
 
-        return result['secret']
+        return result["secret"]
 
     def confirm_tfa(self, code):
         """
         Confirms TFA for an account.  Needs a TFA code generated by enable_tfa
+
+        API Documentation: https://www.linode.com/docs/api/profile/#two-factor-authentication-confirmenable
+
+        :param code: The Two Factor code you generated with your Two Factor secret.
+                     These codes are time-based, so be sure it is current.
+        :type code: str
+
+        :returns: Returns true if operation was successful
+        :rtype: bool
         """
-        self._client.post('/profile/tfa-enable-confirm', data={
-            "tfa_code": code
-        })
+        self._client.post(
+            "/profile/tfa-enable-confirm", data={"tfa_code": code}
+        )
 
         return True
 
     def disable_tfa(self):
         """
         Turns off TFA for this user's account.
+
+        API Documentation: https://www.linode.com/docs/api/profile/#two-factor-authentication-disable
+
+        :returns: Returns true if operation was successful
+        :rtype: bool
         """
-        self._client.post('/profile/tfa-disable')
+        self._client.post("/profile/tfa-disable")
 
         return True
 
@@ -89,9 +133,19 @@ def disable_tfa(self):
     def grants(self):
         """
         Returns grants for the current user
+
+        API Documentation: https://www.linode.com/docs/api/profile/#grants-list
+
+        :returns: The grants for the current user
+        :rtype: UserGrants
         """
-        from linode_api4.objects.account import UserGrants # pylint: disable-all
-        resp = self._client.get('/profile/grants') # use special endpoint for restricted users
+        from linode_api4.objects.account import (  # pylint: disable-all
+            UserGrants,
+        )
+
+        resp = self._client.get(
+            "/profile/grants"
+        )  # use special endpoint for restricted users
 
         grants = None
         if resp is not None:
@@ -104,31 +158,42 @@ def grants(self):
     def whitelist(self):
         """
         Returns the user's whitelist entries, if whitelist is enabled
+
+        DEPRECATED: Limited to customers with a feature tag
         """
         return self._client._get_and_filter(WhitelistEntry)
 
     def add_whitelist_entry(self, address, netmask, note=None):
         """
         Adds a new entry to this user's IP whitelist, if enabled
+
+        DEPRECATED: Limited to customers with a feature tag
         """
-        result = self._client.post("{}/whitelist".format(Profile.api_endpoint),
-                data={
-                    "address": address,
-                    "netmask": netmask,
-                    "note": note,
-        })
+        result = self._client.post(
+            "{}/whitelist".format(Profile.api_endpoint),
+            data={
+                "address": address,
+                "netmask": netmask,
+                "note": note,
+            },
+        )
 
-        if not 'id' in result:
-            raise UnexpectedResponseError("Unexpected response creating whitelist entry!")
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response creating whitelist entry!"
+            )
 
-        return WhitelistEntry(result['id'], self._client, json=result)
+        return WhitelistEntry(result["id"], self._client, json=result)
 
 
 class SSHKey(Base):
     """
     An SSH Public Key uploaded to your profile for use in Linode Instance deployments.
+
+    API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-view
     """
-    api_endpoint = '/profile/sshkeys/{id}'
+
+    api_endpoint = "/profile/sshkeys/{id}"
 
     properties = {
         "id": Property(identifier=True),
@@ -136,3 +201,41 @@ class SSHKey(Base):
         "ssh_key": Property(),
         "created": Property(is_datetime=True),
     }
+
+
+class TrustedDevice(Base):
+    """
+    A Trusted Device for a User.
+
+    API Documentation: https://www.linode.com/docs/api/profile/#trusted-device-view
+    """
+
+    api_endpoint = "/profile/devices/{id}"
+
+    properties = {
+        "id": Property(identifier=True),
+        "created": Property(is_datetime=True),
+        "expiry": Property(is_datetime=True),
+        "last_authenticated": Property(is_datetime=True),
+        "last_remote_addr": Property(),
+        "user_agent": Property(),
+    }
+
+
+class ProfileLogin(Base):
+    """
+    A Login object displaying information about a successful account login from this user.
+
+    API Documentation: https://www.linode.com/docs/api/profile/#login-view
+    """
+
+    api_endpoint = "profile/logins/{id}"
+
+    properties = {
+        "id": Property(identifier=True),
+        "datetime": Property(is_datetime=True),
+        "ip": Property(),
+        "restricted": Property(),
+        "status": Property(),
+        "username": Property(),
+    }
diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py
index 4b3b87e57..a9919f94b 100644
--- a/linode_api4/objects/region.py
+++ b/linode_api4/objects/region.py
@@ -2,11 +2,18 @@
 
 
 class Region(Base):
+    """
+    A Region. Regions correspond to individual data centers, each located in a different geographical area.
+
+    API Documentation: https://www.linode.com/docs/api/regions/#region-view
+    """
+
     api_endpoint = "/regions/{id}"
     properties = {
-        'id': Property(identifier=True),
-        'country': Property(filterable=True),
-        'capabilities': Property(),
-        'status': Property(),
-        'resolvers': Property(),
+        "id": Property(identifier=True),
+        "country": Property(),
+        "capabilities": Property(),
+        "status": Property(),
+        "resolvers": Property(),
+        "label": Property(),
     }
diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py
index 1270c15fa..78d1d86d1 100644
--- a/linode_api4/objects/support.py
+++ b/linode_api4/objects/support.py
@@ -1,101 +1,193 @@
+from pathlib import Path
+from typing import Union
+
 import requests
 
 from linode_api4.errors import ApiError, UnexpectedResponseError
-from linode_api4.objects import (Base, DerivedBase, Domain, Instance, Property,
-                                Volume)
+from linode_api4.objects import (
+    Base,
+    DerivedBase,
+    Domain,
+    Instance,
+    Property,
+    Volume,
+)
 from linode_api4.objects.nodebalancer import NodeBalancer
 
 
 class TicketReply(DerivedBase):
-    api_endpoint = '/support/tickets/{ticket_id}/replies'
-    derived_url_path = 'replies'
-    parent_id_name='ticket_id'
+    """
+    A reply to a Support Ticket.
+
+    API Documentation: https://www.linode.com/docs/api/support/#replies-list
+    """
+
+    api_endpoint = "/support/tickets/{ticket_id}/replies"
+    derived_url_path = "replies"
+    parent_id_name = "ticket_id"
 
     properties = {
-        'id': Property(identifier=True),
-        'ticket_id': Property(identifier=True),
-        'description': Property(),
-        'created': Property(is_datetime=True),
-        'created_by': Property(),
-        'from_linode': Property(),
+        "id": Property(identifier=True),
+        "ticket_id": Property(identifier=True),
+        "description": Property(),
+        "created": Property(is_datetime=True),
+        "created_by": Property(),
+        "from_linode": Property(),
     }
 
 
 class SupportTicket(Base):
-    api_endpoint = '/support/tickets/{id}'
+    """
+    An objected representing a Linode Support Ticket.
+
+    API Documentation: https://www.linode.com/docs/api/support/#replies-list
+    """
+
+    api_endpoint = "/support/tickets/{id}"
     properties = {
-        'id': Property(identifier=True),
-        'summary': Property(),
-        'description': Property(),
-        'status': Property(filterable=True),
-        'entity': Property(),
-        'opened': Property(is_datetime=True),
-        'closed': Property(is_datetime=True),
-        'updated': Property(is_datetime=True),
-        'updated_by': Property(),
-        'replies': Property(derived_class=TicketReply),
+        "id": Property(identifier=True),
+        "summary": Property(),
+        "description": Property(),
+        "status": Property(),
+        "entity": Property(),
+        "opened": Property(is_datetime=True),
+        "closed": Property(is_datetime=True),
+        "updated": Property(is_datetime=True),
+        "updated_by": Property(),
+        "replies": Property(derived_class=TicketReply),
+        "attachments": Property(),
+        "closable": Property(),
+        "gravatar_id": Property(),
+        "opened_by": Property(),
     }
 
     @property
     def linode(self):
-        if self.entity and self.entity.type == 'linode':
+        """
+        If applicable, the Linode referenced in this ticket.
+
+        :returns: The Linode referenced in this ticket.
+        :rtype: Optional[Instance]
+        """
+
+        if self.entity and self.entity.type == "linode":
             return Instance(self._client, self.entity.id)
         return None
 
     @property
     def domain(self):
-        if self.entity and self.entity.type == 'domain':
+        """
+        If applicable, the Domain referenced in this ticket.
+
+        :returns: The Domain referenced in this ticket.
+        :rtype: Optional[Domain]
+        """
+
+        if self.entity and self.entity.type == "domain":
             return Domain(self._client, self.entity.id)
         return None
 
     @property
     def nodebalancer(self):
-        if self.entity and self.entity.type == 'nodebalancer':
+        """
+        If applicable, the NodeBalancer referenced in this ticket.
+
+        :returns: The NodeBalancer referenced in this ticket.
+        :rtype: Optional[NodeBalancer]
+        """
+
+        if self.entity and self.entity.type == "nodebalancer":
             return NodeBalancer(self._client, self.entity.id)
         return None
 
     @property
     def volume(self):
-        if self.entity and self.entity.type == 'volume':
+        """
+        If applicable, the Volume referenced in this ticket.
+
+        :returns: The Volume referenced in this ticket.
+        :rtype: Optional[Volume]
+        """
+
+        if self.entity and self.entity.type == "volume":
             return Volume(self._client, self.entity.id)
         return None
 
     def post_reply(self, description):
         """
+        Adds a reply to an existing Support Ticket.
+
+        API Documentation: https://www.linode.com/docs/api/support/#reply-create
+
+        :param description: The content of this Support Ticket Reply.
+        :type description: str
+
+        :returns: The new TicketReply object.
+        :rtype: Optional[TicketReply]
         """
-        result = self._client.post("{}/replies".format(SupportTicket.api_endpoint), model=self, data={
-            "description": description,
-        })
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when creating ticket reply!',
-                    json=result)
+        result = self._client.post(
+            "{}/replies".format(SupportTicket.api_endpoint),
+            model=self,
+            data={
+                "description": description,
+            },
+        )
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when creating ticket reply!", json=result
+            )
 
-        r = TicketReply(self._client, result['id'], self.id, result)
+        r = TicketReply(self._client, result["id"], self.id, result)
         return r
 
-    def upload_attachment(self, attachment):
-        content = None
-        with open(attachment) as f:
-            content = f.read()
+    def upload_attachment(self, attachment: Union[Path, str]):
+        """
+        Uploads an attachment to an existing Support Ticket.
+
+        API Documentation: https://www.linode.com/docs/api/support/#support-ticket-attachment-create
 
-        if not content:
-            raise ValueError('Nothing to upload!')
+        :param attachment: A path to the file to upload as an attachment.
+        :type attachment: str
+
+        :returns: Whether the upload operation was successful.
+        :rtype: bool
+        """
+        if not isinstance(attachment, Path):
+            attachment = Path(attachment)
+
+        if not attachment.exists():
+            raise ValueError("File not exist, nothing to upload.")
 
         headers = {
-            "Authorization": "token {}".format(self._client.token),
-            "Content-type": "multipart/form-data",
+            "Authorization": "Bearer {}".format(self._client.token),
         }
 
-        result = requests.post('{}{}/attachments'.format(self._client.base_url,
-                SupportTicket.api_endpoint.format(id=self.id)),
-                headers=headers, files=content)
+        with open(attachment, "rb") as f:
+            result = requests.post(
+                "{}{}/attachments".format(
+                    self._client.base_url,
+                    SupportTicket.api_endpoint.format(id=self.id),
+                ),
+                headers=headers,
+                files={"file": f},
+            )
 
         if not result.status_code == 200:
             errors = []
             j = result.json()
-            if 'errors' in j:
-                errors = [ e['reason'] for e in j['errors'] ]
-            raise ApiError('{}: {}'.format(result.status_code, errors), json=j)
+            if "errors" in j:
+                errors = [e["reason"] for e in j["errors"]]
+            raise ApiError("{}: {}".format(result.status_code, errors), json=j)
 
         return True
+
+    def support_ticket_close(self):
+        """
+        Closes a Support Ticket.
+
+        API Documentation: https://www.linode.com/docs/api/support/#support-ticket-close
+        """
+
+        self._client.post("{}/close".format(self.api_endpoint), model=self)
diff --git a/linode_api4/objects/tag.py b/linode_api4/objects/tag.py
index 773a99af8..5a604d445 100644
--- a/linode_api4/objects/tag.py
+++ b/linode_api4/objects/tag.py
@@ -1,30 +1,36 @@
-import string
-import sys
-from datetime import datetime
-from enum import Enum
-from os import urandom
-from random import randint
-
 from linode_api4.errors import UnexpectedResponseError
+from linode_api4.objects import (
+    Base,
+    DerivedBase,
+    Domain,
+    Instance,
+    NodeBalancer,
+    Property,
+    Volume,
+)
 from linode_api4.paginated_list import PaginatedList
-from linode_api4.objects import (Base, DerivedBase, Property, Instance, Volume,
-                                 NodeBalancer, Domain)
-
 
 CLASS_MAP = {
-    'linode': Instance,
-    'domain': Domain,
-    'nodebalancer': NodeBalancer,
-    'volume': Volume,
+    "linode": Instance,
+    "domain": Domain,
+    "nodebalancer": NodeBalancer,
+    "volume": Volume,
 }
 
 
 class Tag(Base):
-    api_endpoint = '/tags/{label}'
-    id_attribute = 'label'
+    """
+    A User-defined labels attached to objects in your Account, such as Linodes.
+    Used for specifying and grouping attributes of objects that are relevant to the User.
+
+    API Documentation: https://www.linode.com/docs/api/tags/#tags-list
+    """
+
+    api_endpoint = "/tags/{label}"
+    id_attribute = "label"
 
     properties = {
-        'label': Property(identifier=True),
+        "label": Property(identifier=True),
     }
 
     def _get_raw_objects(self):
@@ -33,12 +39,12 @@ def _get_raw_objects(self):
         This has the side effect of creating the ``_raw_objects`` attribute of
         this object.
         """
-        if not hasattr(self, '_raw_objects'):
+        if not hasattr(self, "_raw_objects"):
             result = self._client.get(type(self).api_endpoint, model=self)
 
             # I want to cache this to avoid making duplicate requests, but I don't
             # want it in the __init__
-            self._raw_objects = result # pylint: disable=attribute-defined-outside-init
+            self._raw_objects = result
 
         return self._raw_objects
 
@@ -59,11 +65,20 @@ def objects(self):
         """
         Returns a list of objects with this Tag.  This list may contain any
         taggable object type.
+
+        API Documentation: https://www.linode.com/docs/api/tags/#tagged-objects-list
+
+        :returns: Objects with this Tag
+        :rtype: PaginatedList of objects with this Tag
         """
         data = self._get_raw_objects()
 
-        return PaginatedList.make_paginated_list(data, self._client, TaggedObjectProxy,
-                                                 page_url=type(self).api_endpoint.format(**vars(self)))
+        return PaginatedList.make_paginated_list(
+            data,
+            self._client,
+            TaggedObjectProxy,
+            page_url=type(self).api_endpoint.format(**vars(self)),
+        )
 
 
 class TaggedObjectProxy:
@@ -77,11 +92,14 @@ class TaggedObjectProxy:
        enveloped objects returned from the tagged objects collection, and should
        only be used in that context.
     """
-    id_attribute = 'type' # the envelope containing tagged objects has a `type` field
-                          # that defined what type of object is in the envelope.  We'll
-                          # use that as the ID for the proxy class so ``make_instance``
-                          # below can easily tell what type it should actually be
-                          # making and returning.
+
+    id_attribute = (
+        "type"  # the envelope containing tagged objects has a `type` field
+    )
+    # that defined what type of object is in the envelope.  We'll
+    # use that as the ID for the proxy class so ``make_instance``
+    # below can easily tell what type it should actually be
+    # making and returning.
 
     @classmethod
     def make_instance(cls, id, client, parent_id=None, json=None):
@@ -96,16 +114,21 @@ def make_instance(cls, id, client, parent_id=None, json=None):
         :param json: The JSON to populate the instance with
 
         :returns: A new instance of this type, populated with json
+        :rtype: TaggedObjectProxy
         """
-        make_cls = CLASS_MAP.get(id) # in this case, ID is coming in as the type
+        make_cls = CLASS_MAP.get(
+            id
+        )  # in this case, ID is coming in as the type
 
         if make_cls is None:
             # we don't recognize this entity type - do nothing?
             return None
 
         # discard the envelope
-        real_json = json['data']
-        real_id = real_json['id']
+        real_json = json["data"]
+        real_id = real_json["id"]
 
         # make the real object type
-        return Base.make(real_id, client, make_cls, parent_id=None, json=real_json)
+        return Base.make(
+            real_id, client, make_cls, parent_id=None, json=real_json
+        )
diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py
index aef36893c..c40619187 100644
--- a/linode_api4/objects/volume.py
+++ b/linode_api4/objects/volume.py
@@ -3,35 +3,63 @@
 
 
 class Volume(Base):
-    api_endpoint = '/volumes/{id}'
+    """
+    A single Block Storage Volume. Block Storage Volumes are persistent storage devices
+    that can be attached to a Compute Instance and used to store any type of data.
+
+    API Documentation: https://www.linode.com/docs/api/volumes/#volume-view
+    """
+
+    api_endpoint = "/volumes/{id}"
 
     properties = {
-        'id': Property(identifier=True),
-        'created': Property(is_datetime=True),
-        'updated': Property(is_datetime=True),
-        'linode_id': Property(id_relationship=Instance),
-        'label': Property(mutable=True, filterable=True),
-        'size': Property(filterable=True),
-        'status': Property(filterable=True),
-        'region': Property(slug_relationship=Region),
-        'tags': Property(mutable=True),
-        'filesystem_path': Property(),
-        'hardware_type': Property(),
-        'linode_label': Property(),
+        "id": Property(identifier=True),
+        "created": Property(is_datetime=True),
+        "updated": Property(is_datetime=True),
+        "linode_id": Property(id_relationship=Instance),
+        "label": Property(mutable=True),
+        "size": Property(),
+        "status": Property(),
+        "region": Property(slug_relationship=Region),
+        "tags": Property(mutable=True),
+        "filesystem_path": Property(),
+        "hardware_type": Property(),
+        "linode_label": Property(),
     }
 
     def attach(self, to_linode, config=None):
         """
-        Attaches this Volume to the given Linode
-        """
-        result = self._client.post('{}/attach'.format(Volume.api_endpoint), model=self,
-                data={
-                    "linode_id": to_linode.id if issubclass(type(to_linode), Base) else to_linode,
-                    "config": None if not config else config.id if issubclass(type(config), Base) else config,
-        })
+        Attaches this Volume to the given Linode.
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response when attaching volume!', json=result)
+        API Documentation: https://www.linode.com/docs/api/volumes/#volume-attach
+
+        :param to_linode: The ID or object of the Linode to attach the volume to.
+        :type to_linode: Union[Instance, int]
+
+        :param config: The ID or object of the Linode Config to include this Volume in.
+                       Must belong to the Linode referenced by linode_id.
+                       If not given, the last booted Config will be chosen.
+        :type config: Union[Config, int]
+        """
+        result = self._client.post(
+            "{}/attach".format(Volume.api_endpoint),
+            model=self,
+            data={
+                "linode_id": to_linode.id
+                if issubclass(type(to_linode), Base)
+                else to_linode,
+                "config": None
+                if not config
+                else config.id
+                if issubclass(type(config), Base)
+                else config,
+            },
+        )
+
+        if not "id" in result:
+            raise UnexpectedResponseError(
+                "Unexpected response when attaching volume!", json=result
+            )
 
         self._populate(result)
         return True
@@ -39,17 +67,33 @@ def attach(self, to_linode, config=None):
     def detach(self):
         """
         Detaches this Volume if it is attached
+
+        API Documentation: https://www.linode.com/docs/api/volumes/#volume-detach
+
+        :returns: Returns true if operation was successful
+        :rtype: bool
         """
-        self._client.post('{}/detach'.format(Volume.api_endpoint), model=self)
+        self._client.post("{}/detach".format(Volume.api_endpoint), model=self)
 
         return True
 
     def resize(self, size):
         """
         Resizes this Volume
+
+        API Documentation: https://www.linode.com/docs/api/volumes/#volume-resize
+
+        :param size: The Volume’s size, in GiB.
+        :type size: int
+
+        :returns: Returns true if operation was successful
+        :rtype: bool
         """
-        result = self._client.post('{}/resize'.format(Volume.api_endpoint), model=self,
-            data={ "size": size })
+        result = self._client.post(
+            "{}/resize".format(Volume.api_endpoint),
+            model=self,
+            data={"size": size},
+        )
 
         self._populate(result)
 
@@ -59,14 +103,21 @@ def clone(self, label):
         """
         Clones this volume to a new volume in the same region with the given label
 
+        API Documentation: https://www.linode.com/docs/api/volumes/#volume-clone
+
         :param label: The label for the new volume.
+        :type label: str
 
         :returns: The new volume object.
+        :rtype: Volume
         """
-        result = self._client.post('{}/clone'.format(Volume.api_endpoint),
-                model=self, data={'label': label})
+        result = self._client.post(
+            "{}/clone".format(Volume.api_endpoint),
+            model=self,
+            data={"label": label},
+        )
 
-        if not 'id' in result:
-            raise UnexpectedResponseError('Unexpected response cloning volume!')
+        if not "id" in result:
+            raise UnexpectedResponseError("Unexpected response cloning volume!")
 
-        return Volume(self._client, result['id'], result)
+        return Volume(self._client, result["id"], result)
diff --git a/linode_api4/paginated_list.py b/linode_api4/paginated_list.py
index c6b51b629..1db5bfc5d 100644
--- a/linode_api4/paginated_list.py
+++ b/linode_api4/paginated_list.py
@@ -29,19 +29,30 @@ class PaginatedList(object):
 
     This will _not_ emit another API request.
     """
-    def __init__(self, client, page_endpoint, page=[], max_pages=1,
-            total_items=None, parent_id=None, filters=None):
+
+    def __init__(
+        self,
+        client,
+        page_endpoint,
+        page=[],
+        max_pages=1,
+        total_items=None,
+        parent_id=None,
+        filters=None,
+    ):
         self.client = client
         self.page_endpoint = page_endpoint
         self.query_filters = filters
         self.page_size = len(page)
         self.max_pages = max_pages
-        self.lists = [ None for _ in range(0, self.max_pages) ]
+        self.lists = [None for _ in range(0, self.max_pages)]
         if self.lists:
             self.lists[0] = page
-        self.list_cls = type(page[0]) if page else None # TODO if this is None that's bad
+        self.list_cls = (
+            type(page[0]) if page else None
+        )  # TODO if this is None that's bad
         self.objects_parent_id = parent_id
-        self.cur = 0 # for being a generator
+        self.cur = 0  # for being a generator
 
         self.total_items = total_items
         if not total_items:
@@ -85,14 +96,24 @@ def __repr__(self):
         return "PaginatedList ({} items)".format(self.total_items)
 
     def _load_page(self, page_number):
-        j = self.client.get("/{}?page={}&page_size={}".format(self.page_endpoint, page_number+1, self.page_size),
-                filters=self.query_filters)
-
-        if j['pages'] != self.max_pages or j['results'] != len(self):
-            raise RuntimeError('List {} has changed since creation!'.format(self))
-
-        l = PaginatedList.make_list(j["data"], self.client, self.list_cls,
-                parent_id=self.objects_parent_id)
+        j = self.client.get(
+            "/{}?page={}&page_size={}".format(
+                self.page_endpoint, page_number + 1, self.page_size
+            ),
+            filters=self.query_filters,
+        )
+
+        if j["pages"] != self.max_pages or j["results"] != len(self):
+            raise RuntimeError(
+                "List {} has changed since creation!".format(self)
+            )
+
+        l = PaginatedList.make_list(
+            j["data"],
+            self.client,
+            self.list_cls,
+            parent_id=self.objects_parent_id,
+        )
         self.lists[page_number] = l
 
     def __getitem__(self, index):
@@ -104,12 +125,12 @@ def __getitem__(self, index):
         if index < 0:
             index = len(self) + index
             if index < 0:
-                raise IndexError('list index out of range')
+                raise IndexError("list index out of range")
 
         if index >= self.page_size * self.max_pages:
-            raise IndexError('list index out of range')
+            raise IndexError("list index out of range")
         normalized_index = index % self.page_size
-        target_page = math.ceil((index+1.0)/self.page_size)-1
+        target_page = math.ceil((index + 1.0) / self.page_size) - 1
         target_page = int(target_page)
 
         if not self.lists[target_page]:
@@ -127,7 +148,9 @@ def _get_slice(self, s):
 
         # we do not support steps outside of 1 yet
         if s.step is not None and s.step != 1:
-            raise NotImplementedError('Only step sizes of 1 are currently supported.')
+            raise NotImplementedError(
+                "Only step sizes of 1 are currently supported."
+            )
 
         # if i or j are negative, normalize them
         if i < 0:
@@ -138,7 +161,7 @@ def _get_slice(self, s):
 
         # if i or j are still negative, that's an IndexError
         if i < 0 or j < 0:
-            raise IndexError('list index out of range')
+            raise IndexError("list index out of range")
 
         # if we're going nowhere or backward, return nothing
         if j <= i:
@@ -152,15 +175,17 @@ def _get_slice(self, s):
         return result
 
     def __setitem__(self, index, value):
-        raise AttributeError('Assigning to indicies in paginated lists is not supported')
+        raise AttributeError(
+            "Assigning to indicies in paginated lists is not supported"
+        )
 
     def __delitem__(self, index):
-        raise AttributeError('Deleting from paginated lists is not supported')
+        raise AttributeError("Deleting from paginated lists is not supported")
 
     def __next__(self):
         if self.cur < len(self):
             self.cur += 1
-            return self[self.cur-1]
+            return self[self.cur - 1]
         else:
             raise StopIteration()
 
@@ -181,10 +206,13 @@ def make_list(json_arr, client, cls, parent_id=None):
         for obj in json_arr:
             id_val = None
 
-            if 'id' in obj:
-                id_val = obj['id']
-            elif hasattr(cls, 'id_attribute') and getattr(cls, 'id_attribute') in obj:
-                id_val = obj[getattr(cls, 'id_attribute')]
+            if "id" in obj:
+                id_val = obj["id"]
+            elif (
+                hasattr(cls, "id_attribute")
+                and getattr(cls, "id_attribute") in obj
+            ):
+                id_val = obj[getattr(cls, "id_attribute")]
             else:
                 continue
             o = cls.make_instance(id_val, client, parent_id=parent_id, json=obj)
@@ -193,8 +221,9 @@ def make_list(json_arr, client, cls, parent_id=None):
         return result
 
     @staticmethod
-    def make_paginated_list(json, client, cls, parent_id=None, page_url=None,
-            filters=None):
+    def make_paginated_list(
+        json, client, cls, parent_id=None, page_url=None, filters=None
+    ):
         """
         Returns a PaginatedList populated with the first page of data provided,
         and the ability to load additional pages.  This should not be called
@@ -212,7 +241,16 @@ def make_paginated_list(json, client, cls, parent_id=None, page_url=None,
         :returns: An instance of PaginatedList that will represent the entire
                   collection whose first page is json
         """
-        l = PaginatedList.make_list(json["data"], client, cls, parent_id=parent_id)
-        p = PaginatedList(client, page_url, page=l, max_pages=json['pages'],
-                total_items=json['results'], parent_id=parent_id, filters=filters)
+        l = PaginatedList.make_list(
+            json["data"], client, cls, parent_id=parent_id
+        )
+        p = PaginatedList(
+            client,
+            page_url,
+            page=l,
+            max_pages=json["pages"],
+            total_items=json["results"],
+            parent_id=parent_id,
+            filters=filters,
+        )
         return p
diff --git a/linode_api4/util.py b/linode_api4/util.py
index 8dac5d35a..3a96fbc05 100644
--- a/linode_api4/util.py
+++ b/linode_api4/util.py
@@ -1,7 +1,7 @@
 """
 Contains various utility functions.
 """
-from typing import Dict, Any
+from typing import Any, Dict
 
 
 def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]:
@@ -14,7 +14,11 @@ def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]:
 
     def recursive_helper(value: Any) -> Any:
         if isinstance(value, dict):
-            return {k: recursive_helper(v) for k, v in value.items() if v is not None}
+            return {
+                k: recursive_helper(v)
+                for k, v in value.items()
+                if v is not None
+            }
 
         if isinstance(value, list):
             return [recursive_helper(v) for v in value]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000..d04b49d84
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,20 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[tool.isort]
+profile = "black"
+line_length = 80
+
+[tool.black]
+line-length = 80
+target-version = ["py37", "py38", "py39", "py310", "py311"]
+
+[tool.autoflake]
+expand-star-imports = false
+ignore-init-module-imports = true
+ignore-pass-after-docstring = true
+in-place = true
+recursive = true
+remove-all-unused-imports = false
+remove-duplicate-keys = false
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 0e3856ab2..a731835b8 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,3 +1,7 @@
+black>=23.1.0
+isort>=5.12.0
+autoflake>=2.0.1
+pylint
 mock>=5.0.0
 tox>=4.4.0
 Sphinx>=6.0.0
diff --git a/setup.py b/setup.py
index ede7e8f41..44a78c465 100755
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@
 Based on a template here:
 https://github.com/pypa/sampleproject/blob/master/setup.py
 """
-
+import os
 # Always prefer setuptools over distutils
 import sys
 # To use a consistent encoding
@@ -17,21 +17,58 @@
 
 here = path.abspath(path.dirname(__file__))
 
+
 def get_test_suite():
     test_loader = TestLoader()
     return test_loader.discover('test', pattern='*_test.py')
 
+
+def get_baked_version():
+    """
+    Attempts to read the version from the baked_version file
+    """
+    with open("./baked_version", "r", encoding="utf-8") as f:
+        result = f.read()
+
+    return result
+
+
+def bake_version(v):
+    """
+    Writes the given version to the baked_version file
+    """
+    with open("./baked_version", "w", encoding="utf-8") as f:
+        f.write(v)
+
+
 # Get the long description from the README file
 with open(path.join(here, 'README.rst'), encoding='utf-8') as f:
     long_description = f.read()
 
+# If there's already a baked version, use it rather than attempting
+# to resolve the version from env.
+# This is useful for installing from an SDist where the version
+# cannot be dynamically resolved.
+#
+# NOTE: baked_version is deleted when running `make build` and `make install`,
+# so it should always be recreated during the build process.
+if path.isfile("baked_version"):
+    version = get_baked_version()
+else:
+    # Otherwise, retrieve and bake the version as normal
+    version = os.getenv("LINODE_SDK_VERSION", "0.0.0")
+    bake_version(version)
+
+if version.startswith("v"):
+    version = version[1:]
+
 setup(
     name='linode_api4',
 
     # Versions should comply with PEP440.  For a discussion on single-sourcing
     # the version across setup.py and the project code, see
     # https://packaging.python.org/en/latest/single_source_version.html
-    version='5.3.0',
+    version=version,
 
     description='The official python SDK for Linode API v4',
     long_description=long_description,
@@ -65,9 +102,11 @@ def get_test_suite():
         # that you indicate whether you support Python 2, Python 3 or both.
         'Programming Language :: Python',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
+        'Programming Language :: Python :: 3.10',
+        'Programming Language :: Python :: 3.11',
     ],
 
     # What does your project relate to?
@@ -78,7 +117,7 @@ def get_test_suite():
     packages=find_packages(exclude=['contrib', 'docs', 'test', 'test.*']),
 
     # What do we need for this to run
-    python_requires=">=3.6",
+    python_requires=">=3.7",
 
     install_requires=[
         "requests",
@@ -87,5 +126,5 @@ def get_test_suite():
     extras_require={
         "test": ["tox"],
     },
-    test_suite = 'setup.get_test_suite'
+    test_suite='setup.get_test_suite'
 )


Test patch:
diff --git a/test/base.py b/test/base.py
index 1dd39d528..f1e65d8ef 100644
--- a/test/base.py
+++ b/test/base.py
@@ -9,6 +9,7 @@
 
 FIXTURES = TestFixtures()
 
+
 class MockResponse:
     def __init__(self, status_code, json, headers={}):
         self.status_code = status_code
@@ -30,7 +31,7 @@ def load_json(url):
     """
     formatted_url = url
 
-    while formatted_url.startswith('/'):
+    while formatted_url.startswith("/"):
         formatted_url = formatted_url[1:]
 
     return FIXTURES.get_fixture(formatted_url)
@@ -50,6 +51,7 @@ class MethodMock:
     This class is used to mock methods on requests and store the parameters
     and headers it was called with.
     """
+
     def __init__(self, method, return_dct):
         """
         Creates and initiates a new MethodMock with the given details
@@ -64,16 +66,18 @@ def __init__(self, method, return_dct):
         elif isinstance(return_dct, str):
             self.return_dct = load_json(return_dct)
         else:
-            raise TypeError('return_dct must be a dict or a URL from which the '
-                            'JSON could be loaded')
+            raise TypeError(
+                "return_dct must be a dict or a URL from which the "
+                "JSON could be loaded"
+            )
 
     def __enter__(self):
         """
         Begins the method mocking
         """
         self.patch = patch(
-            'linode_api4.linode_client.requests.Session.'+self.method,
-            return_value=MockResponse(200, self.return_dct)
+            "linode_api4.linode_client.requests.Session." + self.method,
+            return_value=MockResponse(200, self.return_dct),
         )
         self.mock = self.patch.start()
         return self
@@ -96,8 +100,7 @@ def call_data_raw(self):
         """
         A shortcut to access the raw call data, not parsed as JSON
         """
-        return self.mock.call_args[1]['data']
-
+        return self.mock.call_args[1]["data"]
 
     @property
     def call_url(self):
@@ -114,30 +117,38 @@ def call_data(self):
         A shortcut to getting the data param this was called with.  Removes all
         keys whose values are None
         """
-        data = json.loads(self.mock.call_args[1]['data'])
+        data = json.loads(self.mock.call_args[1]["data"])
 
-        return { k: v for k, v in data.items() if v is not None }
+        return {k: v for k, v in data.items() if v is not None}
 
     @property
     def call_headers(self):
         """
         A shortcut to getting the headers param this was called with
         """
-        return self.mock.call_args[1]['headers']
+        return self.mock.call_args[1]["headers"]
+
+    @property
+    def called(self):
+        """
+        A shortcut to check whether the mock function was called.
+        """
+        return self.mock.called
 
 
 class ClientBaseCase(TestCase):
     def setUp(self):
-        self.client = LinodeClient('testing', base_url='/')
+        self.client = LinodeClient("testing", base_url="/")
 
-        self.get_patch = patch('linode_api4.linode_client.requests.Session.get',
-                side_effect=mock_get)
+        self.get_patch = patch(
+            "linode_api4.linode_client.requests.Session.get",
+            side_effect=mock_get,
+        )
         self.get_patch.start()
 
     def tearDown(self):
         self.get_patch.stop()
 
-
     def mock_get(self, return_dct):
         """
         Returns a MethodMock mocking a GET.  This should be used in a with
@@ -148,7 +159,7 @@ def mock_get(self, return_dct):
         :returns: A MethodMock object who will capture the parameters of the
             mocked requests
         """
-        return MethodMock('get', return_dct)
+        return MethodMock("get", return_dct)
 
     def mock_post(self, return_dct):
         """
@@ -160,7 +171,7 @@ def mock_post(self, return_dct):
         :returns: A MethodMock object who will capture the parameters of the
             mocked requests
         """
-        return MethodMock('post', return_dct)
+        return MethodMock("post", return_dct)
 
     def mock_put(self, return_dct):
         """
@@ -172,7 +183,7 @@ def mock_put(self, return_dct):
         :returns: A MethodMock object who will capture the parameters of the
             mocked requests
         """
-        return MethodMock('put', return_dct)
+        return MethodMock("put", return_dct)
 
     def mock_delete(self):
         """
@@ -184,4 +195,4 @@ def mock_delete(self):
         :returns: A MethodMock object who will capture the parameters of the
             mocked requests
         """
-        return MethodMock('delete', {})
+        return MethodMock("delete", {})
diff --git a/test/fixtures.py b/test/fixtures.py
index cd1e63b80..a4609b22c 100644
--- a/test/fixtures.py
+++ b/test/fixtures.py
@@ -2,7 +2,8 @@
 import os
 import sys
 
-FIXTURES_DIR = sys.path[0] + '/test/fixtures'
+FIXTURES_DIR = sys.path[0] + "/test/fixtures"
+
 
 class TestFixtures:
     def __init__(self):
@@ -16,7 +17,7 @@ def get_fixture(self, url):
         Returns the test fixture data loaded at the given URL
         """
         return self.fixtures[url]
-    
+
     def _load_fixtures(self):
         """
         Handles loading JSON files and parsing them into responses.  Also splits
@@ -25,20 +26,20 @@ def _load_fixtures(self):
         self.fixtures = {}
 
         for json_file in os.listdir(FIXTURES_DIR):
-            if not json_file.endswith('.json'):
+            if not json_file.endswith(".json"):
                 continue
 
-            with open(FIXTURES_DIR + '/' + json_file) as f:
+            with open(FIXTURES_DIR + "/" + json_file) as f:
                 raw = f.read()
 
             data = json.loads(raw)
 
-            fixture_url = json_file.replace('_', '/')[:-5]
-            
+            fixture_url = json_file.replace("_", "/")[:-5]
+
             self.fixtures[fixture_url] = data
 
-            if 'results' in data:
+            if "results" in data:
                 # this is a paginated response
-                for obj in data['data']:
-                    if 'id' in obj: # tags don't have ids
-                        self.fixtures[fixture_url + '/' + str(obj['id'])] = obj
+                for obj in data["data"]:
+                    if "id" in obj:  # tags, obj-buckets don't have ids
+                        self.fixtures[fixture_url + "/" + str(obj["id"])] = obj
diff --git a/test/fixtures/account.json b/test/fixtures/account.json
index ef292d932..1d823798b 100644
--- a/test/fixtures/account.json
+++ b/test/fixtures/account.json
@@ -17,5 +17,21 @@
     "NodeBalancers",
     "Block Storage",
     "Object Storage"
-  ]
+  ],
+  "active_promotions": [
+    {
+      "credit_monthly_cap": "10.00",
+      "credit_remaining": "50.00",
+      "description": "Receive up to $10 off your services every month for 6 months! Unused credits will expire once this promotion period ends.",
+      "expire_dt": "2018-01-31T23:59:59",
+      "image_url": "https://linode.com/10_a_month_promotion.svg",
+      "service_type": "all",
+      "summary": "$10 off your Linode a month!",
+      "this_month_credit_remaining": "10.00"
+    }
+  ],
+  "active_since": "2018-01-01T00:01:01",
+  "balance_uninvoiced": 145,
+  "billing_source": "akamai",
+  "euuid": "E1AF5EEC-526F-487D-B317EBEB34C87D71"
 }
diff --git a/test/fixtures/account_events_123.json b/test/fixtures/account_events_123.json
new file mode 100644
index 000000000..4c2b7141d
--- /dev/null
+++ b/test/fixtures/account_events_123.json
@@ -0,0 +1,27 @@
+{
+    "action": "ticket_create",
+    "created": "2018-01-01T00:01:01",
+    "duration": 300.56,
+    "entity": {
+      "id": 11111,
+      "label": "Problem booting my Linode",
+      "type": "ticket",
+      "url": "/v4/support/tickets/11111"
+    },
+    "id": 123,
+    "message": "None",
+    "percent_complete": null,
+    "rate": null,
+    "read": true,
+    "secondary_entity": {
+      "id": "linode/debian9",
+      "label": "linode1234",
+      "type": "linode",
+      "url": "/v4/linode/instances/1234"
+    },
+    "seen": true,
+    "status": null,
+    "time_remaining": null,
+    "username": "exampleUser"
+  }
+  
\ No newline at end of file
diff --git a/test/fixtures/account_invoices_123.json b/test/fixtures/account_invoices_123.json
new file mode 100644
index 000000000..e20fe4de6
--- /dev/null
+++ b/test/fixtures/account_invoices_123.json
@@ -0,0 +1,14 @@
+{
+    "date": "2018-01-01T00:01:01",
+    "id": 123,
+    "label": "Invoice",
+    "subtotal": 120.25,
+    "tax": 12.25,
+    "tax_summary": [
+      {
+        "name": "PA STATE TAX",
+        "tax": 12.25
+      }
+    ],
+    "total": 132.5
+}
diff --git a/test/fixtures/account_logins.json b/test/fixtures/account_logins.json
new file mode 100644
index 000000000..9c54581b1
--- /dev/null
+++ b/test/fixtures/account_logins.json
@@ -0,0 +1,15 @@
+{
+    "data": [
+      {
+        "datetime": "2018-01-01T00:01:01",
+        "id": 1234,
+        "ip": "192.0.2.0",
+        "restricted": true,
+        "status": "successful",
+        "username": "test-user"
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
diff --git a/test/fixtures/account_logins_123.json b/test/fixtures/account_logins_123.json
new file mode 100644
index 000000000..3ec95d1ba
--- /dev/null
+++ b/test/fixtures/account_logins_123.json
@@ -0,0 +1,8 @@
+{
+    "datetime": "2018-01-01T00:01:01",
+    "id": 123,
+    "ip": "192.0.2.0",
+    "restricted": true,
+    "status": "successful",
+    "username": "test-user"
+}
\ No newline at end of file
diff --git a/test/fixtures/account_maintenance.json b/test/fixtures/account_maintenance.json
new file mode 100644
index 000000000..aeeab91e6
--- /dev/null
+++ b/test/fixtures/account_maintenance.json
@@ -0,0 +1,19 @@
+{
+    "data": [
+      {
+        "entity": {
+          "id": 123,
+          "label": "demo-linode",
+          "type": "Linode",
+          "url": "https://api.linode.com/v4/linode/instances/{linodeId}"
+        },
+        "reason": "This maintenance will allow us to update the BIOS on the host's motherboard.",
+        "status": "started",
+        "type": "reboot",
+        "when": "2020-07-09T00:01:01"
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
\ No newline at end of file
diff --git a/test/fixtures/account_notifications.json b/test/fixtures/account_notifications.json
new file mode 100644
index 000000000..7e6355221
--- /dev/null
+++ b/test/fixtures/account_notifications.json
@@ -0,0 +1,22 @@
+{
+    "data": [
+      {
+        "body": null,
+        "entity": {
+          "id": 3456,
+          "label": "Linode not booting.",
+          "type": "ticket",
+          "url": "/support/tickets/3456"
+        },
+        "label": "You have an important ticket open!",
+        "message": "You have an important ticket open!",
+        "severity": "major",
+        "type": "ticket_important",
+        "until": null,
+        "when": null
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
diff --git a/test/fixtures/account_oauth-clients_2737bf16b39ab5d7b4a1.json b/test/fixtures/account_oauth-clients_2737bf16b39ab5d7b4a1.json
new file mode 100644
index 000000000..1520c8114
--- /dev/null
+++ b/test/fixtures/account_oauth-clients_2737bf16b39ab5d7b4a1.json
@@ -0,0 +1,9 @@
+{
+    "id": "2737bf16b39ab5d7b4a1",
+    "label": "Test_Client_1",
+    "public": false,
+    "redirect_uri": "https://example.org/oauth/callback",
+    "secret": "<REDACTED>",
+    "status": "active",
+    "thumbnail_url": "https://api.linode.com/v4/account/clients/2737bf16b39ab5d7b4a1/thumbnail"
+}
\ No newline at end of file
diff --git a/test/fixtures/account_payment-method_123.json b/test/fixtures/account_payment-method_123.json
new file mode 100644
index 000000000..611e49713
--- /dev/null
+++ b/test/fixtures/account_payment-method_123.json
@@ -0,0 +1,12 @@
+{
+    "created": "2018-01-15T00:01:01",
+    "data": {
+      "card_type": "Discover",
+      "expiry": "06/2022",
+      "last_four": "1234"
+    },
+    "id": 123,
+    "is_default": true,
+    "type": "credit_card"
+  }
+  
\ No newline at end of file
diff --git a/test/fixtures/account_payment-methods.json b/test/fixtures/account_payment-methods.json
new file mode 100644
index 000000000..2619af248
--- /dev/null
+++ b/test/fixtures/account_payment-methods.json
@@ -0,0 +1,18 @@
+{
+    "data": [
+      {
+        "created": "2018-01-15T00:01:01",
+        "data": {
+          "card_type": "Discover",
+          "expiry": "06/2022",
+          "last_four": "1234"
+        },
+        "id": 123,
+        "is_default": true,
+        "type": "credit_card"
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
\ No newline at end of file
diff --git a/test/fixtures/account_promo-codes.json b/test/fixtures/account_promo-codes.json
new file mode 100644
index 000000000..838762934
--- /dev/null
+++ b/test/fixtures/account_promo-codes.json
@@ -0,0 +1,10 @@
+{
+    "credit_monthly_cap": "10.00",
+    "credit_remaining": "50.00",
+    "description": "Receive up to $10 off your services every month for 6 months! Unused credits will expire once this promotion period ends.",
+    "expire_dt": "2018-01-31T23:59:59",
+    "image_url": "https://linode.com/10_a_month_promotion.svg",
+    "service_type": "all",
+    "summary": "$10 off your Linode a month!",
+    "this_month_credit_remaining": "10.00"
+  }
\ No newline at end of file
diff --git a/test/fixtures/account_service-transfers.json b/test/fixtures/account_service-transfers.json
new file mode 100644
index 000000000..cbf4a0c60
--- /dev/null
+++ b/test/fixtures/account_service-transfers.json
@@ -0,0 +1,21 @@
+{
+    "data": [
+      {
+        "created": "2021-02-11T16:37:03",
+        "entities": {
+          "linodes": [
+            111,
+            222
+          ]
+        },
+        "expiry": "2021-02-12T16:37:03",
+        "is_sender": true,
+        "status": "pending",
+        "token": "123E4567-E89B-12D3-A456-426614174000",
+        "updated": "2021-02-11T16:37:03"
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+  }
\ No newline at end of file
diff --git a/test/fixtures/account_service-transfers_12345.json b/test/fixtures/account_service-transfers_12345.json
new file mode 100644
index 000000000..819506524
--- /dev/null
+++ b/test/fixtures/account_service-transfers_12345.json
@@ -0,0 +1,14 @@
+{
+    "created": "2021-02-11T16:37:03",
+    "entities": {
+      "linodes": [
+        111,
+        222
+      ]
+    },
+    "expiry": "2021-02-12T16:37:03",
+    "is_sender": true,
+    "status": "pending",
+    "token": "12345",
+    "updated": "2021-02-11T16:37:03"
+  }
\ No newline at end of file
diff --git a/test/fixtures/account_settings.json b/test/fixtures/account_settings.json
index a857d1750..77a2fdac3 100644
--- a/test/fixtures/account_settings.json
+++ b/test/fixtures/account_settings.json
@@ -2,5 +2,6 @@
   "longview_subscription": "longview-100",
   "managed": false,
   "network_helper": false,
-  "object_storage": "active"
+  "object_storage": "active",
+  "backups_enabled": true
 }
diff --git a/test/fixtures/account_users_test-user.json b/test/fixtures/account_users_test-user.json
new file mode 100644
index 000000000..66e5f9b12
--- /dev/null
+++ b/test/fixtures/account_users_test-user.json
@@ -0,0 +1,10 @@
+{
+    "email": "test-user@linode.com",
+    "restricted": true,
+    "ssh_keys": [
+      "home-pc",
+      "laptop"
+    ],
+    "tfa_enabled": true,
+    "username": "test-user"
+  }
\ No newline at end of file
diff --git a/test/fixtures/domains_12345_clone.json b/test/fixtures/domains_12345_clone.json
new file mode 100644
index 000000000..faf3e7c28
--- /dev/null
+++ b/test/fixtures/domains_12345_clone.json
@@ -0,0 +1,19 @@
+{
+    "axfr_ips": [],
+    "description": null,
+    "domain": "example.org",
+    "expire_sec": 300,
+    "group": null,
+    "id": 1234,
+    "master_ips": [],
+    "refresh_sec": 300,
+    "retry_sec": 300,
+    "soa_email": "admin@example.org",
+    "status": "active",
+    "tags": [
+      "example tag",
+      "another example"
+    ],
+    "ttl_sec": 300,
+    "type": "master"
+}
diff --git a/test/fixtures/domains_12345_records.json b/test/fixtures/domains_12345_records.json
new file mode 100644
index 000000000..fe90f3282
--- /dev/null
+++ b/test/fixtures/domains_12345_records.json
@@ -0,0 +1,15 @@
+{
+    "created": "2018-01-01T00:01:01",
+    "id": 123456,
+    "name": "test",
+    "port": 80,
+    "priority": 50,
+    "protocol": null,
+    "service": null,
+    "tag": null,
+    "target": "192.0.2.0",
+    "ttl_sec": 604800,
+    "type": "A",
+    "updated": "2018-01-01T00:01:01",
+    "weight": 50
+}
\ No newline at end of file
diff --git a/test/fixtures/domains_12345_zone-file.json b/test/fixtures/domains_12345_zone-file.json
new file mode 100644
index 000000000..7cb4ad591
--- /dev/null
+++ b/test/fixtures/domains_12345_zone-file.json
@@ -0,0 +1,12 @@
+{
+    "zone_file": [
+      "; example.com [123]",
+      "$TTL 864000",
+      "@  IN  SOA  ns1.linode.com. user.example.com. 2021000066 14400 14400 1209600 86400",
+      "@    NS  ns1.linode.com.",
+      "@    NS  ns2.linode.com.",
+      "@    NS  ns3.linode.com.",
+      "@    NS  ns4.linode.com.",
+      "@    NS  ns5.linode.com."
+    ]
+}
diff --git a/test/fixtures/domains_import.json b/test/fixtures/domains_import.json
new file mode 100644
index 000000000..f1a254afc
--- /dev/null
+++ b/test/fixtures/domains_import.json
@@ -0,0 +1,19 @@
+{
+    "axfr_ips": [],
+    "description": null,
+    "domain": "example.org",
+    "expire_sec": 300,
+    "group": null,
+    "id": 1234,
+    "master_ips": [],
+    "refresh_sec": 300,
+    "retry_sec": 300,
+    "soa_email": "admin@example.org",
+    "status": "active",
+    "tags": [
+      "example tag",
+      "another example"
+    ],
+    "ttl_sec": 300,
+    "type": "master"
+}
\ No newline at end of file
diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json
index 4ebf4395a..efb502e7e 100644
--- a/test/fixtures/linode_instances.json
+++ b/test/fixtures/linode_instances.json
@@ -38,7 +38,9 @@
       ],
       "updated": "2017-01-01T00:00:00",
       "image": "linode/ubuntu17.04",
-      "tags": ["something"]
+      "tags": ["something"],
+      "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8",
+      "watchdog_enabled": true
     },
     {
       "group": "test",
@@ -75,7 +77,9 @@
       ],
       "updated": "2017-01-01T00:00:00",
       "image": "linode/debian9",
-      "tags": []
+      "tags": [],
+      "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8",
+      "watchdog_enabled": false
     }
   ]
 }
diff --git a/test/fixtures/linode_instances_123_backups.json b/test/fixtures/linode_instances_123_backups.json
index 964dbe883..94fe7f3b7 100644
--- a/test/fixtures/linode_instances_123_backups.json
+++ b/test/fixtures/linode_instances_123_backups.json
@@ -23,7 +23,8 @@
       "id": 12345,
       "status": "successful",
       "created": "2018-01-09T00:01:01",
-      "type": "auto"
+      "type": "auto",
+      "available": true
     },
     {
       "region": "us-east-1a",
@@ -48,7 +49,8 @@
       "id": 12456,
       "status": "successful",
       "created": "2018-01-01T00:01:01",
-      "type": "auto"
+      "type": "auto",
+      "available": true
     },
     {
       "region": "us-east-1a",
@@ -73,7 +75,8 @@
       "id": 12567,
       "status": "successful",
       "created": "2018-01-07T00:01:01",
-      "type": "auto"
+      "type": "auto",
+      "available": false
     }
   ],
   "snapshot": {
diff --git a/test/fixtures/linode_instances_123_disks_12345_clone.json b/test/fixtures/linode_instances_123_disks_12345_clone.json
new file mode 100644
index 000000000..2d378edca
--- /dev/null
+++ b/test/fixtures/linode_instances_123_disks_12345_clone.json
@@ -0,0 +1,10 @@
+{
+    "size": 25088,
+    "status": "ready",
+    "filesystem": "ext4",
+    "id": 12345,
+    "updated": "2017-01-01T00:00:00",
+    "label": "Ubuntu 17.04 Disk",
+    "created": "2017-01-01T00:00:00"
+  }
+  
\ No newline at end of file
diff --git a/test/fixtures/linode_instances_123_firewalls.json b/test/fixtures/linode_instances_123_firewalls.json
new file mode 100644
index 000000000..17a4a9199
--- /dev/null
+++ b/test/fixtures/linode_instances_123_firewalls.json
@@ -0,0 +1,56 @@
+{
+  "data": [
+    {
+      "created": "2018-01-01T00:01:01",
+      "id": 123,
+      "label": "firewall123",
+      "rules": {
+        "inbound": [
+          {
+            "action": "ACCEPT",
+            "addresses": {
+              "ipv4": [
+                "192.0.2.0/24"
+              ],
+              "ipv6": [
+                "2001:DB8::/32"
+              ]
+            },
+            "description": "An example firewall rule description.",
+            "label": "firewallrule123",
+            "ports": "22-24, 80, 443",
+            "protocol": "TCP"
+          }
+        ],
+        "inbound_policy": "DROP",
+        "outbound": [
+          {
+            "action": "ACCEPT",
+            "addresses": {
+              "ipv4": [
+                "192.0.2.0/24"
+              ],
+              "ipv6": [
+                "2001:DB8::/32"
+              ]
+            },
+            "description": "An example firewall rule description.",
+            "label": "firewallrule123",
+            "ports": "22-24, 80, 443",
+            "protocol": "TCP"
+          }
+        ],
+        "outbound_policy": "DROP"
+      },
+      "status": "enabled",
+      "tags": [
+        "example tag",
+        "another example"
+      ],
+      "updated": "2018-01-02T00:01:01"
+    }
+  ],
+  "page": 1,
+  "pages": 1,
+  "results": 1
+}
diff --git a/test/fixtures/linode_instances_123_ips.json b/test/fixtures/linode_instances_123_ips.json
new file mode 100644
index 000000000..8b9e64af0
--- /dev/null
+++ b/test/fixtures/linode_instances_123_ips.json
@@ -0,0 +1,87 @@
+{
+    "ipv4": {
+      "private": [
+        {
+          "address": "192.168.133.234",
+          "gateway": null,
+          "linode_id": 123,
+          "prefix": 17,
+          "public": false,
+          "rdns": null,
+          "region": "us-east",
+          "subnet_mask": "255.255.128.0",
+          "type": "ipv4"
+        }
+      ],
+      "public": [
+        {
+          "address": "97.107.143.141",
+          "gateway": "97.107.143.1",
+          "linode_id": 123,
+          "prefix": 24,
+          "public": true,
+          "rdns": "test.example.org",
+          "region": "us-east",
+          "subnet_mask": "255.255.255.0",
+          "type": "ipv4"
+        }
+      ],
+      "reserved": [
+        {
+          "address": "97.107.143.141",
+          "gateway": "97.107.143.1",
+          "linode_id": 123,
+          "prefix": 24,
+          "public": true,
+          "rdns": "test.example.org",
+          "region": "us-east",
+          "subnet_mask": "255.255.255.0",
+          "type": "ipv4"
+        }
+      ],
+      "shared": [
+        {
+          "address": "97.107.143.141",
+          "gateway": "97.107.143.1",
+          "linode_id": 123,
+          "prefix": 24,
+          "public": true,
+          "rdns": "test.example.org",
+          "region": "us-east",
+          "subnet_mask": "255.255.255.0",
+          "type": "ipv4"
+        }
+      ]
+    },
+    "ipv6": {
+      "global": {
+        "prefix": 124,
+        "range": "2600:3c01::2:5000:0",
+        "region": "us-east",
+        "route_target": "2600:3c01::2:5000:f"
+      },
+      "link_local": {
+        "address": "fe80::f03c:91ff:fe24:3a2f",
+        "gateway": "fe80::1",
+        "linode_id": 123,
+        "prefix": 64,
+        "public": false,
+        "rdns": null,
+        "region": "us-east",
+        "subnet_mask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+        "type": "ipv6"
+      },
+      "slaac": {
+        "address": "2600:3c03::f03c:91ff:fe24:3a2f",
+        "gateway": "fe80::1",
+        "linode_id": 123,
+        "prefix": 64,
+        "public": true,
+        "rdns": null,
+        "region": "us-east",
+        "subnet_mask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+        "type": "ipv6"
+      }
+    }
+  }
+  
\ No newline at end of file
diff --git a/test/fixtures/linode_instances_123_nodebalancers.json b/test/fixtures/linode_instances_123_nodebalancers.json
new file mode 100644
index 000000000..821ff4801
--- /dev/null
+++ b/test/fixtures/linode_instances_123_nodebalancers.json
@@ -0,0 +1,27 @@
+{
+  "data": [
+    {
+      "client_conn_throttle": 0,
+      "created": "2018-01-01T00:01:01",
+      "hostname": "192.0.2.1.ip.linodeusercontent.com",
+      "id": 12345,
+      "ipv4": "203.0.113.1",
+      "ipv6": null,
+      "label": "balancer12345",
+      "region": "us-east",
+      "tags": [
+        "example tag",
+        "another example"
+      ],
+      "transfer": {
+        "in": 28.91200828552246,
+        "out": 3.5487728118896484,
+        "total": 32.46078109741211
+      },
+      "updated": "2018-03-01T00:01:01"
+    }
+  ],
+  "page": 1,
+  "pages": 1,
+  "results": 1
+}
diff --git a/test/fixtures/linode_instances_123_transfer_2023_4.json b/test/fixtures/linode_instances_123_transfer_2023_4.json
new file mode 100644
index 000000000..3b9397efa
--- /dev/null
+++ b/test/fixtures/linode_instances_123_transfer_2023_4.json
@@ -0,0 +1,6 @@
+{
+    "bytes_in": 30471077120,
+    "bytes_out": 22956600198,
+    "bytes_total": 53427677318
+  }
+  
\ No newline at end of file
diff --git a/test/fixtures/linode_instances_123_volumes.json b/test/fixtures/linode_instances_123_volumes.json
new file mode 100644
index 000000000..63038e042
--- /dev/null
+++ b/test/fixtures/linode_instances_123_volumes.json
@@ -0,0 +1,24 @@
+{
+  "data": [
+    {
+      "created": "2018-01-01T00:01:01",
+      "filesystem_path": "/dev/disk/by-id/scsi-0Linode_Volume_my-volume",
+      "hardware_type": "nvme",
+      "id": 12345,
+      "label": "my-volume",
+      "linode_id": 12346,
+      "linode_label": "linode123",
+      "region": "us-east",
+      "size": 30,
+      "status": "active",
+      "tags": [
+        "example tag",
+        "another example"
+      ],
+      "updated": "2018-01-01T00:01:01"
+    }
+  ],
+  "page": 1,
+  "pages": 1,
+  "results": 1
+}
diff --git a/test/fixtures/linode_stackscripts_10079.json b/test/fixtures/linode_stackscripts_10079.json
new file mode 100644
index 000000000..bf0fef197
--- /dev/null
+++ b/test/fixtures/linode_stackscripts_10079.json
@@ -0,0 +1,29 @@
+{
+    "created": "2018-01-01T00:01:01",
+    "deployments_active": 1,
+    "deployments_total": 12,
+    "description": "This StackScript installs and configures MySQL\n",
+    "id": 10079,
+    "images": [
+      "linode/debian9",
+      "linode/debian8"
+    ],
+    "is_public": true,
+    "label": "a-stackscript",
+    "mine": true,
+    "rev_note": "Set up MySQL",
+    "script": "\"#!/bin/bash\"\n",
+    "updated": "2018-01-01T00:01:01",
+    "user_defined_fields": [
+      {
+        "default": null,
+        "example": "hunter2",
+        "label": "Enter the password",
+        "manyOf": "avalue,anothervalue,thirdvalue",
+        "name": "DB_PASSWORD",
+        "oneOf": "avalue,anothervalue,thirdvalue"
+      }
+    ],
+    "user_gravatar_id": "a445b305abda30ebc766bc7fda037c37",
+    "username": "myuser"
+}
\ No newline at end of file
diff --git a/test/fixtures/lke_clusters_18881.json b/test/fixtures/lke_clusters_18881.json
new file mode 100644
index 000000000..755d11c58
--- /dev/null
+++ b/test/fixtures/lke_clusters_18881.json
@@ -0,0 +1,13 @@
+{
+    "id": 18881, 
+    "status": "ready", 
+    "created": "2021-02-10T23:54:21", 
+    "updated": "2021-02-10T23:54:21",
+    "label": "example-cluster",
+    "region": "ap-west",
+    "k8s_version": "1.19",
+    "tags": [],
+    "control_plane": {
+        "high_availability": true
+    }
+}
\ No newline at end of file
diff --git a/test/fixtures/lke_clusters_18881_dashboard.json b/test/fixtures/lke_clusters_18881_dashboard.json
new file mode 100644
index 000000000..eb58d587d
--- /dev/null
+++ b/test/fixtures/lke_clusters_18881_dashboard.json
@@ -0,0 +1,3 @@
+{
+    "url": "https://example.dashboard.linodelke.net"
+}
diff --git a/test/fixtures/lke_clusters_18881_nodes_123456.json b/test/fixtures/lke_clusters_18881_nodes_123456.json
new file mode 100644
index 000000000..311ef3878
--- /dev/null
+++ b/test/fixtures/lke_clusters_18881_nodes_123456.json
@@ -0,0 +1,5 @@
+{
+    "id": "123456",
+    "instance_id": 123458,
+    "status": "ready"
+  }
\ No newline at end of file
diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json
new file mode 100644
index 000000000..ec6b570ac
--- /dev/null
+++ b/test/fixtures/lke_clusters_18881_pools_456.json
@@ -0,0 +1,27 @@
+{
+    "autoscaler": {
+      "enabled": true,
+      "max": 12,
+      "min": 3
+    },
+    "count": 6,
+    "disks": [
+      {
+        "size": 1024,
+        "type": "ext-4"
+      }
+    ],
+    "id": 456,
+    "nodes": [
+      {
+        "id": "123456",
+        "instance_id": 123458,
+        "status": "ready"
+      }
+    ],
+    "tags": [
+      "example tag",
+      "another example"
+    ],
+    "type": "g6-standard-4"
+  }
\ No newline at end of file
diff --git a/test/fixtures/longview_plan.json b/test/fixtures/longview_plan.json
new file mode 100644
index 000000000..f5f8503b2
--- /dev/null
+++ b/test/fixtures/longview_plan.json
@@ -0,0 +1,9 @@
+{
+    "clients_included": 10,
+    "id": "longview-10",
+    "label": "Longview Pro 10 pack",
+    "price": {
+      "hourly": 0.06,
+      "monthly": 40
+    }
+}
\ No newline at end of file
diff --git a/test/fixtures/networking_ips_127.0.0.1.json b/test/fixtures/networking_ips_127.0.0.1.json
new file mode 100644
index 000000000..f6567ebd5
--- /dev/null
+++ b/test/fixtures/networking_ips_127.0.0.1.json
@@ -0,0 +1,11 @@
+{
+  "address": "127.0.0.1",
+  "gateway": "127.0.0.1",
+  "linode_id": 123,
+  "prefix": 24,
+  "public": true,
+  "rdns": "test.example.org",
+  "region": "us-east",
+  "subnet_mask": "255.255.255.0",
+  "type": "ipv4"
+}
\ No newline at end of file
diff --git a/test/fixtures/networking_ipv6_pools.json b/test/fixtures/networking_ipv6_pools.json
new file mode 100644
index 000000000..aef9311e4
--- /dev/null
+++ b/test/fixtures/networking_ipv6_pools.json
@@ -0,0 +1,13 @@
+{
+    "data": [
+      {
+        "prefix": 124,
+        "range": "2600:3c01::2:5000:0",
+        "region": "us-east",
+        "route_target": "2600:3c01::2:5000:f"
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
diff --git a/test/fixtures/networking_ipv6_ranges.json b/test/fixtures/networking_ipv6_ranges.json
new file mode 100644
index 000000000..589ae42e0
--- /dev/null
+++ b/test/fixtures/networking_ipv6_ranges.json
@@ -0,0 +1,13 @@
+{
+    "data": [
+      {
+        "prefix": 64,
+        "range": "2600:3c01::",
+        "region": "us-east",
+        "route_target": "2600:3c01::ffff:ffff:ffff:ffff"
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
diff --git a/test/fixtures/networking_ipv6_ranges_2600:3c01::.json b/test/fixtures/networking_ipv6_ranges_2600:3c01::.json
new file mode 100644
index 000000000..7e7983a12
--- /dev/null
+++ b/test/fixtures/networking_ipv6_ranges_2600:3c01::.json
@@ -0,0 +1,9 @@
+{
+  "is_bgp": false,
+  "linodes": [
+    123
+  ],
+  "prefix": 64,
+  "range": "2600:3c01::",
+  "region": "us-east"
+}
\ No newline at end of file
diff --git a/test/fixtures/object-storage_buckets.json b/test/fixtures/object-storage_buckets.json
new file mode 100644
index 000000000..f99a944a6
--- /dev/null
+++ b/test/fixtures/object-storage_buckets.json
@@ -0,0 +1,15 @@
+{
+    "data": [
+      {
+        "cluster": "us-east-1",
+        "created": "2019-01-01T01:23:45",
+        "hostname": "example-bucket.us-east-1.linodeobjects.com",
+        "label": "example-bucket",
+        "objects": 4,
+        "size": 188318981
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
\ No newline at end of file
diff --git a/test/fixtures/object-storage_buckets_us-east-1.json b/test/fixtures/object-storage_buckets_us-east-1.json
new file mode 100644
index 000000000..f99a944a6
--- /dev/null
+++ b/test/fixtures/object-storage_buckets_us-east-1.json
@@ -0,0 +1,15 @@
+{
+    "data": [
+      {
+        "cluster": "us-east-1",
+        "created": "2019-01-01T01:23:45",
+        "hostname": "example-bucket.us-east-1.linodeobjects.com",
+        "label": "example-bucket",
+        "objects": 4,
+        "size": 188318981
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
\ No newline at end of file
diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json
new file mode 100644
index 000000000..b8c9450b6
--- /dev/null
+++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json
@@ -0,0 +1,8 @@
+{
+  "cluster": "us-east-1",
+  "created": "2019-01-01T01:23:45",
+  "hostname": "example-bucket.us-east-1.linodeobjects.com",
+  "label": "example-bucket",
+  "objects": 4,
+  "size": 188318981
+}
\ No newline at end of file
diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-acl.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-acl.json
new file mode 100644
index 000000000..a9b9aaf34
--- /dev/null
+++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-acl.json
@@ -0,0 +1,4 @@
+{
+    "acl": "public-read",
+    "acl_xml": "<AccessControlPolicy>...</AccessControlPolicy>"
+}
\ No newline at end of file
diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-list.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-list.json
new file mode 100644
index 000000000..6d92be5e0
--- /dev/null
+++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-list.json
@@ -0,0 +1,13 @@
+{
+    "data": [
+      {
+        "etag": "9f254c71e28e033bf9e0e5262e3e72ab",
+        "is_truncated": true,
+        "last_modified": "2019-01-01T01:23:45",
+        "name": "example",
+        "next_marker": "bd021c21-e734-4823-97a4-58b41c2cd4c8.892602.184",
+        "owner": "bfc70ab2-e3d4-42a4-ad55-83921822270c",
+        "size": 123
+      }
+    ]
+}
\ No newline at end of file
diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-url.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-url.json
new file mode 100644
index 000000000..de617779b
--- /dev/null
+++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-url.json
@@ -0,0 +1,3 @@
+{
+    "url": "https://us-east-1.linodeobjects.com/example-bucket/example?Signature=qr98TEucCntPgEG%2BsZQGDsJg93c%3D&Expires=1567609905&AWSAccessKeyId=G4YAF81XWY61DQM94SE0"
+}
\ No newline at end of file
diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket_ssl.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_ssl.json
new file mode 100644
index 000000000..e16ebc332
--- /dev/null
+++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_ssl.json
@@ -0,0 +1,3 @@
+{
+    "ssl": true
+}
\ No newline at end of file
diff --git a/test/fixtures/object-storage_transfer.json b/test/fixtures/object-storage_transfer.json
new file mode 100644
index 000000000..ae1968e66
--- /dev/null
+++ b/test/fixtures/object-storage_transfer.json
@@ -0,0 +1,3 @@
+{
+    "used": 12956600198
+}
\ No newline at end of file
diff --git a/test/fixtures/profile.json b/test/fixtures/profile.json
new file mode 100644
index 000000000..2c62a70a0
--- /dev/null
+++ b/test/fixtures/profile.json
@@ -0,0 +1,24 @@
+{
+    "authentication_type": "password",
+    "authorized_keys": [
+      null
+    ],
+    "email": "example-user@gmail.com",
+    "email_notifications": true,
+    "ip_whitelist_enabled": false,
+    "lish_auth_method": "keys_only",
+    "referrals": {
+      "code": "871be32f49c1411b14f29f618aaf0c14637fb8d3",
+      "completed": 0,
+      "credit": 0,
+      "pending": 0,
+      "total": 0,
+      "url": "https://www.linode.com/?r=871be32f49c1411b14f29f618aaf0c14637fb8d3"
+    },
+    "restricted": false,
+    "timezone": "US/Eastern",
+    "two_factor_auth": true,
+    "uid": 1234,
+    "username": "exampleUser",
+    "verified_phone_number": "+5555555555"
+}
\ No newline at end of file
diff --git a/test/fixtures/profile_device_123.json b/test/fixtures/profile_device_123.json
new file mode 100644
index 000000000..7505373ef
--- /dev/null
+++ b/test/fixtures/profile_device_123.json
@@ -0,0 +1,8 @@
+{
+    "created": "2018-01-01T01:01:01",
+    "expiry": "2018-01-31T01:01:01",
+    "id": 123,
+    "last_authenticated": "2018-01-05T12:57:12",
+    "last_remote_addr": "203.0.113.1",
+    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 Vivaldi/2.1.1337.36\n"
+}
diff --git a/test/fixtures/profile_devices.json b/test/fixtures/profile_devices.json
new file mode 100644
index 000000000..0c8ce9322
--- /dev/null
+++ b/test/fixtures/profile_devices.json
@@ -0,0 +1,15 @@
+{
+    "data": [
+      {
+        "created": "2018-01-01T01:01:01",
+        "expiry": "2018-01-31T01:01:01",
+        "id": 123,
+        "last_authenticated": "2018-01-05T12:57:12",
+        "last_remote_addr": "203.0.113.1",
+        "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 Vivaldi/2.1.1337.36\n"
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
\ No newline at end of file
diff --git a/test/fixtures/profile_logins.json b/test/fixtures/profile_logins.json
new file mode 100644
index 000000000..53cc1d8d7
--- /dev/null
+++ b/test/fixtures/profile_logins.json
@@ -0,0 +1,15 @@
+{
+    "data": [
+      {
+        "datetime": "2018-01-01T00:01:01",
+        "id": 123,
+        "ip": "192.0.2.0",
+        "restricted": true,
+        "status": "successful",
+        "username": "example_user"
+      }
+    ],
+    "page": 1,
+    "pages": 1,
+    "results": 1
+}
\ No newline at end of file
diff --git a/test/fixtures/profile_logins_123.json b/test/fixtures/profile_logins_123.json
new file mode 100644
index 000000000..0d700f79e
--- /dev/null
+++ b/test/fixtures/profile_logins_123.json
@@ -0,0 +1,8 @@
+{
+    "datetime": "2018-01-01T00:01:01",
+    "id": 123,
+    "ip": "192.0.2.0",
+    "restricted": true,
+    "status": "successful",
+    "username": "example_user"
+}
\ No newline at end of file
diff --git a/test/fixtures/profile_preferences.json b/test/fixtures/profile_preferences.json
new file mode 100644
index 000000000..9b24d09fa
--- /dev/null
+++ b/test/fixtures/profile_preferences.json
@@ -0,0 +1,4 @@
+{
+    "key1": "value1",
+    "key2": "value2"
+}
\ No newline at end of file
diff --git a/test/fixtures/profile_security-questions.json b/test/fixtures/profile_security-questions.json
new file mode 100644
index 000000000..7e7821853
--- /dev/null
+++ b/test/fixtures/profile_security-questions.json
@@ -0,0 +1,9 @@
+{
+    "security_questions": [
+      {
+        "id": 1,
+        "question": "In what city were you born?",
+        "response": "Gotham City"
+      }
+    ]
+}
\ No newline at end of file
diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json
index 8ad551718..ab848b3f8 100644
--- a/test/fixtures/regions.json
+++ b/test/fixtures/regions.json
@@ -12,7 +12,8 @@
       "resolvers": {
         "ipv4": "172.105.34.5,172.105.35.5,172.105.36.5,172.105.37.5,172.105.38.5,172.105.39.5,172.105.40.5,172.105.41.5,172.105.42.5,172.105.43.5",
         "ipv6": "2400:8904::f03c:91ff:fea5:659,2400:8904::f03c:91ff:fea5:9282,2400:8904::f03c:91ff:fea5:b9b3,2400:8904::f03c:91ff:fea5:925a,2400:8904::f03c:91ff:fea5:22cb,2400:8904::f03c:91ff:fea5:227a,2400:8904::f03c:91ff:fea5:924c,2400:8904::f03c:91ff:fea5:f7e2,2400:8904::f03c:91ff:fea5:2205,2400:8904::f03c:91ff:fea5:9207"
-      }
+      },
+      "label": "label1"
     },
     {
       "id": "ca-central",
@@ -26,7 +27,8 @@
       "resolvers": {
         "ipv4": "172.105.0.5,172.105.3.5,172.105.4.5,172.105.5.5,172.105.6.5,172.105.7.5,172.105.8.5,172.105.9.5,172.105.10.5,172.105.11.5",
         "ipv6": "2600:3c04::f03c:91ff:fea9:f63,2600:3c04::f03c:91ff:fea9:f6d,2600:3c04::f03c:91ff:fea9:f80,2600:3c04::f03c:91ff:fea9:f0f,2600:3c04::f03c:91ff:fea9:f99,2600:3c04::f03c:91ff:fea9:fbd,2600:3c04::f03c:91ff:fea9:fdd,2600:3c04::f03c:91ff:fea9:fe2,2600:3c04::f03c:91ff:fea9:f68,2600:3c04::f03c:91ff:fea9:f4a"
-      }
+      },
+      "label": "label2"
     },
     {
       "id": "ap-southeast",
@@ -40,7 +42,8 @@
       "resolvers": {
         "ipv4": "172.105.166.5,172.105.169.5,172.105.168.5,172.105.172.5,172.105.162.5,172.105.170.5,172.105.167.5,172.105.171.5,172.105.181.5,172.105.161.5",
         "ipv6": "2400:8907::f03c:92ff:fe6e:ec8,2400:8907::f03c:92ff:fe6e:98e4,2400:8907::f03c:92ff:fe6e:1c58,2400:8907::f03c:92ff:fe6e:c299,2400:8907::f03c:92ff:fe6e:c210,2400:8907::f03c:92ff:fe6e:c219,2400:8907::f03c:92ff:fe6e:1c5c,2400:8907::f03c:92ff:fe6e:c24e,2400:8907::f03c:92ff:fe6e:e6b,2400:8907::f03c:92ff:fe6e:e3d"
-      }
+      },
+      "label": "label3"
     },
     {
       "id": "us-central",
@@ -54,7 +57,8 @@
       "resolvers": {
         "ipv4": "72.14.179.5,72.14.188.5,173.255.199.5,66.228.53.5,96.126.122.5,96.126.124.5,96.126.127.5,198.58.107.5,198.58.111.5,23.239.24.5",
         "ipv6": "2600:3c00::2,2600:3c00::9,2600:3c00::7,2600:3c00::5,2600:3c00::3,2600:3c00::8,2600:3c00::6,2600:3c00::4,2600:3c00::c,2600:3c00::b"
-      }
+      },
+      "label": "label4"
     },
     {
       "id": "us-west",
@@ -68,7 +72,8 @@
       "resolvers": {
         "ipv4": "173.230.145.5,173.230.147.5,173.230.155.5,173.255.212.5,173.255.219.5,173.255.241.5,173.255.243.5,173.255.244.5,74.207.241.5,74.207.242.5",
         "ipv6": "2600:3c01::2,2600:3c01::9,2600:3c01::5,2600:3c01::7,2600:3c01::3,2600:3c01::8,2600:3c01::4,2600:3c01::b,2600:3c01::c,2600:3c01::6"
-      }
+      },
+      "label": "label5"
     },
     {
       "id": "us-southeast",
@@ -82,7 +87,8 @@
       "resolvers": {
         "ipv4": "74.207.231.5,173.230.128.5,173.230.129.5,173.230.136.5,173.230.140.5,66.228.59.5,66.228.62.5,50.116.35.5,50.116.41.5,23.239.18.5",
         "ipv6": "2600:3c02::3,2600:3c02::5,2600:3c02::4,2600:3c02::6,2600:3c02::c,2600:3c02::7,2600:3c02::2,2600:3c02::9,2600:3c02::8,2600:3c02::b"
-      }
+      },
+      "label": "label6"
     },
     {
       "id": "us-east",
@@ -97,7 +103,8 @@
       "resolvers": {
         "ipv4": "66.228.42.5,96.126.106.5,50.116.53.5,50.116.58.5,50.116.61.5,50.116.62.5,66.175.211.5,97.107.133.4,207.192.69.4,207.192.69.5",
         "ipv6": "2600:3c03::7,2600:3c03::4,2600:3c03::9,2600:3c03::6,2600:3c03::3,2600:3c03::c,2600:3c03::5,2600:3c03::b,2600:3c03::2,2600:3c03::8"
-      }
+      },
+      "label": "label7"
     },
     {
       "id": "eu-west",
@@ -111,7 +118,8 @@
       "resolvers": {
         "ipv4": "178.79.182.5,176.58.107.5,176.58.116.5,176.58.121.5,151.236.220.5,212.71.252.5,212.71.253.5,109.74.192.20,109.74.193.20,109.74.194.20",
         "ipv6": "2a01:7e00::9,2a01:7e00::3,2a01:7e00::c,2a01:7e00::5,2a01:7e00::6,2a01:7e00::8,2a01:7e00::b,2a01:7e00::4,2a01:7e00::7,2a01:7e00::2"
-      }
+      },
+      "label": "label8"
     },
     {
       "id": "ap-south",
@@ -126,7 +134,8 @@
       "resolvers": {
         "ipv4": "139.162.11.5,139.162.13.5,139.162.14.5,139.162.15.5,139.162.16.5,139.162.21.5,139.162.27.5,103.3.60.18,103.3.60.19,103.3.60.20",
         "ipv6": "2400:8901::5,2400:8901::4,2400:8901::b,2400:8901::3,2400:8901::9,2400:8901::2,2400:8901::8,2400:8901::7,2400:8901::c,2400:8901::6"
-      }
+      },
+      "label": "label9"
     },
     {
       "id": "eu-central",
@@ -141,7 +150,8 @@
       "resolvers": {
         "ipv4": "139.162.130.5,139.162.131.5,139.162.132.5,139.162.133.5,139.162.134.5,139.162.135.5,139.162.136.5,139.162.137.5,139.162.138.5,139.162.139.5",
         "ipv6": "2a01:7e01::5,2a01:7e01::9,2a01:7e01::7,2a01:7e01::c,2a01:7e01::2,2a01:7e01::4,2a01:7e01::3,2a01:7e01::6,2a01:7e01::b,2a01:7e01::8"
-      }
+      },
+      "label": "label10"
     },
     {
       "id": "ap-northeast",
@@ -155,7 +165,8 @@
       "resolvers": {
         "ipv4": "139.162.66.5,139.162.67.5,139.162.68.5,139.162.69.5,139.162.70.5,139.162.71.5,139.162.72.5,139.162.73.5,139.162.74.5,139.162.75.5",
         "ipv6": "2400:8902::3,2400:8902::6,2400:8902::c,2400:8902::4,2400:8902::2,2400:8902::8,2400:8902::7,2400:8902::5,2400:8902::b,2400:8902::9"
-      }
+      },
+      "label": "label11"
     }
   ],
   "page": 1,
diff --git a/test/fixtures/support_tickets_123.json b/test/fixtures/support_tickets_123.json
new file mode 100644
index 000000000..4a568f111
--- /dev/null
+++ b/test/fixtures/support_tickets_123.json
@@ -0,0 +1,22 @@
+{
+    "attachments": [
+      null
+    ],
+    "closable": false,
+    "closed": "2015-06-04T16:07:03",
+    "description": "I'm having trouble setting the root password on my Linode. I tried following the instructions but something is not working and I'm not sure what I'm doing wrong. Can you please help me figure out how I can reset it?\n",
+    "entity": {
+      "id": 10400,
+      "label": "linode123456",
+      "type": "linode",
+      "url": "/v4/linode/instances/123456"
+    },
+    "gravatar_id": "474a1b7373ae0be4132649e69c36ce30",
+    "id": 123,
+    "opened": "2015-06-04T14:16:44",
+    "opened_by": "some_user",
+    "status": "open",
+    "summary": "Having trouble resetting root password on my Linode\n",
+    "updated": "2015-06-04T16:07:03",
+    "updated_by": "some_other_user"
+  }
diff --git a/test/linode_client_test.py b/test/linode_client_test.py
index 2c3003e33..35378d482 100644
--- a/test/linode_client_test.py
+++ b/test/linode_client_test.py
@@ -1,21 +1,27 @@
 from datetime import datetime
+from test.base import ClientBaseCase
 from unittest import TestCase
 from unittest.mock import MagicMock
 
-from test.base import ClientBaseCase
-
-from linode_api4 import LongviewSubscription, LinodeClient, ApiError
+from linode_api4 import ApiError, LinodeClient, LongviewSubscription
+from linode_api4.objects.linode import Instance
+from linode_api4.objects.networking import IPAddress
+from linode_api4.objects.object_storage import (
+    ObjectStorageACL,
+    ObjectStorageCluster,
+)
 
 
 class LinodeClientGeneralTest(ClientBaseCase):
     """
     Tests methods of the LinodeClient class that do not live inside of a group.
     """
+
     def test_get_no_empty_body(self):
         """
         Tests that a valid JSON body is passed for a GET call
         """
-        with self.mock_get('linode/instances') as m:
+        with self.mock_get("linode/instances") as m:
             self.client.regions()
 
             self.assertEqual(m.call_data_raw, None)
@@ -24,20 +30,23 @@ def test_get_account(self):
         a = self.client.account()
         self.assertEqual(a._populated, True)
 
-        self.assertEqual(a.first_name, 'Test')
-        self.assertEqual(a.last_name, 'Guy')
-        self.assertEqual(a.email, 'support@linode.com')
-        self.assertEqual(a.phone, '123-456-7890')
-        self.assertEqual(a.company, 'Linode')
-        self.assertEqual(a.address_1, '3rd & Arch St')
-        self.assertEqual(a.address_2, '')
-        self.assertEqual(a.city, 'Philadelphia')
-        self.assertEqual(a.state, 'PA')
-        self.assertEqual(a.country, 'US')
-        self.assertEqual(a.zip, '19106')
-        self.assertEqual(a.tax_id, '')
+        self.assertEqual(a.first_name, "Test")
+        self.assertEqual(a.last_name, "Guy")
+        self.assertEqual(a.email, "support@linode.com")
+        self.assertEqual(a.phone, "123-456-7890")
+        self.assertEqual(a.company, "Linode")
+        self.assertEqual(a.address_1, "3rd & Arch St")
+        self.assertEqual(a.address_2, "")
+        self.assertEqual(a.city, "Philadelphia")
+        self.assertEqual(a.state, "PA")
+        self.assertEqual(a.country, "US")
+        self.assertEqual(a.zip, "19106")
+        self.assertEqual(a.tax_id, "")
         self.assertEqual(a.balance, 0)
-        self.assertEqual(a.capabilities, ["Linodes","NodeBalancers","Block Storage","Object Storage"])
+        self.assertEqual(
+            a.capabilities,
+            ["Linodes", "NodeBalancers", "Block Storage", "Object Storage"],
+        )
 
     def test_get_regions(self):
         r = self.client.regions()
@@ -47,10 +56,21 @@ def test_get_regions(self):
             self.assertTrue(region._populated)
             self.assertIsNotNone(region.id)
             self.assertIsNotNone(region.country)
-            if region.id in ('us-east', 'eu-central', 'ap-south'):
-                self.assertEqual(region.capabilities, ["Linodes","NodeBalancers","Block Storage","Object Storage"])
+            if region.id in ("us-east", "eu-central", "ap-south"):
+                self.assertEqual(
+                    region.capabilities,
+                    [
+                        "Linodes",
+                        "NodeBalancers",
+                        "Block Storage",
+                        "Object Storage",
+                    ],
+                )
             else:
-                self.assertEqual(region.capabilities, ["Linodes","NodeBalancers","Block Storage"])
+                self.assertEqual(
+                    region.capabilities,
+                    ["Linodes", "NodeBalancers", "Block Storage"],
+                )
             self.assertEqual(region.status, "ok")
             self.assertIsNotNone(region.resolvers)
             self.assertIsNotNone(region.resolvers.ipv4)
@@ -73,54 +93,72 @@ def test_get_domains(self):
         self.assertEqual(len(r), 1)
         domain = r.first()
 
-        self.assertEqual(domain.domain, 'example.org')
-        self.assertEqual(domain.type, 'master')
+        self.assertEqual(domain.domain, "example.org")
+        self.assertEqual(domain.type, "master")
         self.assertEqual(domain.id, 12345)
         self.assertEqual(domain.axfr_ips, [])
         self.assertEqual(domain.retry_sec, 0)
         self.assertEqual(domain.ttl_sec, 300)
-        self.assertEqual(domain.status, 'active')
-        self.assertEqual(domain.master_ips, [],)
-        self.assertEqual(domain.description, "",)
-        self.assertEqual(domain.group, "",)
-        self.assertEqual(domain.expire_sec, 0,)
-        self.assertEqual(domain.soa_email, "test@example.org",)
+        self.assertEqual(domain.status, "active")
+        self.assertEqual(
+            domain.master_ips,
+            [],
+        )
+        self.assertEqual(
+            domain.description,
+            "",
+        )
+        self.assertEqual(
+            domain.group,
+            "",
+        )
+        self.assertEqual(
+            domain.expire_sec,
+            0,
+        )
+        self.assertEqual(
+            domain.soa_email,
+            "test@example.org",
+        )
         self.assertEqual(domain.refresh_sec, 0)
 
     def test_image_create(self):
         """
         Tests that an Image can be created successfully
         """
-        with self.mock_post('images/private/123') as m:
-            i = self.client.image_create(654, 'Test-Image', 'This is a test')
+        with self.mock_post("images/private/123") as m:
+            i = self.client.image_create(654, "Test-Image", "This is a test")
 
             self.assertIsNotNone(i)
-            self.assertEqual(i.id, 'private/123')
+            self.assertEqual(i.id, "private/123")
 
-            self.assertEqual(m.call_url, '/images')
+            self.assertEqual(m.call_url, "/images")
 
-            self.assertEqual(m.call_data, {
-                "disk_id": 654,
-                "label": "Test-Image",
-                "description": "This is a test",
-            })
+            self.assertEqual(
+                m.call_data,
+                {
+                    "disk_id": 654,
+                    "label": "Test-Image",
+                    "description": "This is a test",
+                },
+            )
 
     def test_get_volumes(self):
         v = self.client.volumes()
 
         self.assertEqual(len(v), 3)
-        self.assertEqual(v[0].label, 'block1')
-        self.assertEqual(v[0].region.id, 'us-east-1a')
-        self.assertEqual(v[1].label, 'block2')
+        self.assertEqual(v[0].label, "block1")
+        self.assertEqual(v[0].region.id, "us-east-1a")
+        self.assertEqual(v[1].label, "block2")
         self.assertEqual(v[1].size, 100)
         self.assertEqual(v[2].size, 200)
-        self.assertEqual(v[2].label, 'block3')
-        self.assertEqual(v[0].filesystem_path, 'this/is/a/file/path')
-        self.assertEqual(v[0].hardware_type, 'hdd')
-        self.assertEqual(v[1].filesystem_path, 'this/is/a/file/path')
+        self.assertEqual(v[2].label, "block3")
+        self.assertEqual(v[0].filesystem_path, "this/is/a/file/path")
+        self.assertEqual(v[0].hardware_type, "hdd")
+        self.assertEqual(v[1].filesystem_path, "this/is/a/file/path")
         self.assertEqual(v[1].linode_label, None)
-        self.assertEqual(v[2].filesystem_path, 'this/is/a/file/path')
-        self.assertEqual(v[2].hardware_type, 'nvme')
+        self.assertEqual(v[2].filesystem_path, "this/is/a/file/path")
+        self.assertEqual(v[2].hardware_type, "nvme")
 
         assert v[0].tags == ["something"]
         assert v[1].tags == []
@@ -133,24 +171,27 @@ def test_get_tags(self):
         t = self.client.tags()
 
         self.assertEqual(len(t), 2)
-        self.assertEqual(t[0].label, 'nothing')
-        self.assertEqual(t[1].label, 'something')
+        self.assertEqual(t[0].label, "nothing")
+        self.assertEqual(t[1].label, "something")
 
     def test_tag_create(self):
         """
         Tests that creating a tag works as expected
         """
         # tags don't work like a normal RESTful collection, so we have to do this
-        with self.mock_post({'label':'nothing'}) as m:
-            t = self.client.tag_create('nothing')
+        with self.mock_post({"label": "nothing"}) as m:
+            t = self.client.tag_create("nothing")
 
             self.assertIsNotNone(t)
-            self.assertEqual(t.label, 'nothing')
-
-            self.assertEqual(m.call_url, '/tags')
-            self.assertEqual(m.call_data, {
-                'label': 'nothing',
-            })
+            self.assertEqual(t.label, "nothing")
+
+            self.assertEqual(m.call_url, "/tags")
+            self.assertEqual(
+                m.call_data,
+                {
+                    "label": "nothing",
+                },
+            )
 
     def test_tag_create_with_ids(self):
         """
@@ -162,24 +203,29 @@ def test_tag_create_with_ids(self):
         volume1, volume2 = self.client.volumes()[:2]
 
         # tags don't work like a normal RESTful collection, so we have to do this
-        with self.mock_post({'label':'pytest'}) as m:
-            t = self.client.tag_create('pytest',
-                                       instances=[instance1.id, instance2],
-                                       nodebalancers=[nodebalancer1.id, nodebalancer2],
-                                       domains=[domain1.id],
-                                       volumes=[volume1.id, volume2])
+        with self.mock_post({"label": "pytest"}) as m:
+            t = self.client.tag_create(
+                "pytest",
+                instances=[instance1.id, instance2],
+                nodebalancers=[nodebalancer1.id, nodebalancer2],
+                domains=[domain1.id],
+                volumes=[volume1.id, volume2],
+            )
 
             self.assertIsNotNone(t)
-            self.assertEqual(t.label, 'pytest')
-
-            self.assertEqual(m.call_url, '/tags')
-            self.assertEqual(m.call_data, {
-                'label': 'pytest',
-                'linodes': [instance1.id, instance2.id],
-                'domains': [domain1.id],
-                'nodebalancers': [nodebalancer1.id, nodebalancer2.id],
-                'volumes': [volume1.id, volume2.id],
-            })
+            self.assertEqual(t.label, "pytest")
+
+            self.assertEqual(m.call_url, "/tags")
+            self.assertEqual(
+                m.call_data,
+                {
+                    "label": "pytest",
+                    "linodes": [instance1.id, instance2.id],
+                    "domains": [domain1.id],
+                    "nodebalancers": [nodebalancer1.id, nodebalancer2.id],
+                    "volumes": [volume1.id, volume2.id],
+                },
+            )
 
     def test_tag_create_with_entities(self):
         """
@@ -191,27 +237,33 @@ def test_tag_create_with_entities(self):
         volume = self.client.volumes().first()
 
         # tags don't work like a normal RESTful collection, so we have to do this
-        with self.mock_post({'label':'pytest'}) as m:
-            t = self.client.tag_create('pytest',
-                                       entities=[instance1, domain, nodebalancer, volume, instance2])
+        with self.mock_post({"label": "pytest"}) as m:
+            t = self.client.tag_create(
+                "pytest",
+                entities=[instance1, domain, nodebalancer, volume, instance2],
+            )
 
             self.assertIsNotNone(t)
-            self.assertEqual(t.label, 'pytest')
-
-            self.assertEqual(m.call_url, '/tags')
-            self.assertEqual(m.call_data, {
-                'label': 'pytest',
-                'linodes': [instance1.id, instance2.id],
-                'domains': [domain.id],
-                'nodebalancers': [nodebalancer.id],
-                'volumes': [volume.id],
-            })
+            self.assertEqual(t.label, "pytest")
+
+            self.assertEqual(m.call_url, "/tags")
+            self.assertEqual(
+                m.call_data,
+                {
+                    "label": "pytest",
+                    "linodes": [instance1.id, instance2.id],
+                    "domains": [domain.id],
+                    "nodebalancers": [nodebalancer.id],
+                    "volumes": [volume.id],
+                },
+            )
 
 
 class AccountGroupTest(ClientBaseCase):
     """
     Tests methods of the AccountGroup
     """
+
     def test_get_settings(self):
         """
         Tests that account settings can be retrieved.
@@ -222,7 +274,7 @@ def test_get_settings(self):
         self.assertEqual(s.network_helper, False)
         self.assertEqual(s.managed, False)
         self.assertEqual(type(s.longview_subscription), LongviewSubscription)
-        self.assertEqual(s.longview_subscription.id, 'longview-100')
+        self.assertEqual(s.longview_subscription.id, "longview-100")
         self.assertEqual(s.object_storage, "active")
 
     def test_get_invoices(self):
@@ -236,9 +288,117 @@ def test_get_invoices(self):
 
         self.assertEqual(invoice.id, 123456)
         self.assertEqual(invoice.date, datetime(2015, 1, 1, 5, 1, 2))
-        self.assertEqual(invoice.label, 'Invoice #123456')
+        self.assertEqual(invoice.label, "Invoice #123456")
         self.assertEqual(invoice.total, 9.51)
 
+    def test_logins(self):
+        """
+        Tests that logins can be retrieved
+        """
+        logins = self.client.account.logins()
+        self.assertEqual(len(logins), 1)
+        self.assertEqual(logins[0].id, 1234)
+
+    def test_maintenance(self):
+        """
+        Tests that maintenance can be retrieved
+        """
+        with self.mock_get("/account/maintenance") as m:
+            result = self.client.account.maintenance()
+            self.assertEqual(m.call_url, "/account/maintenance")
+            self.assertEqual(len(result), 1)
+            self.assertEqual(
+                result[0].reason,
+                "This maintenance will allow us to update the BIOS on the host's motherboard.",
+            )
+
+    def test_notifications(self):
+        """
+        Tests that notifications can be retrieved
+        """
+        with self.mock_get("/account/notifications") as m:
+            result = self.client.account.notifications()
+            self.assertEqual(m.call_url, "/account/notifications")
+            self.assertEqual(len(result), 1)
+            self.assertEqual(
+                result[0].label, "You have an important ticket open!"
+            )
+
+    def test_payment_methods(self):
+        """
+        Tests that payment methods can be retrieved
+        """
+        paymentMethods = self.client.account.payment_methods()
+        self.assertEqual(len(paymentMethods), 1)
+        self.assertEqual(paymentMethods[0].id, 123)
+
+    def test_add_payment_method(self):
+        """
+        Tests that adding a payment method creates the correct api request.
+        """
+        with self.mock_post({}) as m:
+            self.client.account.add_payment_method(
+                {
+                    "card_number": "123456789100",
+                    "expiry_month": 1,
+                    "expiry_year": 2028,
+                    "cvv": 111,
+                },
+                True,
+                "credit_card",
+            )
+            self.assertEqual(m.call_url, "/account/payment-methods")
+            self.assertEqual(m.call_data["type"], "credit_card")
+            self.assertTrue(m.call_data["is_default"])
+            self.assertIsNotNone(m.call_data["data"])
+
+    def test_add_promo_code(self):
+        """
+        Tests that adding a promo code creates the correct api request.
+        """
+        with self.mock_post("/account/promo-codes") as m:
+            self.client.account.add_promo_code("123promo456")
+            self.assertEqual(m.call_url, "/account/promo-codes")
+            self.assertEqual(m.call_data["promo_code"], "123promo456")
+
+    def test_service_transfers(self):
+        """
+        Tests that service transfers can be retrieved
+        """
+        serviceTransfers = self.client.account.service_transfers()
+        self.assertEqual(len(serviceTransfers), 1)
+        self.assertEqual(
+            serviceTransfers[0].token, "123E4567-E89B-12D3-A456-426614174000"
+        )
+
+    def test_linode_managed_enable(self):
+        """
+        Tests that enabling linode managed creates the correct api request.
+        """
+        with self.mock_post({}) as m:
+            self.client.account.linode_managed_enable()
+            self.assertEqual(m.call_url, "/account/settings/managed-enable")
+
+    def test_service_transfer_create(self):
+        """
+        Tests that creating a service transfer creates the correct api request.
+        """
+        data = {"linodes": [111, 222]}
+        response = {
+            "created": "2021-02-11T16:37:03",
+            "entities": {"linodes": [111, 222]},
+            "expiry": "2021-02-12T16:37:03",
+            "is_sender": True,
+            "status": "pending",
+            "token": "123E4567-E89B-12D3-A456-426614174000",
+            "updated": "2021-02-11T16:37:03",
+        }
+
+        with self.mock_post(response) as m:
+            self.client.account.service_transfer_create(data)
+            self.assertEqual(m.call_url, "/account/service-transfers")
+            self.assertEqual(m.call_data["entities"], data)
+
     def test_payments(self):
         """
         Tests that payments can be retrieved
@@ -257,47 +417,55 @@ class LinodeGroupTest(ClientBaseCase):
     """
     Tests methods of the LinodeGroup
     """
+
     def test_instance_create(self):
         """
         Tests that a Linode Instance can be created successfully
         """
-        with self.mock_post('linode/instances/123') as m:
-            l = self.client.linode.instance_create('g5-standard-1', 'us-east-1a')
+        with self.mock_post("linode/instances/123") as m:
+            l = self.client.linode.instance_create(
+                "g5-standard-1", "us-east-1a"
+            )
 
             self.assertIsNotNone(l)
             self.assertEqual(l.id, 123)
 
-            self.assertEqual(m.call_url, '/linode/instances')
+            self.assertEqual(m.call_url, "/linode/instances")
 
-            self.assertEqual(m.call_data, {
-                "region": "us-east-1a",
-                "type": "g5-standard-1"
-            })
+            self.assertEqual(
+                m.call_data, {"region": "us-east-1a", "type": "g5-standard-1"}
+            )
 
     def test_instance_create_with_image(self):
         """
         Tests that a Linode Instance can be created with an image, and a password generated
         """
-        with self.mock_post('linode/instances/123') as m:
+        with self.mock_post("linode/instances/123") as m:
             l, pw = self.client.linode.instance_create(
-                'g5-standard-1', 'us-east-1a', image='linode/debian9')
+                "g5-standard-1", "us-east-1a", image="linode/debian9"
+            )
 
             self.assertIsNotNone(l)
             self.assertEqual(l.id, 123)
 
-            self.assertEqual(m.call_url, '/linode/instances')
+            self.assertEqual(m.call_url, "/linode/instances")
+
+            self.assertEqual(
+                m.call_data,
+                {
+                    "region": "us-east-1a",
+                    "type": "g5-standard-1",
+                    "image": "linode/debian9",
+                    "root_pass": pw,
+                },
+            )
 
-            self.assertEqual(m.call_data, {
-                "region": "us-east-1a",
-                "type": "g5-standard-1",
-                "image": "linode/debian9",
-                "root_pass": pw,
-            })
 
 class LongviewGroupTest(ClientBaseCase):
     """
     Tests methods of the LongviewGroup
     """
+
     def test_get_clients(self):
         """
         Tests that a list of LongviewClients can be retrieved
@@ -314,35 +482,53 @@ def test_client_create(self):
         """
         Tests that creating a client calls the api correctly
         """
-        with self.mock_post('longview/clients/5678') as m:
+        with self.mock_post("longview/clients/5678") as m:
             client = self.client.longview.client_create()
 
             self.assertIsNotNone(client)
             self.assertEqual(client.id, 5678)
-            self.assertEqual(client.label, 'longview5678')
+            self.assertEqual(client.label, "longview5678")
 
-            self.assertEqual(m.call_url, '/longview/clients')
+            self.assertEqual(m.call_url, "/longview/clients")
             self.assertEqual(m.call_data, {})
 
     def test_client_create_with_label(self):
         """
         Tests that creating a client with a label calls the api correctly
         """
-        with self.mock_post('longview/clients/1234') as m:
-            client = self.client.longview.client_create(label='test_client_1')
+        with self.mock_post("longview/clients/1234") as m:
+            client = self.client.longview.client_create(label="test_client_1")
 
             self.assertIsNotNone(client)
             self.assertEqual(client.id, 1234)
-            self.assertEqual(client.label, 'test_client_1')
+            self.assertEqual(client.label, "test_client_1")
 
-            self.assertEqual(m.call_url, '/longview/clients')
+            self.assertEqual(m.call_url, "/longview/clients")
             self.assertEqual(m.call_data, {"label": "test_client_1"})
 
+    def test_update_plan(self):
+        """
+        Tests that you can submit a correct longview plan update api request
+        """
+        with self.mock_post("/longview/plan") as m:
+            result = self.client.longview.longview_plan_update("longview-100")
+            self.assertEqual(m.call_url, "/longview/plan")
+            self.assertEqual(
+                m.call_data["longview_subscription"], "longview-100"
+            )
+            self.assertEqual(result.id, "longview-10")
+            self.assertEqual(result.clients_included, 10)
+            self.assertEqual(result.label, "Longview Pro 10 pack")
+            self.assertIsNotNone(result.price)
+
     def test_get_subscriptions(self):
         """
         Tests that Longview subscriptions can be retrieved
         """
-        r = self.client.longview.subscriptions()
+
+        with self.mock_get("longview/subscriptions") as m:
+            r = self.client.longview.subscriptions()
+            self.assertEqual(m.call_url, "/longview/subscriptions")
 
         self.assertEqual(len(r), 4)
 
@@ -362,6 +548,7 @@ class LKEGroupTest(ClientBaseCase):
     """
     Tests methods of the LKEGroupTest
     """
+
     def test_kube_version(self):
         """
         Tests that KubeVersions can be retrieved
@@ -385,8 +572,9 @@ def test_cluster_create_with_api_objects(self):
                 region, "example-cluster", node_pools, version
             )
             self.assertEqual(m.call_data["region"], "ap-west")
-            self.assertEqual(m.call_data["node_pools"],
-                             [{"type": "g5-nanode-1", "count": 3}])
+            self.assertEqual(
+                m.call_data["node_pools"], [{"type": "g5-nanode-1", "count": 3}]
+            )
             self.assertEqual(m.call_data["k8s_version"], "1.19")
 
         self.assertEqual(cluster.id, 18881)
@@ -399,12 +587,16 @@ def test_cluster_create_with_string_repr(self):
         """
         with self.mock_post("lke/clusters") as m:
             cluster = self.client.lke.cluster_create(
-                "ap-west", "example-cluster",
-                {"type": "g6-standard-1", "count": 3}, "1.19"
+                "ap-west",
+                "example-cluster",
+                {"type": "g6-standard-1", "count": 3},
+                "1.19",
             )
             self.assertEqual(m.call_data["region"], "ap-west")
-            self.assertEqual(m.call_data["node_pools"],
-                             [{"type": "g6-standard-1", "count": 3}])
+            self.assertEqual(
+                m.call_data["node_pools"],
+                [{"type": "g6-standard-1", "count": 3}],
+            )
             self.assertEqual(m.call_data["k8s_version"], "1.19")
 
         self.assertEqual(cluster.id, 18881)
@@ -416,6 +608,87 @@ class ProfileGroupTest(ClientBaseCase):
     """
     Tests methods of the ProfileGroup
     """
+
+    def test_trusted_devices(self):
+        devices = self.client.profile.trusted_devices()
+        self.assertEqual(len(devices), 1)
+        self.assertEqual(devices[0].id, 123)
+
+    def test_logins(self):
+        logins = self.client.profile.logins()
+        self.assertEqual(len(logins), 1)
+        self.assertEqual(logins[0].id, 123)
+
+    def test_phone_number_delete(self):
+        with self.mock_delete() as m:
+            self.client.profile.phone_number_delete()
+            self.assertEqual(m.call_url, "/profile/phone-number")
+
+    def test_phone_number_verify(self):
+        with self.mock_post({}) as m:
+            self.client.profile.phone_number_verify("123456")
+            self.assertEqual(m.call_url, "/profile/phone-number/verify")
+            self.assertEqual(m.call_data["otp_code"], "123456")
+
+    def test_phone_number_verification_code_send(self):
+        with self.mock_post({}) as m:
+            self.client.profile.phone_number_verification_code_send(
+                "us", "1234567890"
+            )
+            self.assertEqual(m.call_url, "/profile/phone-number")
+            self.assertEqual(m.call_data["iso_code"], "us")
+            self.assertEqual(m.call_data["phone_number"], "1234567890")
+
+    def test_user_preferences(self):
+        with self.mock_get("/profile/preferences") as m:
+            result = self.client.profile.user_preferences()
+            self.assertEqual(m.call_url, "/profile/preferences")
+            self.assertEqual(result.key1, "value1")
+            self.assertEqual(result.key2, "value2")
+
+    def test_user_preferences_update(self):
+        with self.mock_put("/profile/preferences") as m:
+            self.client.profile.user_preferences_update(
+                key1="value3", key2="value4"
+            )
+            self.assertEqual(m.call_url, "/profile/preferences")
+            self.assertEqual(m.call_data["key1"], "value3")
+            self.assertEqual(m.call_data["key2"], "value4")
+
+    def test_security_questions(self):
+        with self.mock_get("/profile/security-questions") as m:
+            result = self.client.profile.security_questions()
+            self.assertEqual(m.call_url, "/profile/security-questions")
+            self.assertEqual(result.security_questions[0].id, 1)
+            self.assertEqual(
+                result.security_questions[0].question,
+                "In what city were you born?",
+            )
+            self.assertEqual(
+                result.security_questions[0].response, "Gotham City"
+            )
+
+    def test_security_questions_answer(self):
+        with self.mock_post("/profile/security-questions") as m:
+            self.client.profile.security_questions_answer(
+                [
+                    {"question_id": 1, "response": "secret answer 1"},
+                    {"question_id": 2, "response": "secret answer 2"},
+                    {"question_id": 3, "response": "secret answer 3"},
+                ]
+            )
+            self.assertEqual(m.call_url, "/profile/security-questions")
+
+            self.assertEqual(
+                m.call_data["security_questions"][0]["question_id"], 1
+            )
+            self.assertEqual(
+                m.call_data["security_questions"][1]["question_id"], 2
+            )
+            self.assertEqual(
+                m.call_data["security_questions"][2]["question_id"], 3
+            )
+
     def test_get_sshkeys(self):
         """
         Tests that a list of SSH Keys can be retrieved
@@ -426,89 +699,99 @@ def test_get_sshkeys(self):
 
         key1, key2 = r
 
-        self.assertEqual(key1.label, 'Home Ubuntu PC')
-        self.assertEqual(key1.created, datetime(year=2018, month=9, day=14, hour=13,
-                                                minute=0, second=0))
+        self.assertEqual(key1.label, "Home Ubuntu PC")
+        self.assertEqual(
+            key1.created,
+            datetime(year=2018, month=9, day=14, hour=13, minute=0, second=0),
+        )
         self.assertEqual(key1.id, 22)
         self.assertEqual(
-            key1.ssh_key, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98"
-                          "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD"
-                          "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO"
-                          "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i"
-                          "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo"
-                          "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC"
-                          "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy"
-                          "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz"
-                          "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz"
-                          "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v"
-                          "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH"
-                          "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b"
-                          "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI"
-                          "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w== dorthu@dorthu-command")
+            key1.ssh_key,
+            "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98"
+            "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD"
+            "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO"
+            "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i"
+            "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo"
+            "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC"
+            "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy"
+            "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz"
+            "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz"
+            "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v"
+            "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH"
+            "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b"
+            "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI"
+            "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w== dorthu@dorthu-command",
+        )
 
     def test_client_create(self):
         """
         Tests that creating a client calls the api correctly
         """
-        with self.mock_post('longview/clients/5678') as m:
+        with self.mock_post("longview/clients/5678") as m:
             client = self.client.longview.client_create()
 
             self.assertIsNotNone(client)
             self.assertEqual(client.id, 5678)
-            self.assertEqual(client.label, 'longview5678')
+            self.assertEqual(client.label, "longview5678")
 
-            self.assertEqual(m.call_url, '/longview/clients')
+            self.assertEqual(m.call_url, "/longview/clients")
             self.assertEqual(m.call_data, {})
 
     def test_ssh_key_create(self):
         """
         Tests that creating an ssh key works as expected
         """
-        with self.mock_post('profile/sshkeys/72') as m:
+        with self.mock_post("profile/sshkeys/72") as m:
             key = self.client.profile.ssh_key_upload(
-                         "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98"
-                         "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD"
-                         "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO"
-                         "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i"
-                         "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo"
-                         "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC"
-                         "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy"
-                         "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz"
-                         "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz"
-                         "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v"
-                         "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH"
-                         "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b"
-                         "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI"
-                         "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w==dorthu@dorthu-command",
-                         'Work Laptop')
+                "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98"
+                "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD"
+                "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO"
+                "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i"
+                "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo"
+                "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC"
+                "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy"
+                "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz"
+                "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz"
+                "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v"
+                "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH"
+                "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b"
+                "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI"
+                "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w==dorthu@dorthu-command",
+                "Work Laptop",
+            )
 
             self.assertIsNotNone(key)
             self.assertEqual(key.id, 72)
-            self.assertEqual(key.label, 'Work Laptop')
-
-            self.assertEqual(m.call_url, '/profile/sshkeys')
-            self.assertEqual(m.call_data, {
-                "ssh_key":  "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98"
-                            "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD"
-                            "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO"
-                            "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i"
-                            "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo"
-                            "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC"
-                            "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy"
-                            "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz"
-                            "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz"
-                            "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v"
-                            "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH"
-                            "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b"
-                            "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI"
-                            "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w==dorthu@dorthu-command",
-                "label": "Work Laptop"
-            })
+            self.assertEqual(key.label, "Work Laptop")
+
+            self.assertEqual(m.call_url, "/profile/sshkeys")
+            self.assertEqual(
+                m.call_data,
+                {
+                    "ssh_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98"
+                    "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD"
+                    "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO"
+                    "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i"
+                    "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo"
+                    "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC"
+                    "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy"
+                    "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz"
+                    "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz"
+                    "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v"
+                    "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH"
+                    "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b"
+                    "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI"
+                    "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w==dorthu@dorthu-command",
+                    "label": "Work Laptop",
+                },
+            )
+
 
 class ObjectStorageGroupTest(ClientBaseCase):
     """
     Tests for the ObjectStorageGroup
     """
+
     def test_get_clusters(self):
         """
         Tests that Object Storage Clusters can be retrieved
@@ -518,10 +801,12 @@ def test_get_clusters(self):
         self.assertEqual(len(clusters), 1)
         cluster = clusters[0]
 
-        self.assertEqual(cluster.id, 'us-east-1')
-        self.assertEqual(cluster.region.id, 'us-east')
-        self.assertEqual(cluster.domain, 'us-east-1.linodeobjects.com')
-        self.assertEqual(cluster.static_site_domain, 'website-us-east-1.linodeobjects.com')
+        self.assertEqual(cluster.id, "us-east-1")
+        self.assertEqual(cluster.region.id, "us-east")
+        self.assertEqual(cluster.domain, "us-east-1.linodeobjects.com")
+        self.assertEqual(
+            cluster.static_site_domain, "website-us-east-1.linodeobjects.com"
+        )
 
     def test_get_keys(self):
         """
@@ -534,33 +819,146 @@ def test_get_keys(self):
         key2 = keys[1]
 
         self.assertEqual(key1.id, 1)
-        self.assertEqual(key1.label, 'object-storage-key-1')
-        self.assertEqual(key1.access_key, 'testAccessKeyHere123')
-        self.assertEqual(key1.secret_key, '[REDACTED]')
+        self.assertEqual(key1.label, "object-storage-key-1")
+        self.assertEqual(key1.access_key, "testAccessKeyHere123")
+        self.assertEqual(key1.secret_key, "[REDACTED]")
 
         self.assertEqual(key2.id, 2)
-        self.assertEqual(key2.label, 'object-storage-key-2')
-        self.assertEqual(key2.access_key, 'testAccessKeyHere456')
-        self.assertEqual(key2.secret_key, '[REDACTED]')
+        self.assertEqual(key2.label, "object-storage-key-2")
+        self.assertEqual(key2.access_key, "testAccessKeyHere456")
+        self.assertEqual(key2.secret_key, "[REDACTED]")
 
     def test_keys_create(self):
         """
         Tests that you can create Object Storage Keys
         """
-        with self.mock_post('object-storage/keys/1') as m:
-            keys = self.client.object_storage.keys_create('object-storage-key-1')
+        with self.mock_post("object-storage/keys/1") as m:
+            keys = self.client.object_storage.keys_create(
+                "object-storage-key-1"
+            )
 
             self.assertIsNotNone(keys)
             self.assertEqual(keys.id, 1)
-            self.assertEqual(keys.label, 'object-storage-key-1')
+            self.assertEqual(keys.label, "object-storage-key-1")
+
+            self.assertEqual(m.call_url, "/object-storage/keys")
+            self.assertEqual(m.call_data, {"label": "object-storage-key-1"})
+
+    def test_transfer(self):
+        """
+        Test that you can get the amount of outbound data transfer
+        used by your account’s Object Storage buckets
+        """
+        object_storage_transfer_url = "/object-storage/transfer"
+
+        with self.mock_get(object_storage_transfer_url) as m:
+            result = self.client.object_storage.transfer()
+            self.assertEqual(result.used, 12956600198)
+            self.assertEqual(m.call_url, object_storage_transfer_url)
+
+    def test_buckets(self):
+        """
+        Test that Object Storage Buckets can be reterived
+        """
+        object_storage_buckets_url = "/object-storage/buckets"
+
+        with self.mock_get(object_storage_buckets_url) as m:
+            buckets = self.client.object_storage.buckets()
+            self.assertIsNotNone(buckets)
+            bucket = buckets[0]
+
+            self.assertEqual(m.call_url, object_storage_buckets_url)
+            self.assertEqual(bucket.cluster, "us-east-1")
+            self.assertEqual(
+                bucket.created,
+                datetime(
+                    year=2019, month=1, day=1, hour=1, minute=23, second=45
+                ),
+            )
+            self.assertEqual(
+                bucket.hostname, "example-bucket.us-east-1.linodeobjects.com"
+            )
+            self.assertEqual(bucket.label, "example-bucket")
+            self.assertEqual(bucket.objects, 4)
+            self.assertEqual(bucket.size, 188318981)
+
+    def test_bucket_create(self):
+        """
+        Test that you can create a Object Storage Bucket
+        """
+        # buckets don't work like a normal RESTful collection, so we have to do this
+        with self.mock_post(
+            {"label": "example-bucket", "cluster": "us-east-1"}
+        ) as m:
+            b = self.client.object_storage.bucket_create(
+                "us-east-1", "example-bucket", ObjectStorageACL.PRIVATE, True
+            )
+            self.assertIsNotNone(b)
+            self.assertEqual(m.call_url, "/object-storage/buckets")
+            self.assertEqual(
+                m.call_data,
+                {
+                    "label": "example-bucket",
+                    "cluster": "us-east-1",
+                    "cors_enabled": True,
+                    "acl": "private",
+                },
+            )
+
+        """
+        Test that you can create a Object Storage Bucket passing a Cluster object
+        """
+        with self.mock_post(
+            {"label": "example-bucket", "cluster": "us-east-1"}
+        ) as m:
+            cluster = ObjectStorageCluster(self.client, "us-east-1")
+            b = self.client.object_storage.bucket_create(
+                cluster, "example-bucket", "private", True
+            )
+            self.assertIsNotNone(b)
+            self.assertEqual(m.call_url, "/object-storage/buckets")
+            self.assertEqual(
+                m.call_data,
+                {
+                    "label": "example-bucket",
+                    "cluster": "us-east-1",
+                    "cors_enabled": True,
+                    "acl": "private",
+                },
+            )
+
+    def test_object_url_create(self):
+        """
+        Test that you can create pre-signed URL to access a single Object in a bucket.
+        """
+        object_url_create_url = (
+            "/object-storage/buckets/us-east-1/example-bucket/object-url"
+        )
+        with self.mock_post(object_url_create_url) as m:
+            result = self.client.object_storage.object_url_create(
+                "us-east-1", "example-bucket", "GET", "example"
+            )
+            self.assertIsNotNone(result)
+            self.assertEqual(m.call_url, object_url_create_url)
+            self.assertEqual(
+                result.url,
+                "https://us-east-1.linodeobjects.com/example-bucket/example?Signature=qr98TEucCntPgEG%2BsZQGDsJg93c%3D&Expires=1567609905&AWSAccessKeyId=G4YAF81XWY61DQM94SE0",
+            )
+            self.assertEqual(
+                m.call_data,
+                {
+                    "method": "GET",
+                    "name": "example",
+                    "expires_in": 3600,
+                },
+            )
 
-            self.assertEqual(m.call_url, '/object-storage/keys')
-            self.assertEqual(m.call_data, {"label":"object-storage-key-1"})
 
 class NetworkingGroupTest(ClientBaseCase):
     """
     Tests for the NetworkingGroup
     """
+
     def test_get_vlans(self):
         """
         Tests that Object Storage Clusters can be retrieved
@@ -568,36 +966,40 @@ def test_get_vlans(self):
         vlans = self.client.networking.vlans()
 
         self.assertEqual(len(vlans), 1)
-        self.assertEqual(vlans[0].label, 'vlan-test')
-        self.assertEqual(vlans[0].region.id, 'us-southeast')
+        self.assertEqual(vlans[0].label, "vlan-test")
+        self.assertEqual(vlans[0].region.id, "us-southeast")
 
         self.assertEqual(len(vlans[0].linodes), 2)
         self.assertEqual(vlans[0].linodes[0], 111)
         self.assertEqual(vlans[0].linodes[1], 222)
 
     def test_firewall_create(self):
-        with self.mock_post('networking/firewalls/123') as m:
+        with self.mock_post("networking/firewalls/123") as m:
             rules = {
-                'outbound': [],
-                'outbound_policy': 'DROP',
-                'inbound': [],
-                'inbound_policy': 'DROP'
+                "outbound": [],
+                "outbound_policy": "DROP",
+                "inbound": [],
+                "inbound_policy": "DROP",
             }
 
-            f = self.client.networking.firewall_create('test-firewall-1', rules,
-                                            status='enabled')
+            f = self.client.networking.firewall_create(
+                "test-firewall-1", rules, status="enabled"
+            )
 
             self.assertIsNotNone(f)
 
-            self.assertEqual(m.call_url, '/networking/firewalls')
-            self.assertEqual(m.method, 'post')
+            self.assertEqual(m.call_url, "/networking/firewalls")
+            self.assertEqual(m.method, "post")
 
             self.assertEqual(f.id, 123)
-            self.assertEqual(m.call_data, {
-                'label': 'test-firewall-1',
-                'status': 'enabled',
-                'rules': rules
-            })
+            self.assertEqual(
+                m.call_data,
+                {
+                    "label": "test-firewall-1",
+                    "status": "enabled",
+                    "rules": rules,
+                },
+            )
 
     def test_get_firewalls(self):
         """
@@ -610,6 +1012,57 @@ def test_get_firewalls(self):
 
         self.assertEqual(firewall.id, 123)
 
+    def test_ip_addresses_share(self):
+        """
+        Tests that you can submit a correct ip addresses share api request.
+        """
+
+        ip = IPAddress(self.client, "192.0.2.1", {})
+        linode = Instance(self.client, 123)
+
+        with self.mock_post({}) as m:
+            self.client.networking.ip_addresses_share(["192.0.2.1"], 123)
+            self.assertEqual(m.call_url, "/networking/ips/share")
+            self.assertEqual(m.call_data["ips"], ["192.0.2.1"])
+            self.assertEqual(m.call_data["linode_id"], 123)
+
+        with self.mock_post({}) as m:
+            self.client.networking.ip_addresses_share([ip], 123)
+            self.assertEqual(m.call_url, "/networking/ips/share")
+            self.assertEqual(m.call_data["ips"], ["192.0.2.1"])
+            self.assertEqual(m.call_data["linode_id"], 123)
+
+        with self.mock_post({}) as m:
+            self.client.networking.ip_addresses_share(["192.0.2.1"], linode)
+            self.assertEqual(m.call_url, "/networking/ips/share")
+            self.assertEqual(m.call_data["ips"], ["192.0.2.1"])
+            self.assertEqual(m.call_data["linode_id"], 123)
+
+    def test_ip_addresses_assign(self):
+        """
+        Tests that you can submit a correct ip addresses assign api request.
+        """
+
+        with self.mock_post({}) as m:
+            self.client.networking.ip_addresses_assign(
+                {"assignments": [{"address": "192.0.2.1", "linode_id": 123}]},
+                "us-east",
+            )
+            self.assertEqual(m.call_url, "/networking/ips/assign")
+            self.assertEqual(
+                m.call_data["assignments"],
+                {"assignments": [{"address": "192.0.2.1", "linode_id": 123}]},
+            )
+            self.assertEqual(m.call_data["region"], "us-east")
+
+    def test_ipv6_ranges(self):
+        """
+        Tests that IPRanges can be retrieved
+        """
+        ranges = self.client.networking.ipv6_ranges()
+        self.assertEqual(len(ranges), 1)
+        self.assertEqual(ranges[0].range, "2600:3c01::")
+
 
 class LinodeClientRateLimitRetryTest(TestCase):
     """
@@ -621,8 +1074,11 @@ class LinodeClientRateLimitRetryTest(TestCase):
        pertain to the 429 retry logic, and make sure you mock the requests calls yourself
        (or else they will make real requests and those won't work).
     """
+
     def setUp(self):
-        self.client = LinodeClient("testing", base_url="/", retry_rate_limit_interval=1)
+        self.client = LinodeClient(
+            "testing", base_url="/", retry_rate_limit_interval=1
+        )
         # sidestep the validation to do immediate retries so tests aren't slow
         self.client.retry_rate_limit_interval = 0.1
 
@@ -641,6 +1097,7 @@ def test_retry_429s(self):
         Tests that 429 responses are automatically retried
         """
         called = 0
+
         def test_method(*args, **kwargs):
             nonlocal called
             called += 1
@@ -648,7 +1105,7 @@ def test_method(*args, **kwargs):
                 return self._get_mock_response(429)
             return self._get_mock_response(200)
 
-        response = self.client._api_call('/test', method=test_method)
+        response = self.client._api_call("/test", method=test_method)
 
         # it retried once, got the empty object
         assert called == 2
@@ -659,13 +1116,14 @@ def test_retry_max_attempts(self):
         Tests that a request will fail after 5 429 responses in a row
         """
         called = 0
+
         def test_method(*args, **kwargs):
             nonlocal called
             called += 1
             return self._get_mock_response(429)
 
         try:
-            response = self.client._api_call('/test', method=test_method)
+            response = self.client._api_call("/test", method=test_method)
             assert False, "Unexpectedly did not raise ApiError!"
         except ApiError as e:
             assert e.status == 429
@@ -679,13 +1137,14 @@ def test_api_error_with_retry(self):
         enabled
         """
         called = 0
+
         def test_method(*args, **kwargs):
             nonlocal called
             called += 1
             return self._get_mock_response(400)
 
         try:
-            response = self.client._api_call('/test', method=test_method)
+            response = self.client._api_call("/test", method=test_method)
             assert False, "Unexpectedly did not raise ApiError!"
         except ApiError as e:
             assert e.status == 400
@@ -699,6 +1158,7 @@ def test_api_error_on_retry(self):
         response after a 429
         """
         called = 0
+
         def test_method(*args, **kwargs):
             nonlocal called
             called += 1
@@ -707,7 +1167,7 @@ def test_method(*args, **kwargs):
             return self._get_mock_response(400)
 
         try:
-            response = self.client._api_call('/test', method=test_method)
+            response = self.client._api_call("/test", method=test_method)
             assert False, "Unexpectedly did not raise ApiError!"
         except ApiError as e:
             assert e.status == 400
@@ -721,12 +1181,13 @@ def test_works_first_time(self):
         try
         """
         called = 0
+
         def test_method(*args, **kwargs):
             nonlocal called
             called += 1
             return self._get_mock_response(200)
 
-        response = self.client._api_call('/test', method=test_method)
+        response = self.client._api_call("/test", method=test_method)
 
         # it tried 5 times
         assert called == 1
diff --git a/test/login_client_test.py b/test/login_client_test.py
new file mode 100644
index 000000000..5a17d77c1
--- /dev/null
+++ b/test/login_client_test.py
@@ -0,0 +1,55 @@
+from unittest import TestCase
+
+from linode_api4 import OAuthScopes
+
+
+class OAuthScopesTest(TestCase):
+    def test_parse_scopes_none(self):
+        """
+        Tests parsing no scopes
+        """
+        scopes = OAuthScopes.parse("")
+        self.assertEqual(scopes, [])
+
+    def test_parse_scopes_single(self):
+        """
+        Tests parsing a single scope
+        """
+        scopes = OAuthScopes.parse("linodes:read_only")
+        self.assertEqual(scopes, [OAuthScopes.Linodes.read_only])
+
+    def test_parse_scopes_many(self):
+        """
+        Tests parsing many scopes
+        """
+        scopes = OAuthScopes.parse("linodes:read_only domains:read_write")
+        self.assertEqual(
+            scopes,
+            [OAuthScopes.Linodes.read_only, OAuthScopes.Domains.read_write],
+        )
+
+    def test_parse_scopes_many_comma_delimited(self):
+        """
+        Tests parsing many scopes that are comma-delimited (which preserves old behavior)
+        """
+        scopes = OAuthScopes.parse(
+            "nodebalancers:read_write,stackscripts:*,events:read_only"
+        )
+        self.assertEqual(
+            scopes,
+            [
+                OAuthScopes.NodeBalancers.read_write,
+                OAuthScopes.StackScripts.all,
+                OAuthScopes.Events.read_only,
+            ],
+        )
+
+    def test_parse_scopes_all(self):
+        """
+        Tests parsing * scopes
+        """
+        scopes = OAuthScopes.parse("*")
+        self.assertEqual(
+            scopes,
+            [getattr(c, "all") for c in OAuthScopes._scope_families.values()],
+        )
diff --git a/test/objects/account_test.py b/test/objects/account_test.py
index f869eb764..4648de12d 100644
--- a/test/objects/account_test.py
+++ b/test/objects/account_test.py
@@ -1,19 +1,42 @@
 from datetime import datetime
-
 from test.base import ClientBaseCase
 
-from linode_api4.objects import Invoice
+from linode_api4.objects import (
+    Account,
+    AccountSettings,
+    Database,
+    Domain,
+    Event,
+    Firewall,
+    Image,
+    Instance,
+    Invoice,
+    Login,
+    LongviewClient,
+    NodeBalancer,
+    OAuthClient,
+    PaymentMethod,
+    ServiceTransfer,
+    StackScript,
+    User,
+    Volume,
+    get_obj_grants,
+)
 
 
 class InvoiceTest(ClientBaseCase):
     """
     Tests methods of the Invoice
     """
+
     def test_get_invoice(self):
+        """
+        Tests that an invoice is loaded correctly by ID
+        """
         invoice = Invoice(self.client, 123456)
         self.assertEqual(invoice._populated, False)
 
-        self.assertEqual(invoice.label, 'Invoice #123456')
+        self.assertEqual(invoice.label, "Invoice #123456")
         self.assertEqual(invoice._populated, True)
 
         self.assertEqual(invoice.date, datetime(2015, 1, 1, 5, 1, 2))
@@ -34,5 +57,186 @@ def test_get_invoice_items(self):
         self.assertEqual(item.amount, 9.51)
         self.assertEqual(item.quantity, 317)
         self.assertEqual(item.unit_price, "0.03")
-        self.assertEqual(item.from_date, datetime(year=2014, month=12, day=19, hour=0, minute=27, second=2))
-        self.assertEqual(item.to_date, datetime(year=2015, month=1, day=1, hour=4, minute=59, second=59))
+        self.assertEqual(
+            item.from_date,
+            datetime(year=2014, month=12, day=19, hour=0, minute=27, second=2),
+        )
+        self.assertEqual(
+            item.to_date,
+            datetime(year=2015, month=1, day=1, hour=4, minute=59, second=59),
+        )
+
+    def test_get_account(self):
+        """
+        Tests that an account is loaded correctly by email
+        """
+        account = Account(self.client, "support@linode.com", {})
+
+        self.assertEqual(account.email, "support@linode.com")
+        self.assertEqual(account.state, "PA")
+        self.assertEqual(account.city, "Philadelphia")
+        self.assertEqual(account.phone, "123-456-7890")
+        self.assertEqual(account.tax_id, "")
+        self.assertEqual(account.balance, 0)
+        self.assertEqual(account.company, "Linode")
+        self.assertEqual(account.address_1, "3rd & Arch St")
+        self.assertEqual(account.address_2, "")
+        self.assertEqual(account.zip, "19106")
+        self.assertEqual(account.first_name, "Test")
+        self.assertEqual(account.last_name, "Guy")
+        self.assertEqual(account.country, "US")
+        self.assertIsNotNone(account.capabilities)
+        self.assertIsNotNone(account.active_promotions)
+        self.assertEqual(account.balance_uninvoiced, 145)
+        self.assertEqual(account.billing_source, "akamai")
+        self.assertEqual(account.euuid, "E1AF5EEC-526F-487D-B317EBEB34C87D71")
+
+    def test_get_login(self):
+        """
+        Tests that a login is loaded correctly by ID
+        """
+        login = Login(self.client, 123)
+
+        self.assertEqual(login.id, 123)
+        self.assertEqual(login.ip, "192.0.2.0")
+        self.assertEqual(login.restricted, True)
+        self.assertEqual(login.status, "successful")
+        self.assertEqual(login.username, "test-user")
+
+    def test_get_account_settings(self):
+        """
+        Tests that account settings are loaded correctly
+        """
+        settings = AccountSettings(self.client, False, {})
+
+        self.assertEqual(settings.longview_subscription.id, "longview-100")
+        self.assertEqual(settings.managed, False)
+        self.assertEqual(settings.network_helper, False)
+        self.assertEqual(settings.object_storage, "active")
+        self.assertEqual(settings.backups_enabled, True)
+
+    def test_get_event(self):
+        """
+        Tests that an event is loaded correctly by ID
+        """
+        event = Event(self.client, 123, {})
+
+        self.assertEqual(event.action, "ticket_create")
+        self.assertEqual(event.created, datetime(2018, 1, 1, 0, 1, 1))
+        self.assertEqual(event.duration, 300.56)
+        self.assertIsNotNone(event.entity)
+        self.assertEqual(event.id, 123)
+        self.assertEqual(event.message, "None")
+        self.assertIsNone(event.percent_complete)
+        self.assertIsNone(event.rate)
+        self.assertTrue(event.read)
+        self.assertIsNotNone(event.secondary_entity)
+        self.assertTrue(event.seen)
+        self.assertIsNone(event.status)
+        self.assertIsNone(event.time_remaining)
+        self.assertEqual(event.username, "exampleUser")
+
+    def test_get_invoice(self):
+        """
+        Tests that an invoice is loaded correctly by ID
+        """
+        invoice = Invoice(self.client, 123, {})
+
+        self.assertEqual(invoice.date, datetime(2018, 1, 1, 0, 1, 1))
+        self.assertEqual(invoice.id, 123)
+        self.assertEqual(invoice.label, "Invoice")
+        self.assertEqual(invoice.subtotal, 120.25)
+        self.assertEqual(invoice.tax, 12.25)
+        self.assertEqual(invoice.total, 132.5)
+        self.assertIsNotNone(invoice.tax_summary)
+
+    def test_get_oauth_client(self):
+        """
+        Tests that an oauth client is loaded correctly by ID
+        """
+        client = OAuthClient(self.client, "2737bf16b39ab5d7b4a1", {})
+
+        self.assertEqual(client.id, "2737bf16b39ab5d7b4a1")
+        self.assertEqual(client.label, "Test_Client_1")
+        self.assertFalse(client.public)
+        self.assertEqual(
+            client.redirect_uri, "https://example.org/oauth/callback"
+        )
+        self.assertEqual(client.secret, "<REDACTED>")
+        self.assertEqual(client.status, "active")
+        self.assertEqual(
+            client.thumbnail_url,
+            "https://api.linode.com/v4/account/clients/2737bf16b39ab5d7b4a1/thumbnail",
+        )
+
+    def test_get_user(self):
+        """
+        Tests that a user is loaded correctly by username
+        """
+        user = User(self.client, "test-user", {})
+
+        self.assertEqual(user.username, "test-user")
+        self.assertEqual(user.email, "test-user@linode.com")
+        self.assertTrue(user.restricted)
+        self.assertTrue(user.tfa_enabled)
+        self.assertIsNotNone(user.ssh_keys)
+
+    def test_get_service_transfer(self):
+        """
+        Tests that a service transfer is loaded correctly by token
+        """
+        serviceTransfer = ServiceTransfer(self.client, "12345")
+
+        self.assertEqual(serviceTransfer.token, "12345")
+        self.assertTrue(serviceTransfer.is_sender)
+        self.assertEqual(serviceTransfer.status, "pending")
+
+    def test_get_payment_method(self):
+        """
+        Tests that a payment method is loaded correctly by ID
+        """
+        paymentMethod = PaymentMethod(self.client, 123)
+
+        self.assertEqual(paymentMethod.id, 123)
+        self.assertTrue(paymentMethod.is_default)
+        self.assertEqual(paymentMethod.type, "credit_card")
+
+    def test_get_user_grant(self):
+        """
+        Tests that a user grant is loaded correctly
+        """
+        grants = get_obj_grants()
+
+        self.assertTrue(grants.count(("linode", Instance)) > 0)
+        self.assertTrue(grants.count(("domain", Domain)) > 0)
+        self.assertTrue(grants.count(("stackscript", StackScript)) > 0)
+        self.assertTrue(grants.count(("nodebalancer", NodeBalancer)) > 0)
+        self.assertTrue(grants.count(("volume", Volume)) > 0)
+        self.assertTrue(grants.count(("image", Image)) > 0)
+        self.assertTrue(grants.count(("longview", LongviewClient)) > 0)
+        self.assertTrue(grants.count(("database", Database)) > 0)
+        self.assertTrue(grants.count(("firewall", Firewall)) > 0)
+
+    def test_payment_method_make_default(self):
+        """
+        Tests that making a payment method default creates the correct api request.
+        """
+        paymentMethod = PaymentMethod(self.client, 123)
+
+        with self.mock_post({}) as m:
+            paymentMethod.payment_method_make_default()
+            self.assertEqual(
+                m.call_url, "/account/payment-methods/123/make-default"
+            )
+
+    def test_service_transfer_accept(self):
+        """
+        Tests that accepting a service transfer creates the correct api request.
+        """
+        serviceTransfer = ServiceTransfer(self.client, "12345")
+
+        with self.mock_post({}) as m:
+            serviceTransfer.service_transfer_accept()
+            self.assertEqual(
+                m.call_url, "/account/service-transfers/12345/accept"
+            )
diff --git a/test/objects/database_test.py b/test/objects/database_test.py
index 8aed8e182..a70f3ae54 100644
--- a/test/objects/database_test.py
+++ b/test/objects/database_test.py
@@ -1,6 +1,6 @@
-from linode_api4 import PostgreSQLDatabase, MongoDBDatabase
 from test.base import ClientBaseCase
 
+from linode_api4 import MongoDBDatabase, PostgreSQLDatabase
 from linode_api4.objects import MySQLDatabase
 
 
@@ -16,8 +16,8 @@ def test_get_types(self):
         types = self.client.database.types()
 
         self.assertEqual(len(types), 1)
-        self.assertEqual(types[0].type_class, 'nanode')
-        self.assertEqual(types[0].id, 'g6-nanode-1')
+        self.assertEqual(types[0].type_class, "nanode")
+        self.assertEqual(types[0].id, "g6-nanode-1")
         self.assertEqual(types[0].engines.mongodb[0].price.monthly, 20)
 
     def test_get_engines(self):
@@ -28,13 +28,13 @@ def test_get_engines(self):
 
         self.assertEqual(len(engines), 2)
 
-        self.assertEqual(engines[0].engine, 'mysql')
-        self.assertEqual(engines[0].id, 'mysql/8.0.26')
-        self.assertEqual(engines[0].version, '8.0.26')
+        self.assertEqual(engines[0].engine, "mysql")
+        self.assertEqual(engines[0].id, "mysql/8.0.26")
+        self.assertEqual(engines[0].version, "8.0.26")
 
-        self.assertEqual(engines[1].engine, 'postgresql')
-        self.assertEqual(engines[1].id, 'postgresql/10.14')
-        self.assertEqual(engines[1].version, '10.14')
+        self.assertEqual(engines[1].engine, "postgresql")
+        self.assertEqual(engines[1].id, "postgresql/10.14")
+        self.assertEqual(engines[1].version, "10.14")
 
     def test_get_databases(self):
         """
@@ -43,16 +43,22 @@ def test_get_databases(self):
         dbs = self.client.database.instances()
 
         self.assertEqual(len(dbs), 1)
-        self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24')
+        self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24")
         self.assertEqual(dbs[0].cluster_size, 3)
         self.assertEqual(dbs[0].encrypted, False)
-        self.assertEqual(dbs[0].engine, 'mysql')
-        self.assertEqual(dbs[0].hosts.primary, 'lin-123-456-mysql-mysql-primary.servers.linodedb.net')
-        self.assertEqual(dbs[0].hosts.secondary, 'lin-123-456-mysql-primary-private.servers.linodedb.net')
+        self.assertEqual(dbs[0].engine, "mysql")
+        self.assertEqual(
+            dbs[0].hosts.primary,
+            "lin-123-456-mysql-mysql-primary.servers.linodedb.net",
+        )
+        self.assertEqual(
+            dbs[0].hosts.secondary,
+            "lin-123-456-mysql-primary-private.servers.linodedb.net",
+        )
         self.assertEqual(dbs[0].id, 123)
-        self.assertEqual(dbs[0].region, 'us-east')
+        self.assertEqual(dbs[0].region, "us-east")
         self.assertEqual(dbs[0].updates.duration, 3)
-        self.assertEqual(dbs[0].version, '8.0.26')
+        self.assertEqual(dbs[0].version, "8.0.26")
 
     def test_database_instance(self):
         """
@@ -78,65 +84,71 @@ def test_get_instances(self):
         dbs = self.client.database.mysql_instances()
 
         self.assertEqual(len(dbs), 1)
-        self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24')
+        self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24")
         self.assertEqual(dbs[0].cluster_size, 3)
         self.assertEqual(dbs[0].encrypted, False)
-        self.assertEqual(dbs[0].engine, 'mysql')
-        self.assertEqual(dbs[0].hosts.primary, 'lin-123-456-mysql-mysql-primary.servers.linodedb.net')
-        self.assertEqual(dbs[0].hosts.secondary, 'lin-123-456-mysql-primary-private.servers.linodedb.net')
+        self.assertEqual(dbs[0].engine, "mysql")
+        self.assertEqual(
+            dbs[0].hosts.primary,
+            "lin-123-456-mysql-mysql-primary.servers.linodedb.net",
+        )
+        self.assertEqual(
+            dbs[0].hosts.secondary,
+            "lin-123-456-mysql-primary-private.servers.linodedb.net",
+        )
         self.assertEqual(dbs[0].id, 123)
-        self.assertEqual(dbs[0].region, 'us-east')
+        self.assertEqual(dbs[0].region, "us-east")
         self.assertEqual(dbs[0].updates.duration, 3)
-        self.assertEqual(dbs[0].version, '8.0.26')
+        self.assertEqual(dbs[0].version, "8.0.26")
 
     def test_create(self):
         """
         Test that MySQL databases can be created
         """
 
-        with self.mock_post('/databases/mysql/instances') as m:
+        with self.mock_post("/databases/mysql/instances") as m:
             # We don't care about errors here; we just want to
             # validate the request.
             try:
                 self.client.database.mysql_create(
-                    'cool',
-                    'us-southeast',
-                    'mysql/8.0.26',
-                    'g6-standard-1',
-                    cluster_size=3
+                    "cool",
+                    "us-southeast",
+                    "mysql/8.0.26",
+                    "g6-standard-1",
+                    cluster_size=3,
                 )
             except Exception:
                 pass
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mysql/instances')
-            self.assertEqual(m.call_data['label'], 'cool')
-            self.assertEqual(m.call_data['region'], 'us-southeast')
-            self.assertEqual(m.call_data['engine'], 'mysql/8.0.26')
-            self.assertEqual(m.call_data['type'], 'g6-standard-1')
-            self.assertEqual(m.call_data['cluster_size'], 3)
+            self.assertEqual(m.method, "post")
+            self.assertEqual(m.call_url, "/databases/mysql/instances")
+            self.assertEqual(m.call_data["label"], "cool")
+            self.assertEqual(m.call_data["region"], "us-southeast")
+            self.assertEqual(m.call_data["engine"], "mysql/8.0.26")
+            self.assertEqual(m.call_data["type"], "g6-standard-1")
+            self.assertEqual(m.call_data["cluster_size"], 3)
 
     def test_update(self):
         """
         Test that the MySQL database can be updated
         """
 
-        with self.mock_put('/databases/mysql/instances/123') as m:
-            new_allow_list = ['192.168.0.1/32']
+        with self.mock_put("/databases/mysql/instances/123") as m:
+            new_allow_list = ["192.168.0.1/32"]
 
             db = MySQLDatabase(self.client, 123)
 
             db.updates.day_of_week = 2
             db.allow_list = new_allow_list
-            db.label = 'cool'
+            db.label = "cool"
 
             db.save()
 
-            self.assertEqual(m.method, 'put')
-            self.assertEqual(m.call_url, '/databases/mysql/instances/123')
-            self.assertEqual(m.call_data['label'], 'cool')
-            self.assertEqual(m.call_data['updates']['day_of_week'], 2)
-            self.assertEqual(m.call_data['allow_list'], new_allow_list)
+            self.assertEqual(m.method, "put")
+            self.assertEqual(m.call_url, "/databases/mysql/instances/123")
+            self.assertEqual(m.call_data["label"], "cool")
+            self.assertEqual(m.call_data["updates"]["day_of_week"], 2)
+            self.assertEqual(m.call_data["allow_list"], new_allow_list)
 
     def test_list_backups(self):
         """
@@ -149,53 +161,61 @@ def test_list_backups(self):
         self.assertEqual(len(backups), 1)
 
         self.assertEqual(backups[0].id, 456)
-        self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI')
-        self.assertEqual(backups[0].type, 'auto')
+        self.assertEqual(
+            backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI"
+        )
+        self.assertEqual(backups[0].type, "auto")
 
     def test_create_backup(self):
         """
         Test that MySQL database backups can be updated
         """
 
-        with self.mock_post('/databases/mysql/instances/123/backups') as m:
+        with self.mock_post("/databases/mysql/instances/123/backups") as m:
             db = MySQLDatabase(self.client, 123)
 
             # We don't care about errors here; we just want to
             # validate the request.
             try:
-                db.backup_create('mybackup', target='secondary')
+                db.backup_create("mybackup", target="secondary")
             except Exception:
                 pass
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mysql/instances/123/backups')
-            self.assertEqual(m.call_data['label'], 'mybackup')
-            self.assertEqual(m.call_data['target'], 'secondary')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url, "/databases/mysql/instances/123/backups"
+            )
+            self.assertEqual(m.call_data["label"], "mybackup")
+            self.assertEqual(m.call_data["target"], "secondary")
 
     def test_backup_restore(self):
         """
         Test that MySQL database backups can be restored
         """
 
-        with self.mock_post('/databases/mysql/instances/123/backups/456/restore') as m:
+        with self.mock_post(
+            "/databases/mysql/instances/123/backups/456/restore"
+        ) as m:
             db = MySQLDatabase(self.client, 123)
 
             db.backups[0].restore()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mysql/instances/123/backups/456/restore')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url, "/databases/mysql/instances/123/backups/456/restore"
+            )
 
     def test_patch(self):
         """
         Test MySQL Database patching logic.
         """
-        with self.mock_post('/databases/mysql/instances/123/patch') as m:
+        with self.mock_post("/databases/mysql/instances/123/patch") as m:
             db = MySQLDatabase(self.client, 123)
 
             db.patch()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mysql/instances/123/patch')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(m.call_url, "/databases/mysql/instances/123/patch")
 
     def test_get_ssl(self):
         """
@@ -205,7 +225,7 @@ def test_get_ssl(self):
 
         ssl = db.ssl
 
-        self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==')
+        self.assertEqual(ssl.ca_certificate, "LS0tLS1CRUdJ...==")
 
     def test_get_credentials(self):
         """
@@ -215,20 +235,24 @@ def test_get_credentials(self):
 
         creds = db.credentials
 
-        self.assertEqual(creds.password, 's3cur3P@ssw0rd')
-        self.assertEqual(creds.username, 'linroot')
+        self.assertEqual(creds.password, "s3cur3P@ssw0rd")
+        self.assertEqual(creds.username, "linroot")
 
     def test_reset_credentials(self):
         """
         Test resetting MySQL credentials
         """
-        with self.mock_post('/databases/mysql/instances/123/credentials/reset') as m:
+        with self.mock_post(
+            "/databases/mysql/instances/123/credentials/reset"
+        ) as m:
             db = MySQLDatabase(self.client, 123)
 
             db.credentials_reset()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mysql/instances/123/credentials/reset')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url, "/databases/mysql/instances/123/credentials/reset"
+            )
 
 
 class PostgreSQLDatabaseTest(ClientBaseCase):
@@ -243,65 +267,71 @@ def test_get_instances(self):
         dbs = self.client.database.postgresql_instances()
 
         self.assertEqual(len(dbs), 1)
-        self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24')
+        self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24")
         self.assertEqual(dbs[0].cluster_size, 3)
         self.assertEqual(dbs[0].encrypted, False)
-        self.assertEqual(dbs[0].engine, 'postgresql')
-        self.assertEqual(dbs[0].hosts.primary, 'lin-0000-000-pgsql-primary.servers.linodedb.net')
-        self.assertEqual(dbs[0].hosts.secondary, 'lin-0000-000-pgsql-primary-private.servers.linodedb.net')
+        self.assertEqual(dbs[0].engine, "postgresql")
+        self.assertEqual(
+            dbs[0].hosts.primary,
+            "lin-0000-000-pgsql-primary.servers.linodedb.net",
+        )
+        self.assertEqual(
+            dbs[0].hosts.secondary,
+            "lin-0000-000-pgsql-primary-private.servers.linodedb.net",
+        )
         self.assertEqual(dbs[0].id, 123)
-        self.assertEqual(dbs[0].region, 'us-east')
+        self.assertEqual(dbs[0].region, "us-east")
         self.assertEqual(dbs[0].updates.duration, 3)
-        self.assertEqual(dbs[0].version, '13.2')
+        self.assertEqual(dbs[0].version, "13.2")
 
     def test_create(self):
         """
         Test that PostgreSQL databases can be created
         """
 
-        with self.mock_post('/databases/postgresql/instances') as m:
+        with self.mock_post("/databases/postgresql/instances") as m:
             # We don't care about errors here; we just want to
             # validate the request.
             try:
                 self.client.database.postgresql_create(
-                    'cool',
-                    'us-southeast',
-                    'postgresql/13.2',
-                    'g6-standard-1',
-                    cluster_size=3
+                    "cool",
+                    "us-southeast",
+                    "postgresql/13.2",
+                    "g6-standard-1",
+                    cluster_size=3,
                 )
             except Exception:
                 pass
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/postgresql/instances')
-            self.assertEqual(m.call_data['label'], 'cool')
-            self.assertEqual(m.call_data['region'], 'us-southeast')
-            self.assertEqual(m.call_data['engine'], 'postgresql/13.2')
-            self.assertEqual(m.call_data['type'], 'g6-standard-1')
-            self.assertEqual(m.call_data['cluster_size'], 3)
+            self.assertEqual(m.method, "post")
+            self.assertEqual(m.call_url, "/databases/postgresql/instances")
+            self.assertEqual(m.call_data["label"], "cool")
+            self.assertEqual(m.call_data["region"], "us-southeast")
+            self.assertEqual(m.call_data["engine"], "postgresql/13.2")
+            self.assertEqual(m.call_data["type"], "g6-standard-1")
+            self.assertEqual(m.call_data["cluster_size"], 3)
 
     def test_update(self):
         """
         Test that the PostgreSQL database can be updated
         """
 
-        with self.mock_put('/databases/postgresql/instances/123') as m:
-            new_allow_list = ['192.168.0.1/32']
+        with self.mock_put("/databases/postgresql/instances/123") as m:
+            new_allow_list = ["192.168.0.1/32"]
 
             db = PostgreSQLDatabase(self.client, 123)
 
             db.updates.day_of_week = 2
             db.allow_list = new_allow_list
-            db.label = 'cool'
+            db.label = "cool"
 
             db.save()
 
-            self.assertEqual(m.method, 'put')
-            self.assertEqual(m.call_url, '/databases/postgresql/instances/123')
-            self.assertEqual(m.call_data['label'], 'cool')
-            self.assertEqual(m.call_data['updates']['day_of_week'], 2)
-            self.assertEqual(m.call_data['allow_list'], new_allow_list)
+            self.assertEqual(m.method, "put")
+            self.assertEqual(m.call_url, "/databases/postgresql/instances/123")
+            self.assertEqual(m.call_data["label"], "cool")
+            self.assertEqual(m.call_data["updates"]["day_of_week"], 2)
+            self.assertEqual(m.call_data["allow_list"], new_allow_list)
 
     def test_list_backups(self):
         """
@@ -314,53 +344,64 @@ def test_list_backups(self):
         self.assertEqual(len(backups), 1)
 
         self.assertEqual(backups[0].id, 456)
-        self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI')
-        self.assertEqual(backups[0].type, 'auto')
+        self.assertEqual(
+            backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI"
+        )
+        self.assertEqual(backups[0].type, "auto")
 
     def test_create_backup(self):
         """
         Test that PostgreSQL database backups can be created
         """
 
-        with self.mock_post('/databases/postgresql/instances/123/backups') as m:
+        with self.mock_post("/databases/postgresql/instances/123/backups") as m:
             db = PostgreSQLDatabase(self.client, 123)
 
             # We don't care about errors here; we just want to
             # validate the request.
             try:
-                db.backup_create('mybackup', target='secondary')
+                db.backup_create("mybackup", target="secondary")
             except Exception:
                 pass
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/postgresql/instances/123/backups')
-            self.assertEqual(m.call_data['label'], 'mybackup')
-            self.assertEqual(m.call_data['target'], 'secondary')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url, "/databases/postgresql/instances/123/backups"
+            )
+            self.assertEqual(m.call_data["label"], "mybackup")
+            self.assertEqual(m.call_data["target"], "secondary")
 
     def test_backup_restore(self):
         """
         Test that PostgreSQL database backups can be restored
         """
 
-        with self.mock_post('/databases/postgresql/instances/123/backups/456/restore') as m:
+        with self.mock_post(
+            "/databases/postgresql/instances/123/backups/456/restore"
+        ) as m:
             db = PostgreSQLDatabase(self.client, 123)
 
             db.backups[0].restore()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/postgresql/instances/123/backups/456/restore')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url,
+                "/databases/postgresql/instances/123/backups/456/restore",
+            )
 
     def test_patch(self):
         """
         Test PostgreSQL Database patching logic.
         """
-        with self.mock_post('/databases/postgresql/instances/123/patch') as m:
+        with self.mock_post("/databases/postgresql/instances/123/patch") as m:
             db = PostgreSQLDatabase(self.client, 123)
 
             db.patch()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/postgresql/instances/123/patch')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url, "/databases/postgresql/instances/123/patch"
+            )
 
     def test_get_ssl(self):
         """
@@ -370,7 +411,7 @@ def test_get_ssl(self):
 
         ssl = db.ssl
 
-        self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==')
+        self.assertEqual(ssl.ca_certificate, "LS0tLS1CRUdJ...==")
 
     def test_get_credentials(self):
         """
@@ -380,20 +421,25 @@ def test_get_credentials(self):
 
         creds = db.credentials
 
-        self.assertEqual(creds.password, 's3cur3P@ssw0rd')
-        self.assertEqual(creds.username, 'linroot')
+        self.assertEqual(creds.password, "s3cur3P@ssw0rd")
+        self.assertEqual(creds.username, "linroot")
 
     def test_reset_credentials(self):
         """
         Test resetting PostgreSQL credentials
         """
-        with self.mock_post('/databases/postgresql/instances/123/credentials/reset') as m:
+        with self.mock_post(
+            "/databases/postgresql/instances/123/credentials/reset"
+        ) as m:
             db = PostgreSQLDatabase(self.client, 123)
 
             db.credentials_reset()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/postgresql/instances/123/credentials/reset')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url,
+                "/databases/postgresql/instances/123/credentials/reset",
+            )
 
 
 class MongoDBDatabaseTest(ClientBaseCase):
@@ -408,67 +454,69 @@ def test_get_instances(self):
         dbs = self.client.database.mongodb_instances()
 
         self.assertEqual(len(dbs), 1)
-        self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24')
+        self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24")
         self.assertEqual(dbs[0].cluster_size, 3)
-        self.assertEqual(dbs[0].compression_type, 'none')
+        self.assertEqual(dbs[0].compression_type, "none")
         self.assertEqual(dbs[0].encrypted, False)
-        self.assertEqual(dbs[0].engine, 'mongodb')
-        self.assertEqual(dbs[0].hosts.primary, 'lin-0000-0000.servers.linodedb.net')
+        self.assertEqual(dbs[0].engine, "mongodb")
+        self.assertEqual(
+            dbs[0].hosts.primary, "lin-0000-0000.servers.linodedb.net"
+        )
         self.assertEqual(dbs[0].hosts.secondary, None)
         self.assertEqual(len(dbs[0].peers), 3)
         self.assertEqual(dbs[0].id, 123)
-        self.assertEqual(dbs[0].region, 'us-east')
+        self.assertEqual(dbs[0].region, "us-east")
         self.assertEqual(dbs[0].updates.duration, 3)
-        self.assertEqual(dbs[0].version, '4.4.10')
+        self.assertEqual(dbs[0].version, "4.4.10")
 
     def test_create(self):
         """
         Test that MongoDB databases can be created
         """
 
-        with self.mock_post('/databases/mongodb/instances') as m:
+        with self.mock_post("/databases/mongodb/instances") as m:
             # We don't care about errors here; we just want to
             # validate the request.
             try:
                 self.client.database.mongodb_create(
-                    'cool',
-                    'us-southeast',
-                    'mongodb/4.4.10',
-                    'g6-standard-1',
-                    cluster_size=3
+                    "cool",
+                    "us-southeast",
+                    "mongodb/4.4.10",
+                    "g6-standard-1",
+                    cluster_size=3,
                 )
             except Exception:
                 pass
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mongodb/instances')
-            self.assertEqual(m.call_data['label'], 'cool')
-            self.assertEqual(m.call_data['region'], 'us-southeast')
-            self.assertEqual(m.call_data['engine'], 'mongodb/4.4.10')
-            self.assertEqual(m.call_data['type'], 'g6-standard-1')
-            self.assertEqual(m.call_data['cluster_size'], 3)
+            self.assertEqual(m.method, "post")
+            self.assertEqual(m.call_url, "/databases/mongodb/instances")
+            self.assertEqual(m.call_data["label"], "cool")
+            self.assertEqual(m.call_data["region"], "us-southeast")
+            self.assertEqual(m.call_data["engine"], "mongodb/4.4.10")
+            self.assertEqual(m.call_data["type"], "g6-standard-1")
+            self.assertEqual(m.call_data["cluster_size"], 3)
 
     def test_update(self):
         """
         Test that the MongoDB database can be updated
         """
 
-        with self.mock_put('/databases/mongodb/instances/123') as m:
-            new_allow_list = ['192.168.0.1/32']
+        with self.mock_put("/databases/mongodb/instances/123") as m:
+            new_allow_list = ["192.168.0.1/32"]
 
             db = MongoDBDatabase(self.client, 123)
 
             db.updates.day_of_week = 2
             db.allow_list = new_allow_list
-            db.label = 'cool'
+            db.label = "cool"
 
             db.save()
 
-            self.assertEqual(m.method, 'put')
-            self.assertEqual(m.call_url, '/databases/mongodb/instances/123')
-            self.assertEqual(m.call_data['label'], 'cool')
-            self.assertEqual(m.call_data['updates']['day_of_week'], 2)
-            self.assertEqual(m.call_data['allow_list'], new_allow_list)
+            self.assertEqual(m.method, "put")
+            self.assertEqual(m.call_url, "/databases/mongodb/instances/123")
+            self.assertEqual(m.call_data["label"], "cool")
+            self.assertEqual(m.call_data["updates"]["day_of_week"], 2)
+            self.assertEqual(m.call_data["allow_list"], new_allow_list)
 
     def test_list_backups(self):
         """
@@ -481,53 +529,64 @@ def test_list_backups(self):
         self.assertEqual(len(backups), 1)
 
         self.assertEqual(backups[0].id, 456)
-        self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI')
-        self.assertEqual(backups[0].type, 'auto')
+        self.assertEqual(
+            backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI"
+        )
+        self.assertEqual(backups[0].type, "auto")
 
     def test_create_backup(self):
         """
         Test that MongoDB database backups can be created
         """
 
-        with self.mock_post('/databases/mongodb/instances/123/backups') as m:
+        with self.mock_post("/databases/mongodb/instances/123/backups") as m:
             db = MongoDBDatabase(self.client, 123)
 
             # We don't care about errors here; we just want to
             # validate the request.
             try:
-                db.backup_create('mybackup', target='secondary')
+                db.backup_create("mybackup", target="secondary")
             except Exception:
                 pass
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mongodb/instances/123/backups')
-            self.assertEqual(m.call_data['label'], 'mybackup')
-            self.assertEqual(m.call_data['target'], 'secondary')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url, "/databases/mongodb/instances/123/backups"
+            )
+            self.assertEqual(m.call_data["label"], "mybackup")
+            self.assertEqual(m.call_data["target"], "secondary")
 
     def test_backup_restore(self):
         """
         Test that MongoDB database backups can be restored
         """
 
-        with self.mock_post('/databases/mongodb/instances/123/backups/456/restore') as m:
+        with self.mock_post(
+            "/databases/mongodb/instances/123/backups/456/restore"
+        ) as m:
             db = MongoDBDatabase(self.client, 123)
 
             db.backups[0].restore()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mongodb/instances/123/backups/456/restore')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url,
+                "/databases/mongodb/instances/123/backups/456/restore",
+            )
 
     def test_patch(self):
         """
         Test MongoDB Database patching logic.
         """
-        with self.mock_post('/databases/mongodb/instances/123/patch') as m:
+        with self.mock_post("/databases/mongodb/instances/123/patch") as m:
             db = MongoDBDatabase(self.client, 123)
 
             db.patch()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mongodb/instances/123/patch')
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url, "/databases/mongodb/instances/123/patch"
+            )
 
     def test_get_ssl(self):
         """
@@ -537,7 +596,7 @@ def test_get_ssl(self):
 
         ssl = db.ssl
 
-        self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==')
+        self.assertEqual(ssl.ca_certificate, "LS0tLS1CRUdJ...==")
 
     def test_get_credentials(self):
         """
@@ -547,17 +606,21 @@ def test_get_credentials(self):
 
         creds = db.credentials
 
-        self.assertEqual(creds.password, 's3cur3P@ssw0rd')
-        self.assertEqual(creds.username, 'linroot')
+        self.assertEqual(creds.password, "s3cur3P@ssw0rd")
+        self.assertEqual(creds.username, "linroot")
 
     def test_reset_credentials(self):
         """
         Test resetting MongoDB credentials
         """
-        with self.mock_post('/databases/mongodb/instances/123/credentials/reset') as m:
+        with self.mock_post(
+            "/databases/mongodb/instances/123/credentials/reset"
+        ) as m:
             db = MongoDBDatabase(self.client, 123)
 
             db.credentials_reset()
 
-            self.assertEqual(m.method, 'post')
-            self.assertEqual(m.call_url, '/databases/mongodb/instances/123/credentials/reset')
\ No newline at end of file
+            self.assertEqual(m.method, "post")
+            self.assertEqual(
+                m.call_url, "/databases/mongodb/instances/123/credentials/reset"
+            )
diff --git a/test/objects/domain_test.py b/test/objects/domain_test.py
new file mode 100644
index 000000000..003058af8
--- /dev/null
+++ b/test/objects/domain_test.py
@@ -0,0 +1,59 @@
+from test.base import ClientBaseCase
+
+from linode_api4.objects import Domain, DomainRecord
+
+
+class DomainGeneralTest(ClientBaseCase):
+    """
+    Tests methods of the Domain class.
+    """
+
+    def test_domain_get(self):
+        domain_record = DomainRecord(self.client, 123456, 12345)
+
+        self.assertEqual(domain_record.id, 123456)
+
+    def test_save_null_values_excluded(self):
+        with self.mock_put("domains/12345") as m:
+            domain = self.client.load(Domain, 12345)
+
+            domain.type = "slave"
+            domain.master_ips = ["127.0.0.1"]
+            domain.save()
+
+            self.assertTrue("group" not in m.call_data.keys())
+
+    def test_zone_file_view(self):
+        domain = Domain(self.client, 12345)
+
+        with self.mock_get("/domains/12345/zone-file") as m:
+            result = domain.zone_file_view()
+            self.assertEqual(m.call_url, "/domains/12345/zone-file")
+            self.assertIsNotNone(result)
+
+    def test_clone(self):
+        domain = Domain(self.client, 12345)
+
+        with self.mock_post("/domains/12345/clone") as m:
+            domain.clone("example.org")
+            self.assertEqual(m.call_url, "/domains/12345/clone")
+            self.assertEqual(m.call_data["domain"], "example.org")
+
+    def test_import(self):
+        domain = Domain(self.client, 12345)
+
+        with self.mock_post("/domains/import") as m:
+            domain.domain_import("example.org", "examplenameserver.com")
+            self.assertEqual(m.call_url, "/domains/import")
+            self.assertEqual(m.call_data["domain"], "example.org")
+            self.assertEqual(
+                m.call_data["remote_nameserver"], "examplenameserver.com"
+            )
+
+        with self.mock_post("/domains/import") as m:
+            domain.domain_import(domain, "examplenameserver.com")
+            self.assertEqual(m.call_url, "/domains/import")
+            self.assertEqual(m.call_data["domain"], "example.org")
+            self.assertEqual(
+                m.call_data["remote_nameserver"], "examplenameserver.com"
+            )
diff --git a/test/objects/firewall_test.py b/test/objects/firewall_test.py
index 0a0f27448..3b8cc280b 100644
--- a/test/objects/firewall_test.py
+++ b/test/objects/firewall_test.py
@@ -2,10 +2,12 @@
 
 from linode_api4.objects import Firewall, FirewallDevice
 
+
 class FirewallTest(ClientBaseCase):
     """
     Tests methods of the Firewall class
     """
+
     def test_get_rules(self):
         """
         Test that the rules can be retrieved from a Firewall
@@ -14,9 +16,9 @@ def test_get_rules(self):
         rules = firewall.rules
 
         self.assertEqual(len(rules.inbound), 0)
-        self.assertEqual(rules.inbound_policy, 'DROP')
+        self.assertEqual(rules.inbound_policy, "DROP")
         self.assertEqual(len(rules.outbound), 0)
-        self.assertEqual(rules.outbound_policy, 'DROP')
+        self.assertEqual(rules.outbound_policy, "DROP")
 
     def test_update_rules(self):
         """
@@ -25,34 +27,30 @@ def test_update_rules(self):
 
         firewall = Firewall(self.client, 123)
 
-        with self.mock_put('networking/firewalls/123/rules') as m:
+        with self.mock_put("networking/firewalls/123/rules") as m:
             new_rules = {
-                'inbound': [
+                "inbound": [
                     {
-                        'action': 'ACCEPT',
-                        'addresses': {
-                            'ipv4': [
-                                '0.0.0.0/0'
-                            ],
-                            'ipv6': [
-                                "ff00::/8"
-                            ]
+                        "action": "ACCEPT",
+                        "addresses": {
+                            "ipv4": ["0.0.0.0/0"],
+                            "ipv6": ["ff00::/8"],
                         },
-                        'description': 'A really cool firewall rule.',
-                        'label': 'really-cool-firewall-rule',
-                        'ports': '80',
-                        'protocol': 'TCP'
+                        "description": "A really cool firewall rule.",
+                        "label": "really-cool-firewall-rule",
+                        "ports": "80",
+                        "protocol": "TCP",
                     }
                 ],
-                'inbound_policy': 'ALLOW',
-                'outbound': [],
-                'outbound_policy': 'ALLOW'
+                "inbound_policy": "ALLOW",
+                "outbound": [],
+                "outbound_policy": "ALLOW",
             }
 
             firewall.update_rules(new_rules)
 
-            self.assertEqual(m.method, 'put')
-            self.assertEqual(m.call_url, '/networking/firewalls/123/rules')
+            self.assertEqual(m.method, "put")
+            self.assertEqual(m.call_url, "/networking/firewalls/123/rules")
 
             self.assertEqual(m.call_data, new_rules)
 
@@ -61,6 +59,7 @@ class FirewallDevicesTest(ClientBaseCase):
     """
     Tests methods of Firewall devices
     """
+
     def test_get_devices(self):
         """
         Tests that devices can be pulled from a firewall
@@ -77,8 +76,8 @@ def test_get_device(self):
 
         self.assertEqual(device.id, 123)
         self.assertEqual(device.entity.id, 123)
-        self.assertEqual(device.entity.label, 'my-linode')
-        self.assertEqual(device.entity.type, 'linode')
-        self.assertEqual(device.entity.url, '/v4/linode/instances/123')
+        self.assertEqual(device.entity.label, "my-linode")
+        self.assertEqual(device.entity.type, "linode")
+        self.assertEqual(device.entity.url, "/v4/linode/instances/123")
 
         self.assertEqual(device._populated, True)
diff --git a/test/objects/image_test.py b/test/objects/image_test.py
index 6e9710646..02f392473 100644
--- a/test/objects/image_test.py
+++ b/test/objects/image_test.py
@@ -1,13 +1,11 @@
 from datetime import datetime
 from io import BytesIO
-from typing import Any, BinaryIO
-from unittest.mock import patch
-
 from test.base import ClientBaseCase
+from typing import BinaryIO
+from unittest.mock import patch
 
 from linode_api4.objects import Image
 
-
 # A minimal gzipped image that will be accepted by the API
 TEST_IMAGE_CONTENT = (
     b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69"
@@ -19,17 +17,18 @@ class ImageTest(ClientBaseCase):
     """
     Tests methods of the Image class
     """
+
     def test_get_image(self):
         """
         Tests that an image is loaded correctly by ID
         """
-        image = Image(self.client, 'linode/debian9')
+        image = Image(self.client, "linode/debian9")
         self.assertEqual(image._populated, False)
 
-        self.assertEqual(image.label, 'Debian 9')
+        self.assertEqual(image.label, "Debian 9")
         self.assertEqual(image._populated, True)
 
-        self.assertEqual(image.vendor, 'Debian')
+        self.assertEqual(image.vendor, "Debian")
         self.assertEqual(image.description, None)
         self.assertEqual(image.deprecated, False)
         self.assertEqual(image.status, "available")
@@ -37,17 +36,20 @@ def test_get_image(self):
         self.assertEqual(image.created_by, "linode")
         self.assertEqual(image.size, 1100)
 
-        self.assertEqual(image.eol, datetime(
-            year=2026, month=7, day=1, hour=4, minute=0, second=0
-        ))
+        self.assertEqual(
+            image.eol,
+            datetime(year=2026, month=7, day=1, hour=4, minute=0, second=0),
+        )
 
-        self.assertEqual(image.expiry, datetime(
-            year=2026, month=8, day=1, hour=4, minute=0, second=0
-        ))
+        self.assertEqual(
+            image.expiry,
+            datetime(year=2026, month=8, day=1, hour=4, minute=0, second=0),
+        )
 
-        self.assertEqual(image.updated, datetime(
-            year=2020, month=7, day=1, hour=4, minute=0, second=0
-        ))
+        self.assertEqual(
+            image.updated,
+            datetime(year=2020, month=7, day=1, hour=4, minute=0, second=0),
+        )
 
     def test_image_create_upload(self):
         """
@@ -68,8 +70,8 @@ def test_image_create_upload(self):
                 {
                     "label": "Realest Image Upload",
                     "region": "us-southeast",
-                    "description": "very real image upload."
-                }
+                    "description": "very real image upload.",
+                },
             )
 
         self.assertEqual(image.id, "private/1337")
@@ -97,4 +99,4 @@ def put_mock(url: str, data: BinaryIO = None, **kwargs):
 
         self.assertEqual(image.id, "private/1337")
         self.assertEqual(image.label, "Realest Image Upload")
-        self.assertEqual(image.description, "very real image upload.")
\ No newline at end of file
+        self.assertEqual(image.description, "very real image upload.")
diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py
index 20fdcac2a..de3c5e20a 100644
--- a/test/objects/linode_test.py
+++ b/test/objects/linode_test.py
@@ -1,14 +1,14 @@
 from datetime import datetime
 from test.base import ClientBaseCase
 
-from linode_api4.objects import Config, Disk, Image, Instance, Type
-from linode_api4.objects.base import MappedObject
+from linode_api4.objects import Config, Disk, Image, Instance, StackScript, Type
 
 
 class LinodeTest(ClientBaseCase):
     """
     Tests methods of the Linode class
     """
+
     def test_get_linode(self):
         """
         Tests that a client is loaded correctly by ID
@@ -21,16 +21,20 @@ def test_get_linode(self):
 
         self.assertTrue(isinstance(linode.image, Image))
         self.assertEqual(linode.image.label, "Ubuntu 17.04")
+        self.assertEqual(
+            linode.host_uuid, "3a3ddd59d9a78bb8de041391075df44de62bfec8"
+        )
+        self.assertEqual(linode.watchdog_enabled, True)
 
         json = linode._raw_json
         self.assertIsNotNone(json)
-        self.assertEqual(json['id'], 123)
-        self.assertEqual(json['label'], 'linode123')
-        self.assertEqual(json['group'], 'test')
+        self.assertEqual(json["id"], 123)
+        self.assertEqual(json["label"], "linode123")
+        self.assertEqual(json["group"], "test")
 
         # test that the _raw_json stored on the object is sufficient to populate
         # a new object
-        linode2 = Instance(self.client, json['id'], json=json)
+        linode2 = Instance(self.client, json["id"], json=json)
 
         self.assertTrue(linode2._populated)
         self.assertEqual(linode2.id, linode.id)
@@ -56,18 +60,21 @@ def test_rebuild(self):
         """
         linode = Instance(self.client, 123)
 
-        with self.mock_post('/linode/instances/123') as m:
-            pw = linode.rebuild('linode/debian9')
+        with self.mock_post("/linode/instances/123") as m:
+            pw = linode.rebuild("linode/debian9")
 
             self.assertIsNotNone(pw)
             self.assertTrue(isinstance(pw, str))
 
-            self.assertEqual(m.call_url, '/linode/instances/123/rebuild')
+            self.assertEqual(m.call_url, "/linode/instances/123/rebuild")
 
-            self.assertEqual(m.call_data, {
-                "image": "linode/debian9",
-                "root_pass": pw,
-            })
+            self.assertEqual(
+                m.call_data,
+                {
+                    "image": "linode/debian9",
+                    "root_pass": pw,
+                },
+            )
 
     def test_available_backups(self):
         """
@@ -84,28 +91,35 @@ def test_available_backups(self):
         b = backups.automatic[0]
         self.assertEqual(b.id, 12345)
         self.assertEqual(b._populated, True)
-        self.assertEqual(b.status, 'successful')
-        self.assertEqual(b.type, 'auto')
-        self.assertEqual(b.created, datetime(year=2018, month=1, day=9, hour=0,
-                                             minute=1, second=1))
-        self.assertEqual(b.updated, datetime(year=2018, month=1, day=9, hour=0,
-                                             minute=1, second=1))
-        self.assertEqual(b.finished, datetime(year=2018, month=1, day=9, hour=0,
-                                             minute=1, second=1))
-        self.assertEqual(b.region.id, 'us-east-1a')
+        self.assertEqual(b.status, "successful")
+        self.assertEqual(b.type, "auto")
+        self.assertEqual(
+            b.created,
+            datetime(year=2018, month=1, day=9, hour=0, minute=1, second=1),
+        )
+        self.assertEqual(
+            b.updated,
+            datetime(year=2018, month=1, day=9, hour=0, minute=1, second=1),
+        )
+        self.assertEqual(
+            b.finished,
+            datetime(year=2018, month=1, day=9, hour=0, minute=1, second=1),
+        )
+        self.assertEqual(b.region.id, "us-east-1a")
         self.assertEqual(b.label, None)
         self.assertEqual(b.message, None)
+        self.assertEqual(b.available, True)
 
         self.assertEqual(len(b.disks), 2)
         self.assertEqual(b.disks[0].size, 1024)
-        self.assertEqual(b.disks[0].label, 'Debian 8.1 Disk')
-        self.assertEqual(b.disks[0].filesystem, 'ext4')
+        self.assertEqual(b.disks[0].label, "Debian 8.1 Disk")
+        self.assertEqual(b.disks[0].filesystem, "ext4")
         self.assertEqual(b.disks[1].size, 0)
-        self.assertEqual(b.disks[1].label, '256MB Swap Image')
-        self.assertEqual(b.disks[1].filesystem, 'swap')
+        self.assertEqual(b.disks[1].label, "256MB Swap Image")
+        self.assertEqual(b.disks[1].filesystem, "swap")
 
         self.assertEqual(len(b.configs), 1)
-        self.assertEqual(b.configs[0], 'My Debian 8.1 Profile')
+        self.assertEqual(b.configs[0], "My Debian 8.1 Profile")
 
         # assert that snapshots came back as expected
         self.assertEqual(backups.snapshot.current, None)
@@ -115,26 +129,29 @@ def test_update_linode(self):
         """
         Tests that a Linode can be updated
         """
-        with self.mock_put('linode/instances/123') as m:
+        with self.mock_put("linode/instances/123") as m:
             linode = self.client.load(Instance, 123)
 
             linode.label = "NewLinodeLabel"
             linode.group = "new_group"
             linode.save()
 
-            self.assertEqual(m.call_url, '/linode/instances/123')
-            self.assertEqual(m.call_data, {
-                "alerts": {
-                    "cpu": 90,
-                    "io": 5000,
-                    "network_in": 5,
-                    "network_out": 5,
-                    "transfer_quota": 80
+            self.assertEqual(m.call_url, "/linode/instances/123")
+            self.assertEqual(
+                m.call_data,
+                {
+                    "alerts": {
+                        "cpu": 90,
+                        "io": 5000,
+                        "network_in": 5,
+                        "network_out": 5,
+                        "transfer_quota": 80,
+                    },
+                    "label": "NewLinodeLabel",
+                    "group": "new_group",
+                    "tags": ["something"],
                 },
-                "label": "NewLinodeLabel",
-                "group": "new_group",
-                "tags": ["something"],
-            })
+            )
 
     def test_delete_linode(self):
         """
@@ -144,7 +161,7 @@ def test_delete_linode(self):
             linode = Instance(self.client, 123)
             linode.delete()
 
-            self.assertEqual(m.call_url, '/linode/instances/123')
+            self.assertEqual(m.call_url, "/linode/instances/123")
 
     def test_reboot(self):
         """
@@ -155,7 +172,7 @@ def test_reboot(self):
 
         with self.mock_post(result) as m:
             linode.reboot()
-            self.assertEqual(m.call_url, '/linode/instances/123/reboot')
+            self.assertEqual(m.call_url, "/linode/instances/123/reboot")
 
     def test_shutdown(self):
         """
@@ -166,7 +183,7 @@ def test_shutdown(self):
 
         with self.mock_post(result) as m:
             linode.shutdown()
-            self.assertEqual(m.call_url, '/linode/instances/123/shutdown')
+            self.assertEqual(m.call_url, "/linode/instances/123/shutdown")
 
     def test_boot(self):
         """
@@ -177,7 +194,7 @@ def test_boot(self):
 
         with self.mock_post(result) as m:
             linode.boot()
-            self.assertEqual(m.call_url, '/linode/instances/123/boot')
+            self.assertEqual(m.call_url, "/linode/instances/123/boot")
 
     def test_resize(self):
         """
@@ -187,22 +204,22 @@ def test_resize(self):
         result = {}
 
         with self.mock_post(result) as m:
-            linode.resize(new_type='g6-standard-1')
-            self.assertEqual(m.call_url, '/linode/instances/123/resize')
-            self.assertEqual(m.call_data, {'type': 'g6-standard-1'})
+            linode.resize(new_type="g6-standard-1")
+            self.assertEqual(m.call_url, "/linode/instances/123/resize")
+            self.assertEqual(m.call_data["type"], "g6-standard-1")
 
     def test_resize_with_class(self):
         """
         Tests that you can submit a correct resize api request with a Base class type
         """
         linode = Instance(self.client, 123)
-        ltype = Type(self.client, 'g6-standard-2')
+        ltype = Type(self.client, "g6-standard-2")
         result = {}
 
         with self.mock_post(result) as m:
             linode.resize(new_type=ltype)
-            self.assertEqual(m.call_url, '/linode/instances/123/resize')
-            self.assertEqual(m.call_data, {'type': 'g6-standard-2'})
+            self.assertEqual(m.call_url, "/linode/instances/123/resize")
+            self.assertEqual(m.call_data["type"], "g6-standard-2")
 
     def test_boot_with_config(self):
         """
@@ -214,7 +231,7 @@ def test_boot_with_config(self):
 
         with self.mock_post(result) as m:
             linode.boot(config=config)
-            self.assertEqual(m.call_url, '/linode/instances/123/boot')
+            self.assertEqual(m.call_url, "/linode/instances/123/boot")
 
     def test_mutate(self):
         """
@@ -225,7 +242,105 @@ def test_mutate(self):
 
         with self.mock_post(result) as m:
             linode.mutate()
-            self.assertEqual(m.call_url, '/linode/instances/123/mutate')
+            self.assertEqual(m.call_url, "/linode/instances/123/mutate")
+            self.assertEqual(m.call_data["allow_auto_disk_resize"], True)
+
+    def test_firewalls(self):
+        """
+        Tests that you can submit a correct firewalls api request
+        """
+        linode = Instance(self.client, 123)
+
+        with self.mock_get("/linode/instances/123/firewalls") as m:
+            result = linode.firewalls()
+            self.assertEqual(m.call_url, "/linode/instances/123/firewalls")
+            self.assertEquals(len(result), 1)
+
+    def test_volumes(self):
+        """
+        Tests that you can submit a correct volumes api request
+        """
+        linode = Instance(self.client, 123)
+
+        with self.mock_get("/linode/instances/123/volumes") as m:
+            result = linode.volumes()
+            self.assertEqual(m.call_url, "/linode/instances/123/volumes")
+            self.assertEquals(len(result), 1)
+
+    def test_nodebalancers(self):
+        """
+        Tests that you can submit a correct nodebalancers api request
+        """
+        linode = Instance(self.client, 123)
+
+        with self.mock_get("/linode/instances/123/nodebalancers") as m:
+            result = linode.nodebalancers()
+            self.assertEqual(m.call_url, "/linode/instances/123/nodebalancers")
+            self.assertEquals(len(result), 1)
+
+    def test_transfer_year_month(self):
+        """
+        Tests that you can submit a correct transfer api request
+        """
+        linode = Instance(self.client, 123)
+
+        with self.mock_get("/linode/instances/123/transfer/2023/4") as m:
+            linode.transfer_year_month(2023, 4)
+            self.assertEqual(
+                m.call_url, "/linode/instances/123/transfer/2023/4"
+            )
+
+    def test_duplicate(self):
+        """
+        Tests that you can submit a correct disk clone api request
+        """
+        disk = Disk(self.client, 12345, 123)
+
+        with self.mock_post("/linode/instances/123/disks/12345/clone") as m:
+            disk.duplicate()
+            self.assertEqual(
+                m.call_url, "/linode/instances/123/disks/12345/clone"
+            )
+
+    def test_disk_password(self):
+        """
+        Tests that you can submit a correct disk password reset api request
+        """
+        disk = Disk(self.client, 12345, 123)
+
+        with self.mock_post({}) as m:
+            disk.reset_root_password()
+            self.assertEqual(
+                m.call_url, "/linode/instances/123/disks/12345/password"
+            )
+
+    def test_instance_password(self):
+        """
+        Tests that you can submit a correct instance password reset api request
+        """
+        instance = Instance(self.client, 123)
+
+        with self.mock_post({}) as m:
+            instance.reset_instance_root_password()
+            self.assertEqual(m.call_url, "/linode/instances/123/password")
+
+    def test_ips(self):
+        """
+        Tests that you can submit a correct ips api request
+        """
+        linode = Instance(self.client, 123)
+
+        ips = linode.ips
+
+        self.assertIsNotNone(ips.ipv4)
+        self.assertIsNotNone(ips.ipv6)
+        self.assertIsNotNone(ips.ipv4.public)
+        self.assertIsNotNone(ips.ipv4.private)
+        self.assertIsNotNone(ips.ipv4.shared)
+        self.assertIsNotNone(ips.ipv4.reserved)
+        self.assertIsNotNone(ips.ipv6.slaac)
+        self.assertIsNotNone(ips.ipv6.link_local)
+        self.assertIsNotNone(ips.ipv6.pools)
 
     def test_initiate_migration(self):
         """
@@ -236,7 +351,7 @@ def test_initiate_migration(self):
 
         with self.mock_post(result) as m:
             linode.initiate_migration()
-            self.assertEqual(m.call_url, '/linode/instances/123/migrate')
+            self.assertEqual(m.call_url, "/linode/instances/123/migrate")
 
     def test_create_disk(self):
         """
@@ -245,17 +360,25 @@ def test_create_disk(self):
         linode = Instance(self.client, 123)
 
         with self.mock_post("/linode/instances/123/disks/12345") as m:
-            disk, gen_pass = linode.disk_create(1234, label="test", authorized_users=["test"], image="linode/debian10")
+            disk, gen_pass = linode.disk_create(
+                1234,
+                label="test",
+                authorized_users=["test"],
+                image="linode/debian10",
+            )
             self.assertEqual(m.call_url, "/linode/instances/123/disks")
             print(m.call_data)
-            self.assertEqual(m.call_data, {
-                "size": 1234,
-                "label": "test",
-                "root_pass": gen_pass,
-                "image": "linode/debian10",
-                "authorized_users": ["test"],
-                "read_only": False,
-            })
+            self.assertEqual(
+                m.call_data,
+                {
+                    "size": 1234,
+                    "label": "test",
+                    "root_pass": gen_pass,
+                    "image": "linode/debian10",
+                    "authorized_users": ["test"],
+                    "read_only": False,
+                },
+            )
 
         assert disk.id == 12345
 
@@ -264,6 +387,7 @@ class DiskTest(ClientBaseCase):
     """
     Tests for the Disk object
     """
+
     def test_resize(self):
         """
         Tests that a resize is submitted correctly
@@ -275,7 +399,9 @@ def test_resize(self):
 
             self.assertTrue(r)
 
-            self.assertEqual(m.call_url, '/linode/instances/123/disks/12345/resize')
+            self.assertEqual(
+                m.call_url, "/linode/instances/123/disks/12345/resize"
+            )
             self.assertEqual(m.call_data, {"size": 1000})
 
 
@@ -289,26 +415,62 @@ def test_update_interfaces(self):
         Tests that a configs interfaces update correctly
         """
 
-        json = self.client.get('/linode/instances/123/configs/456789')
+        json = self.client.get("/linode/instances/123/configs/456789")
         config = Config(self.client, 456789, 123, json=json)
 
-        with self.mock_put('/linode/instances/123/configs/456789') as m:
+        with self.mock_put("/linode/instances/123/configs/456789") as m:
             new_interfaces = [
-                {
-                    'purpose': 'public'
-                },
-                {
-                    'purpose': 'vlan',
-                    'label': 'cool-vlan'
-                }
+                {"purpose": "public"},
+                {"purpose": "vlan", "label": "cool-vlan"},
             ]
 
             config.interfaces = new_interfaces
 
             config.save()
 
-            self.assertEqual(m.call_url, '/linode/instances/123/configs/456789')
-            self.assertEqual(m.call_data.get('interfaces'), new_interfaces)
+            self.assertEqual(m.call_url, "/linode/instances/123/configs/456789")
+            self.assertEqual(m.call_data.get("interfaces"), new_interfaces)
+
+    def test_get_config(self):
+        json = self.client.get("/linode/instances/123/configs/456789")
+        config = Config(self.client, 456789, 123, json=json)
+
+        self.assertEqual(config.root_device, "/dev/sda")
+        self.assertEqual(config.comments, "")
+        self.assertIsNotNone(config.helpers)
+        self.assertEqual(config.label, "My Ubuntu 17.04 LTS Profile")
+        self.assertEqual(
+            config.created,
+            datetime(year=2014, month=10, day=7, hour=20, minute=4, second=0),
+        )
+        self.assertEqual(config.memory_limit, 0)
+        self.assertEqual(config.id, 456789)
+        self.assertIsNotNone(config.interfaces)
+        self.assertEqual(config.run_level, "default")
+        self.assertIsNone(config.initrd)
+        self.assertEqual(config.virt_mode, "paravirt")
+        self.assertIsNotNone(config.devices)
+
+
+class StackScriptTest(ClientBaseCase):
+    """
+    Tests the methods of the StackScript class.
+    """
+
+    def test_get_stackscript(self):
+        """
+        Tests that a stackscript is loaded correctly by ID
+        """
+        stackscript = StackScript(self.client, 10079)
+
+        self.assertEqual(stackscript.id, 10079)
+        self.assertEqual(stackscript.deployments_active, 1)
+        self.assertEqual(stackscript.deployments_total, 12)
+        self.assertEqual(stackscript.rev_note, "Set up MySQL")
+        self.assertTrue(stackscript.mine)
+        self.assertTrue(stackscript.is_public)
+        self.assertIsNotNone(stackscript.user_defined_fields)
+        self.assertIsNotNone(stackscript.images)
 
 
 class TypeTest(ClientBaseCase):
@@ -332,14 +494,14 @@ def test_get_type_by_id(self):
         """
         Tests that a Linode type is loaded correctly by ID
         """
-        t = Type(self.client, 'g5-nanode-1')
+        t = Type(self.client, "g5-nanode-1")
         self.assertEqual(t._populated, False)
 
         self.assertEqual(t.vcpus, 1)
         self.assertEqual(t.gpus, 0)
         self.assertEqual(t.label, "Linode 1024")
         self.assertEqual(t.disk, 20480)
-        self.assertEqual(t.type_class, 'nanode')
+        self.assertEqual(t.type_class, "nanode")
 
     def test_get_type_gpu(self):
         """
@@ -350,3 +512,46 @@ def test_get_type_gpu(self):
 
         self.assertEqual(t.gpus, 1)
         self.assertEqual(t._populated, True)
+
+    def test_save_noforce(self):
+        """
+        Tests that a client will only save if changes are detected
+        """
+        linode = Instance(self.client, 123)
+        self.assertEqual(linode._populated, False)
+
+        self.assertEqual(linode.label, "linode123")
+        self.assertEqual(linode.group, "test")
+
+        assert not linode._changed
+
+        with self.mock_put("linode/instances") as m:
+            linode.save(force=False)
+            assert not m.called
+
+        linode.label = "blah"
+        assert linode._changed
+
+        with self.mock_put("linode/instances") as m:
+            linode.save(force=False)
+            assert m.called
+            assert m.call_url == "/linode/instances/123"
+            assert m.call_data["label"] == "blah"
+
+        assert not linode._changed
+
+    def test_save_force(self):
+        """
+        Tests that a client will forcibly save by default
+        """
+        linode = Instance(self.client, 123)
+        self.assertEqual(linode._populated, False)
+
+        self.assertEqual(linode.label, "linode123")
+        self.assertEqual(linode.group, "test")
+
+        assert not linode._changed
+
+        with self.mock_put("linode/instances") as m:
+            linode.save()
+            assert m.called
diff --git a/test/objects/lke_test.py b/test/objects/lke_test.py
new file mode 100644
index 000000000..c42a3de2a
--- /dev/null
+++ b/test/objects/lke_test.py
@@ -0,0 +1,134 @@
+from datetime import datetime
+from test.base import ClientBaseCase
+
+from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode
+
+
+class LKETest(ClientBaseCase):
+    """
+    Tests methods of the LKE class
+    """
+
+    def test_get_cluster(self):
+        """
+        Tests that the LKECluster object is properly generated.
+        """
+
+        cluster = LKECluster(self.client, 18881)
+
+        self.assertEqual(cluster.id, 18881)
+        self.assertEqual(
+            cluster.created,
+            datetime(year=2021, month=2, day=10, hour=23, minute=54, second=21),
+        )
+        self.assertEqual(
+            cluster.updated,
+            datetime(year=2021, month=2, day=10, hour=23, minute=54, second=21),
+        )
+        self.assertEqual(cluster.label, "example-cluster")
+        self.assertEqual(cluster.tags, [])
+        self.assertEqual(cluster.region.id, "ap-west")
+        self.assertEqual(cluster.k8s_version.id, "1.19")
+        self.assertTrue(cluster.control_plane.high_availability)
+
+    def test_get_pool(self):
+        """
+        Tests that the LKENodePool object is properly generated.
+        """
+
+        pool = LKENodePool(self.client, 456, 18881)
+
+        self.assertEqual(pool.id, 456)
+        self.assertEqual(pool.cluster_id, 18881)
+        self.assertEqual(pool.type.id, "g6-standard-4")
+        self.assertIsNotNone(pool.disks)
+        self.assertIsNotNone(pool.nodes)
+        self.assertIsNotNone(pool.autoscaler)
+        self.assertIsNotNone(pool.tags)
+
+    def test_cluster_dashboard_url_view(self):
+        """
+        Tests that you can submit a correct cluster dashboard url api request.
+        """
+        cluster = LKECluster(self.client, 18881)
+
+        with self.mock_get("/lke/clusters/18881/dashboard") as m:
+            result = cluster.cluster_dashboard_url_view()
+            self.assertEqual(m.call_url, "/lke/clusters/18881/dashboard")
+            self.assertEqual(result, "https://example.dashboard.linodelke.net")
+
+    def test_kubeconfig_delete(self):
+        """
+        Tests that you can submit a correct kubeconfig delete api request.
+        """
+        cluster = LKECluster(self.client, 18881)
+
+        with self.mock_delete() as m:
+            cluster.kubeconfig_delete()
+            self.assertEqual(m.call_url, "/lke/clusters/18881/kubeconfig")
+
+    def test_node_view(self):
+        """
+        Tests that you can submit a correct node view api request.
+        """
+        cluster = LKECluster(self.client, 18881)
+
+        with self.mock_get("/lke/clusters/18881/nodes/123456") as m:
+            node = cluster.node_view(123456)
+            self.assertEqual(m.call_url, "/lke/clusters/18881/nodes/123456")
+            self.assertIsNotNone(node)
+            self.assertEqual(node.id, "123456")
+            self.assertEqual(node.instance_id, 123458)
+            self.assertEqual(node.status, "ready")
+
+    def test_node_delete(self):
+        """
+        Tests that you can submit a correct node delete api request.
+        """
+        cluster = LKECluster(self.client, 18881)
+
+        with self.mock_delete() as m:
+            cluster.node_delete(1234)
+            self.assertEqual(m.call_url, "/lke/clusters/18881/nodes/1234")
+
+    def test_node_recycle(self):
+        """
+        Tests that you can submit a correct node recycle api request.
+        """
+        cluster = LKECluster(self.client, 18881)
+
+        with self.mock_post({}) as m:
+            cluster.node_recycle(1234)
+            self.assertEqual(
+                m.call_url, "/lke/clusters/18881/nodes/1234/recycle"
+            )
+
+    def test_cluster_nodes_recycle(self):
+        """
+        Tests that you can submit a correct cluster nodes recycle api request.
+        """
+        cluster = LKECluster(self.client, 18881)
+
+        with self.mock_post({}) as m:
+            cluster.cluster_nodes_recycle()
+            self.assertEqual(m.call_url, "/lke/clusters/18881/recycle")
+
+    def test_cluster_regenerate(self):
+        """
+        Tests that you can submit a correct cluster regenerate api request.
+        """
+        cluster = LKECluster(self.client, 18881)
+
+        with self.mock_post({}) as m:
+            cluster.cluster_regenerate()
+            self.assertEqual(m.call_url, "/lke/clusters/18881/regenerate")
+
+    def test_service_token_delete(self):
+        """
+        Tests that you can submit a correct service token delete api request.
+        """
+        cluster = LKECluster(self.client, 18881)
+
+        with self.mock_delete() as m:
+            cluster.service_token_delete()
+            self.assertEqual(m.call_url, "/lke/clusters/18881/servicetoken")
diff --git a/test/objects/longview_test.py b/test/objects/longview_test.py
index e6134f1b5..c5002eba8 100644
--- a/test/objects/longview_test.py
+++ b/test/objects/longview_test.py
@@ -1,14 +1,36 @@
 from datetime import datetime
 from test.base import ClientBaseCase
 
-from linode_api4.objects import LongviewClient, LongviewSubscription
+from linode_api4.objects import (
+    LongviewClient,
+    LongviewPlan,
+    LongviewSubscription,
+)
 from linode_api4.objects.base import MappedObject
 
 
+class LongviewPlanTest(ClientBaseCase):
+    """
+    Tests methods of the LongviewPlan class
+    """
+
+    def test_get_plan(self):
+        """
+        Tests that a plan is loaded correctly
+        """
+        plan = LongviewPlan(self.client, "longview-10")
+
+        self.assertEqual(plan.id, "longview-10")
+        self.assertEqual(plan.clients_included, 10)
+        self.assertEqual(plan.label, "Longview Pro 10 pack")
+        self.assertIsNotNone(plan.price)
+
+
 class LongviewClientTest(ClientBaseCase):
     """
     Tests methods of the LongviewClient class
     """
+
     def test_get_client(self):
         """
         Tests that a client is loaded correctly by ID
@@ -16,7 +38,7 @@ def test_get_client(self):
         client = LongviewClient(self.client, 1234)
         self.assertEqual(client._populated, False)
 
-        self.assertEqual(client.label, 'test_client_1')
+        self.assertEqual(client.label, "test_client_1")
         self.assertEqual(client._populated, True)
 
         self.assertIsInstance(client.created, datetime)
@@ -27,22 +49,22 @@ def test_get_client(self):
         self.assertFalse(client.apps.mysql)
         self.assertFalse(client.apps.apache)
 
-        self.assertEqual(client.install_code, '12345678-ABCD-EF01-23456789ABCDEF12')
-        self.assertEqual(client.api_key, '12345678-ABCD-EF01-23456789ABCDEF12')
+        self.assertEqual(
+            client.install_code, "12345678-ABCD-EF01-23456789ABCDEF12"
+        )
+        self.assertEqual(client.api_key, "12345678-ABCD-EF01-23456789ABCDEF12")
 
     def test_update_label(self):
         """
         Tests that updating a client's label contacts the api correctly.
         """
-        with self.mock_put('longview/clients/1234') as m:
+        with self.mock_put("longview/clients/1234") as m:
             client = LongviewClient(self.client, 1234)
             client.label = "updated"
             client.save()
 
-            self.assertEqual(m.call_url, '/longview/clients/1234')
-            self.assertEqual(m.call_data, {
-                "label": "updated"
-            })
+            self.assertEqual(m.call_url, "/longview/clients/1234")
+            self.assertEqual(m.call_data, {"label": "updated"})
 
     def test_delete_client(self):
         """
@@ -52,13 +74,14 @@ def test_delete_client(self):
             client = LongviewClient(self.client, 1234)
             client.delete()
 
-            self.assertEqual(m.call_url, '/longview/clients/1234')
+            self.assertEqual(m.call_url, "/longview/clients/1234")
 
 
 class LongviewSubscriptionTest(ClientBaseCase):
     """
     Tests methods of the LongviewSubscription class
     """
+
     def test_get_subscription(self):
         """
         Tests that a subscription is loaded correctly by ID
@@ -66,11 +89,11 @@ def test_get_subscription(self):
         sub = LongviewSubscription(self.client, "longview-40")
         self.assertEqual(sub._populated, False)
 
-        self.assertEqual(sub.label, 'Longview Pro 40 pack')
+        self.assertEqual(sub.label, "Longview Pro 40 pack")
         self.assertEqual(sub._populated, True)
 
         self.assertEqual(sub.clients_included, 40)
 
         self.assertIsInstance(sub.price, MappedObject)
-        self.assertEqual(sub.price.hourly, .15)
+        self.assertEqual(sub.price.hourly, 0.15)
         self.assertEqual(sub.price.monthly, 100)
diff --git a/test/objects/networking_test.py b/test/objects/networking_test.py
new file mode 100644
index 000000000..51db14e48
--- /dev/null
+++ b/test/objects/networking_test.py
@@ -0,0 +1,72 @@
+from test.base import ClientBaseCase
+
+from linode_api4 import ExplicitNullValue
+from linode_api4.objects import Firewall, IPAddress, IPv6Pool, IPv6Range
+
+
+class NetworkingTest(ClientBaseCase):
+    """
+    Tests methods of the Networking class
+    """
+
+    def test_get_ipv6_range(self):
+        """
+        Tests that the IPv6Range object is properly generated.
+        """
+
+        ipv6Range = IPv6Range(self.client, "2600:3c01::")
+        ipv6Range._api_get()
+
+        self.assertEqual(ipv6Range.range, "2600:3c01::")
+        self.assertEqual(ipv6Range.prefix, 64)
+        self.assertEqual(ipv6Range.region.id, "us-east")
+
+        ranges = self.client.networking.ipv6_ranges()
+
+        self.assertEqual(ranges[0].range, "2600:3c01::")
+        self.assertEqual(ranges[0].prefix, 64)
+        self.assertEqual(ranges[0].region.id, "us-east")
+        self.assertEqual(
+            ranges[0].route_target, "2600:3c01::ffff:ffff:ffff:ffff"
+        )
+
+    def test_get_rules(self):
+        """
+        Tests that you can submit a correct firewall rules view api request.
+        """
+
+        firewall = Firewall(self.client, 123)
+
+        with self.mock_get("/networking/firewalls/123/rules") as m:
+            result = firewall.get_rules()
+            self.assertEqual(m.call_url, "/networking/firewalls/123/rules")
+            self.assertEqual(result["inbound"], [])
+            self.assertEqual(result["outbound"], [])
+            self.assertEqual(result["inbound_policy"], "DROP")
+            self.assertEqual(result["outbound_policy"], "DROP")
+
+    def test_rdns_reset(self):
+        """
+        Tests that the RDNS of an IP and be reset using an explicit null value.
+        """
+
+        ip = IPAddress(self.client, "127.0.0.1")
+
+        with self.mock_put("/networking/ips/127.0.0.1") as m:
+            ip.rdns = ExplicitNullValue()
+            ip.save()
+
+            self.assertEqual(m.call_url, "/networking/ips/127.0.0.1")
+
+            # We need to assert of call_data_raw because
+            # call_data drops keys with null values
+            self.assertEqual(m.call_data_raw, '{"rdns": null}')
+
+        # Ensure that everything works as expected with a class reference
+        with self.mock_put("/networking/ips/127.0.0.1") as m:
+            ip.rdns = ExplicitNullValue
+            ip.save()
+
+            self.assertEqual(m.call_url, "/networking/ips/127.0.0.1")
+
+            self.assertEqual(m.call_data_raw, '{"rdns": null}')
diff --git a/test/objects/nodebalancers_test.py b/test/objects/nodebalancers_test.py
index e78934a71..4450cdcdc 100644
--- a/test/objects/nodebalancers_test.py
+++ b/test/objects/nodebalancers_test.py
@@ -1,4 +1,3 @@
-from datetime import datetime
 from test.base import ClientBaseCase
 
 from linode_api4.objects import NodeBalancerConfig, NodeBalancerNode
@@ -9,6 +8,7 @@ class NodeBalancerConfigTest(ClientBaseCase):
     """
     Tests methods of the NodeBalancerConfig class
     """
+
     def test_get_config(self):
         """
         Tests that a config is loaded correctly by ID
@@ -44,6 +44,7 @@ class NodeBalancerNodeTest(ClientBaseCase):
     """
     Tests methods of the NodeBalancerNode class
     """
+
     def test_get_node(self):
         """
         Tests that a node is loaded correctly by ID
@@ -66,26 +67,36 @@ def test_create_node(self):
         """
         Tests that a node can be created
         """
-        with self.mock_post('nodebalancers/123456/configs/65432/nodes/54321') as m:
+        with self.mock_post(
+            "nodebalancers/123456/configs/65432/nodes/54321"
+        ) as m:
             config = NodeBalancerConfig(self.client, 65432, 123456)
-            node = config.node_create('node54321', '192.168.210.120',
-                weight=50, mode='accept')
+            node = config.node_create(
+                "node54321", "192.168.210.120", weight=50, mode="accept"
+            )
 
             self.assertIsNotNone(node)
             self.assertEqual(node.id, 54321)
-            self.assertEqual(m.call_url, '/nodebalancers/123456/configs/65432/nodes')
-            self.assertEqual(m.call_data, {
-                "label": "node54321",
-                "address": "192.168.210.120",
-                "weight": 50,
-                "mode": "accept"
-            })
+            self.assertEqual(
+                m.call_url, "/nodebalancers/123456/configs/65432/nodes"
+            )
+            self.assertEqual(
+                m.call_data,
+                {
+                    "label": "node54321",
+                    "address": "192.168.210.120",
+                    "weight": 50,
+                    "mode": "accept",
+                },
+            )
 
     def test_update_node(self):
         """
         Tests that a node can be updated
         """
-        with self.mock_put('nodebalancers/123456/configs/65432/nodes/54321') as m:
+        with self.mock_put(
+            "nodebalancers/123456/configs/65432/nodes/54321"
+        ) as m:
             node = self.client.load(NodeBalancerNode, 54321, (65432, 123456))
             node.label = "ThisNewLabel"
             node.weight = 60
@@ -93,13 +104,18 @@ def test_update_node(self):
             node.address = "192.168.210.121"
             node.save()
 
-            self.assertEqual(m.call_url, '/nodebalancers/123456/configs/65432/nodes/54321')
-            self.assertEqual(m.call_data, {
-                "label": "ThisNewLabel",
-                "address": "192.168.210.121",
-                "mode": "drain",
-                "weight": 60
-            })
+            self.assertEqual(
+                m.call_url, "/nodebalancers/123456/configs/65432/nodes/54321"
+            )
+            self.assertEqual(
+                m.call_data,
+                {
+                    "label": "ThisNewLabel",
+                    "address": "192.168.210.121",
+                    "mode": "drain",
+                    "weight": 60,
+                },
+            )
 
     def test_delete_node(self):
         """
@@ -109,4 +125,6 @@ def test_delete_node(self):
             node = NodeBalancerNode(self.client, 54321, (65432, 123456))
             node.delete()
 
-            self.assertEqual(m.call_url, '/nodebalancers/123456/configs/65432/nodes/54321')
+            self.assertEqual(
+                m.call_url, "/nodebalancers/123456/configs/65432/nodes/54321"
+            )
diff --git a/test/objects/object_storage_test.py b/test/objects/object_storage_test.py
new file mode 100644
index 000000000..a2c9219e2
--- /dev/null
+++ b/test/objects/object_storage_test.py
@@ -0,0 +1,259 @@
+from datetime import datetime
+from test.base import ClientBaseCase
+
+from linode_api4.objects import (
+    ObjectStorageACL,
+    ObjectStorageBucket,
+    ObjectStorageCluster,
+)
+
+
+class ObjectStorageTest(ClientBaseCase):
+    """
+    Test the methods of the ObjectStorage
+    """
+
+    def test_object_storage_bucket_api_get(self):
+        object_storage_bucket_api_get_url = (
+            "/object-storage/buckets/us-east-1/example-bucket"
+        )
+        with self.mock_get(object_storage_bucket_api_get_url) as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            self.assertEqual(object_storage_bucket.cluster, "us-east-1")
+            self.assertEqual(object_storage_bucket.label, "example-bucket")
+            self.assertEqual(
+                object_storage_bucket.created,
+                datetime(
+                    year=2019, month=1, day=1, hour=1, minute=23, second=45
+                ),
+            )
+            self.assertEqual(
+                object_storage_bucket.hostname,
+                "example-bucket.us-east-1.linodeobjects.com",
+            )
+            self.assertEqual(object_storage_bucket.objects, 4)
+            self.assertEqual(object_storage_bucket.size, 188318981)
+            self.assertEqual(m.call_url, object_storage_bucket_api_get_url)
+
+    def test_object_storage_bucket_delete(self):
+        object_storage_bucket_delete_url = (
+            "/object-storage/buckets/us-east-1/example-bucket"
+        )
+        with self.mock_delete() as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            object_storage_bucket.delete()
+            self.assertEqual(m.call_url, object_storage_bucket_delete_url)
+
+    def test_bucket_access_modify(self):
+        """
+        Test that you can modify bucket access settings.
+        """
+        bucket_access_modify_url = (
+            "/object-storage/buckets/us-east-1/example-bucket/access"
+        )
+        with self.mock_post({}) as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            object_storage_bucket.access_modify(ObjectStorageACL.PRIVATE, True)
+            self.assertEqual(
+                m.call_data,
+                {
+                    "acl": "private",
+                    "cors_enabled": True,
+                },
+            )
+            self.assertEqual(m.call_url, bucket_access_modify_url)
+
+    def test_bucket_access_update(self):
+        """
+        Test that you can update bucket access settings.
+        """
+        bucket_access_update_url = (
+            "/object-storage/buckets/us-east-1/example-bucket/access"
+        )
+        with self.mock_put({}) as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            object_storage_bucket.access_update(ObjectStorageACL.PRIVATE, True)
+            self.assertEqual(
+                m.call_data,
+                {
+                    "acl": "private",
+                    "cors_enabled": True,
+                },
+            )
+            self.assertEqual(m.call_url, bucket_access_update_url)
+
+    def test_buckets_in_cluster(self):
+        """
+        Test that Object Storage Buckets in a specified cluster can be reterived
+        """
+        buckets_in_cluster_url = "/object-storage/buckets/us-east-1"
+        with self.mock_get(buckets_in_cluster_url) as m:
+            cluster = ObjectStorageCluster(self.client, "us-east-1")
+            buckets = cluster.buckets_in_cluster()
+            self.assertIsNotNone(buckets)
+            bucket = buckets[0]
+
+            self.assertEqual(m.call_url, buckets_in_cluster_url)
+            self.assertEqual(bucket.cluster, "us-east-1")
+            self.assertEqual(
+                bucket.created,
+                datetime(
+                    year=2019, month=1, day=1, hour=1, minute=23, second=45
+                ),
+            )
+            self.assertEqual(
+                bucket.hostname, "example-bucket.us-east-1.linodeobjects.com"
+            )
+            self.assertEqual(bucket.label, "example-bucket")
+            self.assertEqual(bucket.objects, 4)
+            self.assertEqual(bucket.size, 188318981)
+
+    def test_ssl_cert_delete(self):
+        """
+        Test that you can delete the TLS/SSL certificate and private key of a bucket.
+        """
+        ssl_cert_delete_url = (
+            "/object-storage/buckets/us-east-1/example-bucket/ssl"
+        )
+        with self.mock_delete() as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            object_storage_bucket.ssl_cert_delete()
+            self.assertEqual(m.call_url, ssl_cert_delete_url)
+
+    def test_ssl_cert(self):
+        """
+        Test tha you can get a bool value indicating if this bucket
+        has a corresponding TLS/SSL certificate.
+        """
+        ssl_cert_url = "/object-storage/buckets/us-east-1/example-bucket/ssl"
+        with self.mock_get(ssl_cert_url) as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            result = object_storage_bucket.ssl_cert()
+            self.assertIsNotNone(result)
+            self.assertEqual(m.call_url, ssl_cert_url)
+            self.assertEqual(result.ssl, True)
+
+    def test_ssl_cert_upload(self):
+        """
+        Test that you can upload a TLS/SSL cert.
+        """
+        ssl_cert_upload_url = (
+            "/object-storage/buckets/us-east-1/example-bucket/ssl"
+        )
+        with self.mock_post(ssl_cert_upload_url) as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            result = object_storage_bucket.ssl_cert_upload(
+                "-----BEGIN CERTIFICATE-----\nCERTIFICATE_INFORMATION\n-----END CERTIFICATE-----",
+                "-----BEGIN PRIVATE KEY-----\nPRIVATE_KEY_INFORMATION\n-----END PRIVATE KEY-----",
+            )
+            self.assertIsNotNone(result)
+            self.assertEqual(m.call_url, ssl_cert_upload_url)
+            self.assertEqual(result.ssl, True)
+            self.assertEqual(
+                m.call_data,
+                {
+                    "certificate": "-----BEGIN CERTIFICATE-----\nCERTIFICATE_INFORMATION\n-----END CERTIFICATE-----",
+                    "private_key": "-----BEGIN PRIVATE KEY-----\nPRIVATE_KEY_INFORMATION\n-----END PRIVATE KEY-----",
+                },
+            )
+
+    def test_contents(self):
+        """
+        Test that you can get the contents of a bucket.
+        """
+        bucket_contents_url = (
+            "/object-storage/buckets/us-east-1/example-bucket/object-list"
+        )
+        with self.mock_get(bucket_contents_url) as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            contents = object_storage_bucket.contents()
+            self.assertIsNotNone(contents)
+            content = contents[0]
+
+            self.assertEqual(m.call_url, bucket_contents_url)
+            self.assertEqual(content.etag, "9f254c71e28e033bf9e0e5262e3e72ab")
+            self.assertEqual(content.is_truncated, True)
+            self.assertEqual(content.last_modified, "2019-01-01T01:23:45")
+            self.assertEqual(content.name, "example")
+            self.assertEqual(
+                content.next_marker,
+                "bd021c21-e734-4823-97a4-58b41c2cd4c8.892602.184",
+            )
+            self.assertEqual(
+                content.owner, "bfc70ab2-e3d4-42a4-ad55-83921822270c"
+            )
+            self.assertEqual(content.size, 123)
+            self.assertEqual(
+                m.call_data,
+                {
+                    "page_size": 100,
+                },
+            )
+
+    def test_object_acl_config(self):
+        """
+        Test that you can view an Object’s configured Access Control List (ACL) in this Object Storage bucket.
+        """
+        object_acl_config_url = (
+            "/object-storage/buckets/us-east-1/example-bucket/object-acl"
+        )
+        with self.mock_get(object_acl_config_url) as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            acl = object_storage_bucket.object_acl_config("example")
+            self.assertEqual(m.call_url, object_acl_config_url)
+            self.assertEqual(acl.acl, "public-read")
+            self.assertEqual(
+                acl.acl_xml, "<AccessControlPolicy>...</AccessControlPolicy>"
+            )
+            self.assertEqual(
+                m.call_data,
+                {
+                    "name": "example",
+                },
+            )
+
+    def test_object_acl_config_update(self):
+        """
+        Test that you can update an Object’s configured Access Control List (ACL) in this Object Storage bucket.
+        """
+        object_acl_config_update_url = (
+            "/object-storage/buckets/us-east-1/example-bucket/object-acl"
+        )
+        with self.mock_put(object_acl_config_update_url) as m:
+            object_storage_bucket = ObjectStorageBucket(
+                self.client, "example-bucket", "us-east-1"
+            )
+            acl = object_storage_bucket.object_acl_config_update(
+                ObjectStorageACL.PUBLIC_READ,
+                "example",
+            )
+            self.assertEqual(m.call_url, object_acl_config_update_url)
+            self.assertEqual(acl.acl, "public-read")
+            self.assertEqual(
+                acl.acl_xml, "<AccessControlPolicy>...</AccessControlPolicy>"
+            )
+            self.assertEqual(
+                m.call_data,
+                {
+                    "acl": "public-read",
+                    "name": "example",
+                },
+            )
diff --git a/test/objects/profile_test.py b/test/objects/profile_test.py
index 3a57b6757..58fbdf125 100644
--- a/test/objects/profile_test.py
+++ b/test/objects/profile_test.py
@@ -1,14 +1,15 @@
 from datetime import datetime
-
 from test.base import ClientBaseCase
 
-from linode_api4.objects import SSHKey
+from linode_api4.objects import Profile, ProfileLogin, SSHKey
+from linode_api4.objects.profile import TrustedDevice
 
 
 class SSHKeyTest(ClientBaseCase):
     """
     Tests methods of the SSHKey class
     """
+
     def test_get_ssh_key(self):
         """
         Tests that an SSHKey is loaded correctly by ID
@@ -16,27 +17,31 @@ def test_get_ssh_key(self):
         key = SSHKey(self.client, 22)
         self.assertEqual(key._populated, False)
 
-        self.assertEqual(key.label, 'Home Ubuntu PC')
+        self.assertEqual(key.label, "Home Ubuntu PC")
         self.assertEqual(key._populated, True)
 
-        self.assertEqual(key.created, datetime(year=2018, month=9, day=14, hour=13,
-                                                minute=0, second=0))
+        self.assertEqual(
+            key.created,
+            datetime(year=2018, month=9, day=14, hour=13, minute=0, second=0),
+        )
         self.assertEqual(key.id, 22)
         self.assertEqual(
-            key.ssh_key, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98"
-                          "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD"
-                          "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO"
-                          "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i"
-                          "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo"
-                          "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC"
-                          "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy"
-                          "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz"
-                          "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz"
-                          "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v"
-                          "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH"
-                          "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b"
-                          "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI"
-                          "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w== dorthu@dorthu-command")
+            key.ssh_key,
+            "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98"
+            "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD"
+            "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO"
+            "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i"
+            "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo"
+            "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC"
+            "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy"
+            "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz"
+            "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz"
+            "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v"
+            "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH"
+            "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b"
+            "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI"
+            "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w== dorthu@dorthu-command",
+        )
 
     def test_update_ssh_key(self):
         """
@@ -44,15 +49,13 @@ def test_update_ssh_key(self):
         """
         key = SSHKey(self.client, 22)
 
-        key.label = 'New Label'
+        key.label = "New Label"
 
-        with self.mock_put('profile/sshkeys/22') as m:
+        with self.mock_put("profile/sshkeys/22") as m:
             key.save()
 
-            self.assertEqual(m.call_url, '/profile/sshkeys/22')
-            self.assertEqual(m.call_data, {
-                "label": 'New Label'
-            })
+            self.assertEqual(m.call_url, "/profile/sshkeys/22")
+            self.assertEqual(m.call_data, {"label": "New Label"})
 
     def test_delete_ssh_key(self):
         """
@@ -63,4 +66,48 @@ def test_delete_ssh_key(self):
         with self.mock_delete() as m:
             key.delete()
 
-            self.assertEqual(m.call_url, '/profile/sshkeys/22')
+            self.assertEqual(m.call_url, "/profile/sshkeys/22")
+
+
+class ProfileTest(ClientBaseCase):
+    """
+    Tests methods of the Profile class
+    """
+
+    def test_get_profile(self):
+        """
+        Tests that a Profile is loaded correctly by ID
+        """
+        profile = Profile(self.client, "exampleUser")
+
+        self.assertEqual(profile.username, "exampleUser")
+        self.assertEqual(profile.authentication_type, "password")
+        self.assertIsNotNone(profile.authorized_keys)
+        self.assertEqual(profile.email, "example-user@gmail.com")
+        self.assertTrue(profile.email_notifications)
+        self.assertFalse(profile.ip_whitelist_enabled)
+        self.assertEqual(profile.lish_auth_method, "keys_only")
+        self.assertIsNotNone(profile.referrals)
+        self.assertFalse(profile.restricted)
+        self.assertEqual(profile.timezone, "US/Eastern")
+        self.assertTrue(profile.two_factor_auth)
+        self.assertEqual(profile.uid, 1234)
+        self.assertEqual(profile.verified_phone_number, "+5555555555")
+
+    def test_get_trusted_device(self):
+        device = TrustedDevice(self.client, 123)
+
+        self.assertEqual(device.id, 123)
+        self.assertEqual(
+            device.user_agent,
+            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 Vivaldi/2.1.1337.36\n",
+        )
+
+    def test_get_login(self):
+        login = ProfileLogin(self.client, 123)
+
+        self.assertEqual(login.id, 123)
+        self.assertEqual(login.ip, "192.0.2.0")
+        self.assertEqual(login.status, "successful")
+        self.assertEqual(login.username, "example_user")
+        self.assertTrue(login.restricted)
diff --git a/test/objects/region_test.py b/test/objects/region_test.py
new file mode 100644
index 000000000..3a2cb62d4
--- /dev/null
+++ b/test/objects/region_test.py
@@ -0,0 +1,22 @@
+from test.base import ClientBaseCase
+
+from linode_api4.objects import Region
+
+
+class RegionTest(ClientBaseCase):
+    """
+    Tests methods of the Region class
+    """
+
+    def test_get_region(self):
+        """
+        Tests that a Region is loaded correctly by ID
+        """
+        region = Region(self.client, "us-east")
+
+        self.assertEqual(region.id, "us-east")
+        self.assertIsNotNone(region.capabilities)
+        self.assertEqual(region.country, "us")
+        self.assertEqual(region.label, "label7")
+        self.assertEqual(region.status, "ok")
+        self.assertIsNotNone(region.resolvers)
diff --git a/test/objects/support_test.py b/test/objects/support_test.py
new file mode 100644
index 000000000..50ca0f9b9
--- /dev/null
+++ b/test/objects/support_test.py
@@ -0,0 +1,28 @@
+from test.base import ClientBaseCase
+
+from linode_api4.objects import SupportTicket
+
+
+class SupportTest(ClientBaseCase):
+    """
+    Tests methods of the SupportTicket class
+    """
+
+    def test_get_support_ticket(self):
+        ticket = SupportTicket(self.client, 123)
+
+        self.assertIsNotNone(ticket.attachments)
+        self.assertFalse(ticket.closable)
+        self.assertIsNotNone(ticket.entity)
+        self.assertEqual(ticket.gravatar_id, "474a1b7373ae0be4132649e69c36ce30")
+        self.assertEqual(ticket.id, 123)
+        self.assertEqual(ticket.opened_by, "some_user")
+        self.assertEqual(ticket.status, "open")
+        self.assertEqual(ticket.updated_by, "some_other_user")
+
+    def test_support_ticket_close(self):
+        ticket = SupportTicket(self.client, 123)
+
+        with self.mock_post({}) as m:
+            ticket.support_ticket_close()
+            self.assertEqual(m.call_url, "/support/tickets/123/close")
diff --git a/test/objects/tag_test.py b/test/objects/tag_test.py
index ba2d726c2..a6c78efbb 100644
--- a/test/objects/tag_test.py
+++ b/test/objects/tag_test.py
@@ -1,4 +1,3 @@
-from datetime import datetime
 from test.base import ClientBaseCase
 
 from linode_api4.objects import Instance, Tag
@@ -8,39 +7,40 @@ class TagTest(ClientBaseCase):
     """
     Tests methods of the Tag class
     """
+
     def test_get_tag(self):
         """
         Tests that Tag is loaded correctly by label
         """
-        tag = Tag(self.client, 'something')
+        tag = Tag(self.client, "something")
 
         self.assertEqual(tag.label, "something")
-        self.assertFalse(hasattr(tag, '_raw_objects'))
+        self.assertFalse(hasattr(tag, "_raw_objects"))
 
     def test_load_tag(self):
         """
         Tests that the LinodeClient can load a tag
         """
-        tag = self.client.load(Tag, 'something')
+        tag = self.client.load(Tag, "something")
 
-        self.assertEqual(tag.label, 'something')
-        self.assertTrue(hasattr(tag, '_raw_objects')) # got the raw objects
+        self.assertEqual(tag.label, "something")
+        self.assertTrue(hasattr(tag, "_raw_objects"))  # got the raw objects
         print(tag._raw_objects)
 
         # objects loaded up right
         self.assertEqual(len(tag.objects), 1)
         self.assertEqual(tag.objects[0].id, 123)
-        self.assertEqual(tag.objects[0].label, 'linode123')
-        self.assertEqual(tag.objects[0].tags, ['something'])
+        self.assertEqual(tag.objects[0].label, "linode123")
+        self.assertEqual(tag.objects[0].tags, ["something"])
 
     def test_delete_tag(self):
         """
         Tests that you can delete a tag
         """
         with self.mock_delete() as m:
-            tag = Tag(self.client, 'nothing')
+            tag = Tag(self.client, "nothing")
             result = tag.delete()
 
             self.assertEqual(result, True)
 
-            self.assertEqual(m.call_url, '/tags/nothing')
+            self.assertEqual(m.call_url, "/tags/nothing")
diff --git a/test/objects/volume_test.py b/test/objects/volume_test.py
index 3e967caf0..1dd652eb4 100644
--- a/test/objects/volume_test.py
+++ b/test/objects/volume_test.py
@@ -16,19 +16,19 @@ def test_get_volume(self):
         volume = Volume(self.client, 1)
         self.assertEqual(volume._populated, False)
 
-        self.assertEqual(volume.label, 'block1')
+        self.assertEqual(volume.label, "block1")
         self.assertEqual(volume._populated, True)
 
         self.assertEqual(volume.size, 40)
         self.assertEqual(volume.linode, None)
-        self.assertEqual(volume.status, 'active')
+        self.assertEqual(volume.status, "active")
         self.assertIsInstance(volume.updated, datetime)
-        self.assertEqual(volume.region.id, 'us-east-1a')
+        self.assertEqual(volume.region.id, "us-east-1a")
 
         assert volume.tags == ["something"]
 
-        self.assertEqual(volume.filesystem_path, 'this/is/a/file/path')
-        self.assertEqual(volume.hardware_type, 'hdd')
+        self.assertEqual(volume.filesystem_path, "this/is/a/file/path")
+        self.assertEqual(volume.hardware_type, "hdd")
         self.assertEqual(volume.linode_label, None)
 
     def test_update_volume_tags(self):
@@ -37,12 +37,12 @@ def test_update_volume_tags(self):
         """
         volume = self.client.volumes().first()
 
-        with self.mock_put('volumes/1') as m:
-            volume.tags = ['test1', 'test2']
+        with self.mock_put("volumes/1") as m:
+            volume.tags = ["test1", "test2"]
             volume.save()
 
-            assert m.call_url == '/volumes/{}'.format(volume.id)
-            assert m.call_data['tags'] == ['test1', 'test2']
+            assert m.call_url == "/volumes/{}".format(volume.id)
+            assert m.call_data["tags"] == ["test1", "test2"]
 
     def test_clone_volume(self):
         """
@@ -51,10 +51,14 @@ def test_clone_volume(self):
         """
         volume_to_clone = self.client.volumes().first()
 
-        with self.mock_post(f'volumes/{volume_to_clone.id}') as mock:
-            new_volume = volume_to_clone.clone('new-volume')
-            assert mock.call_url == f'/volumes/{volume_to_clone.id}/clone'
-            self.assertEqual(str(new_volume.region), str(volume_to_clone.region), 'the regions should be the same')
+        with self.mock_post(f"volumes/{volume_to_clone.id}") as mock:
+            new_volume = volume_to_clone.clone("new-volume")
+            assert mock.call_url == f"/volumes/{volume_to_clone.id}/clone"
+            self.assertEqual(
+                str(new_volume.region),
+                str(volume_to_clone.region),
+                "the regions should be the same",
+            )
             assert new_volume.id != str(volume_to_clone.id)
 
     def test_resize_volume(self):
@@ -63,10 +67,10 @@ def test_resize_volume(self):
         """
         volume = self.client.volumes().first()
 
-        with self.mock_post(f'volumes/{volume.id}') as mock:
+        with self.mock_post(f"volumes/{volume.id}") as mock:
             volume.resize(3048)
-            assert mock.call_url == f'/volumes/{volume.id}/resize'
-            assert str(mock.call_data['size']) == '3048'
+            assert mock.call_url == f"/volumes/{volume.id}/resize"
+            assert str(mock.call_data["size"]) == "3048"
 
     def test_detach_volume(self):
         """
@@ -74,9 +78,9 @@ def test_detach_volume(self):
         """
         volume = self.client.volumes()[2]
 
-        with self.mock_post(f'volumes/{volume.id}') as mock:
+        with self.mock_post(f"volumes/{volume.id}") as mock:
             result = volume.detach()
-            assert mock.call_url == f'/volumes/{volume.id}/detach'
+            assert mock.call_url == f"/volumes/{volume.id}/detach"
             assert result is True
 
     def test_attach_volume_to_linode(self):
@@ -85,8 +89,8 @@ def test_attach_volume_to_linode(self):
         """
         volume = self.client.volumes().first()
 
-        with self.mock_post(f'volumes/{volume.id}') as mock:
+        with self.mock_post(f"volumes/{volume.id}") as mock:
             result = volume.attach(1)
-            assert mock.call_url == f'/volumes/{volume.id}/attach'
+            assert mock.call_url == f"/volumes/{volume.id}/attach"
             assert result is True
-            assert str(mock.call_data['linode_id']) == '1'
\ No newline at end of file
+            assert str(mock.call_data["linode_id"]) == "1"
diff --git a/test/paginated_list_test.py b/test/paginated_list_test.py
index 77643a5fd..2d6705561 100644
--- a/test/paginated_list_test.py
+++ b/test/paginated_list_test.py
@@ -10,28 +10,31 @@ def setUp(self):
         Creates sample mocked lists for use in the test cases
         """
         self.normal_list = list(range(25))
-        self.paginated_list = PaginatedList(None, None, page=self.normal_list,
-                total_items=25)
+        self.paginated_list = PaginatedList(
+            None, None, page=self.normal_list, total_items=25
+        )
 
     def test_slice_normal(self):
         """
         Tests that bounded, forward slices work as expected
         """
-        slices = ( (1, 10), (10, 20), (5, 25), (0, 10) )
+        slices = ((1, 10), (10, 20), (5, 25), (0, 10))
 
-        for (start, stop) in slices:
-            self.assertEqual(self.normal_list[start:stop],
-                    self.paginated_list[start:stop])
+        for start, stop in slices:
+            self.assertEqual(
+                self.normal_list[start:stop], self.paginated_list[start:stop]
+            )
 
     def test_slice_negative(self):
         """
         Tests that negative indexing works in slices
         """
-        slices = ( (-10,-5), (-20, 20), (3, -10) )
+        slices = ((-10, -5), (-20, 20), (3, -10))
 
-        for (start, stop) in slices:
-            self.assertEqual(self.normal_list[start:stop],
-                    self.paginated_list[start:stop])
+        for start, stop in slices:
+            self.assertEqual(
+                self.normal_list[start:stop], self.paginated_list[start:stop]
+            )
 
     def test_slice_no_lower_bound(self):
         """
@@ -68,7 +71,7 @@ def test_slice_unsupported_step(self):
         """
         Tests that steps outside of 1 raise a NotImplementedError
         """
-        for step in ( -1, 0, 2, 3 ):
+        for step in (-1, 0, 2, 3):
             with self.assertRaises(NotImplementedError):
                 self.paginated_list[::step]
 
@@ -79,11 +82,12 @@ def test_slice_backward_indexing(self):
         self.assertEqual(self.normal_list[10:5], self.paginated_list[10:5])
 
 
-class TestModel():
+class TestModel:
     """
     This is a test model class used to simulate an actual model that would be
     returned by the API
     """
+
     @classmethod
     def make_instance(*args, **kwargs):
         return TestModel()
@@ -97,7 +101,7 @@ def test_page_size_in_request(self):
 
         for i in (25, 100, 500):
             # these are the pages we're sending in to the mocked list
-            first_page = [ TestModel()  for x in range(i) ]
+            first_page = [TestModel() for x in range(i)]
             second_page = {
                 "data": [{"id": 1}],
                 "pages": 2,
@@ -110,11 +114,15 @@ def test_page_size_in_request(self):
             client.get = MagicMock(return_value=second_page)
 
             # let's do it!
-            p = PaginatedList(client, "/test", page=first_page, max_pages=2, total_items=i+1)
-            p[i] # load second page
+            p = PaginatedList(
+                client, "/test", page=first_page, max_pages=2, total_items=i + 1
+            )
+            p[i]  # load second page
 
             # and we called the next page URL with the correct page_size
-            assert client.get.call_args == call("//test?page=2&page_size={}".format(i), filters=None)
+            assert client.get.call_args == call(
+                "//test?page=2&page_size={}".format(i), filters=None
+            )
 
     def test_no_pages(self):
         """
@@ -125,4 +133,4 @@ def test_no_pages(self):
 
         p = PaginatedList(client, "/test", page=[], max_pages=0, total_items=0)
 
-        assert(len(p) == 0)
+        assert len(p) == 0
diff --git a/test/util_test.py b/test/util_test.py
index cdee919ab..3123a4447 100644
--- a/test/util_test.py
+++ b/test/util_test.py
@@ -18,16 +18,10 @@ def test_drop_null_keys_nonrecursive(self):
             "cool": {
                 "test": "bar",
                 "cool": None,
-            }
+            },
         }
 
-        expected_output = {
-            "foo": "bar",
-            "cool": {
-                "test": "bar",
-                "cool": None
-            }
-        }
+        expected_output = {"foo": "bar", "cool": {"test": "bar", "cool": None}}
 
         assert drop_null_keys(value, recursive=False) == expected_output
 
@@ -42,13 +36,8 @@ def test_drop_null_keys_recursive(self):
             "cool": {
                 "test": "bar",
                 "cool": None,
-                "list": [
-                    {
-                        "foo": "bar",
-                        "test": None
-                    }
-                ]
-            }
+                "list": [{"foo": "bar", "test": None}],
+            },
         }
 
         expected_output = {
@@ -59,8 +48,8 @@ def test_drop_null_keys_recursive(self):
                     {
                         "foo": "bar",
                     }
-                ]
-            }
+                ],
+            },
         }
 
         assert drop_null_keys(value) == expected_output