Repository: chasleslr/metabase-python (language: python)

Issue:
Add README



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/ci.yml
similarity index 50%
rename from .github/workflows/python-package.yml
rename to .github/workflows/ci.yml
index 9f2df3a..612bb3f 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/ci.yml
@@ -1,13 +1,16 @@
 # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
 # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
 
-name: Python package
+name: build
 
 on:
   push:
-    branches: [ main ]
+    branches:
+      - main
   pull_request:
-    branches: [ main ]
+    branches:
+      - main
+      - develop
 
 jobs:
   build:
@@ -16,19 +19,41 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.8", "3.9", "3.10"]
+        python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]
+        os: [ubuntu-latest, macos-latest, windows-latest]
+
+    services:
+      metabase:
+        image: metabase/metabase
+        ports:
+          - 3000:3000
 
     steps:
     - uses: actions/checkout@v2
+
     - name: Set up Python ${{ matrix.python-version }}
       uses: actions/setup-python@v2
       with:
         python-version: ${{ matrix.python-version }}
+
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
         python -m pip install pipenv
-        pipenv install --deploy
+        pipenv install --deploy --dev
+
+    - name: Wait for Metabase to be initialized
+      run: sleep 60s
+      shell: bash
+
     - name: Test with pytest
       run: |
-        pipenv run pytest
+        pipenv run pytest --cov=./ --cov-report=xml
+
+    - name: Upload coverage to Codecov
+      uses: codecov/codecov-action@v2
+      with:
+        token: ${{ secrets.CODECOV_TOKEN }}
+        directory: ./
+        env_vars: OS,PYTHON
+        fail_ci_if_error: true
diff --git a/.gitignore b/.gitignore
index dfe7351..e5fc47a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -139,4 +139,6 @@ dmypy.json
 # Cython debug symbols
 cython_debug/
 
-.idea
+
+.idea/
+/metabase/_version.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..574013a
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,18 @@
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v3.2.0
+    hooks:
+      - id: trailing-whitespace
+      - id: end-of-file-fixer
+      - id: check-yaml
+      - id: check-added-large-files
+
+  - repo: https://github.com/psf/black
+    rev: 21.11b0
+    hooks:
+      - id: black
+
+  - repo: https://github.com/PyCQA/isort
+    rev: 5.10.1
+    hooks:
+      - id: isort
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9710142
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Charles Lariviere
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..60823de
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,3 @@
+dev:
+	@pipenv install --dev --pre
+	@pipenv run pre-commit install
diff --git a/Pipfile b/Pipfile
index 1697c4d..a378bcc 100644
--- a/Pipfile
+++ b/Pipfile
@@ -4,12 +4,15 @@ verify_ssl = true
 name = "pypi"
 
 [packages]
+metabase-python = {editable = true, path = "."}
 
 [dev-packages]
 pre-commit = "*"
 pytest = "*"
 pytest-cov = "*"
 black = "*"
+build = "*"
+twine = "*"
 
 [requires]
 python_version = "3.8"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..ca3615c
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,691 @@
+{
+    "_meta": {
+        "hash": {
+            "sha256": "8536a593787a25505502097224e507ea92aad5868491f6d7d56a814aad331a45"
+        },
+        "pipfile-spec": 6,
+        "requires": {
+            "python_version": "3.8"
+        },
+        "sources": [
+            {
+                "name": "pypi",
+                "url": "https://pypi.org/simple",
+                "verify_ssl": true
+            }
+        ]
+    },
+    "default": {
+        "certifi": {
+            "hashes": [
+                "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
+                "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
+            ],
+            "version": "==2021.10.8"
+        },
+        "charset-normalizer": {
+            "hashes": [
+                "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0",
+                "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==2.0.7"
+        },
+        "idna": {
+            "hashes": [
+                "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
+                "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==3.3"
+        },
+        "metabase-python": {
+            "editable": true,
+            "path": "."
+        },
+        "numpy": {
+            "hashes": [
+                "sha256:0b78ecfa070460104934e2caf51694ccd00f37d5e5dbe76f021b1b0b0d221823",
+                "sha256:1247ef28387b7bb7f21caf2dbe4767f4f4175df44d30604d42ad9bd701ebb31f",
+                "sha256:1403b4e2181fc72664737d848b60e65150f272fe5a1c1cbc16145ed43884065a",
+                "sha256:170b2a0805c6891ca78c1d96ee72e4c3ed1ae0a992c75444b6ab20ff038ba2cd",
+                "sha256:2e4ed57f45f0aa38beca2a03b6532e70e548faf2debbeb3291cfc9b315d9be8f",
+                "sha256:32fe5b12061f6446adcbb32cf4060a14741f9c21e15aaee59a207b6ce6423469",
+                "sha256:34f3456f530ae8b44231c63082c8899fe9c983fd9b108c997c4b1c8c2d435333",
+                "sha256:4c9c23158b87ed0e70d9a50c67e5c0b3f75bcf2581a8e34668d4e9d7474d76c6",
+                "sha256:5d95668e727c75b3f5088ec7700e260f90ec83f488e4c0aaccb941148b2cd377",
+                "sha256:615d4e328af7204c13ae3d4df7615a13ff60a49cb0d9106fde07f541207883ca",
+                "sha256:69077388c5a4b997442b843dbdc3a85b420fb693ec8e33020bb24d647c164fa5",
+                "sha256:74b85a17528ca60cf98381a5e779fc0264b4a88b46025e6bcbe9621f46bb3e63",
+                "sha256:81225e58ef5fce7f1d80399575576fc5febec79a8a2742e8ef86d7b03beef49f",
+                "sha256:8890b3360f345e8360133bc078d2dacc2843b6ee6059b568781b15b97acbe39f",
+                "sha256:92aafa03da8658609f59f18722b88f0a73a249101169e28415b4fa148caf7e41",
+                "sha256:9864424631775b0c052f3bd98bc2712d131b3e2cd95d1c0c68b91709170890b0",
+                "sha256:9e6f5f50d1eff2f2f752b3089a118aee1ea0da63d56c44f3865681009b0af162",
+                "sha256:a3deb31bc84f2b42584b8c4001c85d1934dbfb4030827110bc36bfd11509b7bf",
+                "sha256:ad010846cdffe7ec27e3f933397f8a8d6c801a48634f419e3d075db27acf5880",
+                "sha256:b1e2312f5b8843a3e4e8224b2b48fe16119617b8fc0a54df8f50098721b5bed2",
+                "sha256:bc988afcea53e6156546e5b2885b7efab089570783d9d82caf1cfd323b0bb3dd",
+                "sha256:c449eb870616a7b62e097982c622d2577b3dbc800aaf8689254ec6e0197cbf1e",
+                "sha256:c74c699b122918a6c4611285cc2cad4a3aafdb135c22a16ec483340ef97d573c",
+                "sha256:c885bfc07f77e8fee3dc879152ba993732601f1f11de248d4f357f0ffea6a6d4",
+                "sha256:e3c3e990274444031482a31280bf48674441e0a5b55ddb168f3a6db3e0c38ec8",
+                "sha256:e4799be6a2d7d3c33699a6f77201836ac975b2e1b98c2a07f66a38f499cb50ce",
+                "sha256:e6c76a87633aa3fa16614b61ccedfae45b91df2767cf097aa9c933932a7ed1e0",
+                "sha256:e89717274b41ebd568cd7943fc9418eeb49b1785b66031bc8a7f6300463c5898",
+                "sha256:f5162ec777ba7138906c9c274353ece5603646c6965570d82905546579573f73",
+                "sha256:fde96af889262e85aa033f8ee1d3241e32bf36228318a61f1ace579df4e8170d"
+            ],
+            "markers": "python_version < '3.10' and platform_machine != 'aarch64' and platform_machine != 'arm64'",
+            "version": "==1.21.4"
+        },
+        "packaging": {
+            "hashes": [
+                "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
+                "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==21.3"
+        },
+        "pandas": {
+            "hashes": [
+                "sha256:003ba92db58b71a5f8add604a17a059f3068ef4e8c0c365b088468d0d64935fd",
+                "sha256:10e10a2527db79af6e830c3d5842a4d60383b162885270f8cffc15abca4ba4a9",
+                "sha256:22808afb8f96e2269dcc5b846decacb2f526dd0b47baebc63d913bf847317c8f",
+                "sha256:2d1dc09c0013d8faa7474574d61b575f9af6257ab95c93dcf33a14fd8d2c1bab",
+                "sha256:35c77609acd2e4d517da41bae0c11c70d31c87aae8dd1aabd2670906c6d2c143",
+                "sha256:372d72a3d8a5f2dbaf566a5fa5fa7f230842ac80f29a931fb4b071502cf86b9a",
+                "sha256:42493f8ae67918bf129869abea8204df899902287a7f5eaf596c8e54e0ac7ff4",
+                "sha256:4acc28364863127bca1029fb72228e6f473bb50c32e77155e80b410e2068eeac",
+                "sha256:5298a733e5bfbb761181fd4672c36d0c627320eb999c59c65156c6a90c7e1b4f",
+                "sha256:5ba0aac1397e1d7b654fccf263a4798a9e84ef749866060d19e577e927d66e1b",
+                "sha256:9707bdc1ea9639c886b4d3be6e2a45812c1ac0c2080f94c31b71c9fa35556f9b",
+                "sha256:a2aa18d3f0b7d538e21932f637fbfe8518d085238b429e4790a35e1e44a96ffc",
+                "sha256:a388960f979665b447f0847626e40f99af8cf191bce9dc571d716433130cb3a7",
+                "sha256:a51528192755f7429c5bcc9e80832c517340317c861318fea9cea081b57c9afd",
+                "sha256:b528e126c13816a4374e56b7b18bfe91f7a7f6576d1aadba5dee6a87a7f479ae",
+                "sha256:c1aa4de4919358c5ef119f6377bc5964b3a7023c23e845d9db7d9016fa0c5b1c",
+                "sha256:c2646458e1dce44df9f71a01dc65f7e8fa4307f29e5c0f2f92c97f47a5bf22f5",
+                "sha256:c2f44425594ae85e119459bb5abb0748d76ef01d9c08583a667e3339e134218e",
+                "sha256:d47750cf07dee6b55d8423471be70d627314277976ff2edd1381f02d52dbadf9",
+                "sha256:d99d2350adb7b6c3f7f8f0e5dfb7d34ff8dd4bc0a53e62c445b7e43e163fce63",
+                "sha256:dd324f8ee05925ee85de0ea3f0d66e1362e8c80799eb4eb04927d32335a3e44a",
+                "sha256:eaca36a80acaacb8183930e2e5ad7f71539a66805d6204ea88736570b2876a7b",
+                "sha256:f567e972dce3bbc3a8076e0b675273b4a9e8576ac629149cf8286ee13c259ae5",
+                "sha256:fe48e4925455c964db914b958f6e7032d285848b7538a5e1b19aeb26ffaea3ec"
+            ],
+            "markers": "python_full_version >= '3.7.1'",
+            "version": "==1.3.4"
+        },
+        "pyparsing": {
+            "hashes": [
+                "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4",
+                "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.0.6"
+        },
+        "python-dateutil": {
+            "hashes": [
+                "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
+                "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.8.2"
+        },
+        "pytz": {
+            "hashes": [
+                "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c",
+                "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"
+            ],
+            "version": "==2021.3"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
+                "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
+            "version": "==2.26.0"
+        },
+        "setuptools-scm": {
+            "hashes": [
+                "sha256:4c64444b1d49c4063ae60bfe1680f611c8b13833d556fd1d6050c0023162a119",
+                "sha256:a49aa8081eeb3514eb9728fa5040f2eaa962d6c6f4ec9c32f6c1fba88f88a0f2"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==6.3.2"
+        },
+        "six": {
+            "hashes": [
+                "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
+                "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.16.0"
+        },
+        "tomli": {
+            "hashes": [
+                "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee",
+                "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==1.2.2"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece",
+                "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+            "version": "==1.26.7"
+        }
+    },
+    "develop": {
+        "attrs": {
+            "hashes": [
+                "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
+                "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==21.2.0"
+        },
+        "backports.entry-points-selectable": {
+            "hashes": [
+                "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b",
+                "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"
+            ],
+            "markers": "python_version >= '2.7'",
+            "version": "==1.1.1"
+        },
+        "black": {
+            "hashes": [
+                "sha256:802c6c30b637b28645b7fde282ed2569c0cd777dbe493a41b6a03c1d903f99ac",
+                "sha256:a042adbb18b3262faad5aff4e834ff186bb893f95ba3a8013f09de1e5569def2"
+            ],
+            "index": "pypi",
+            "version": "==21.11b1"
+        },
+        "bleach": {
+            "hashes": [
+                "sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da",
+                "sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==4.1.0"
+        },
+        "build": {
+            "hashes": [
+                "sha256:1aaadcd69338252ade4f7ec1265e1a19184bf916d84c9b7df095f423948cb89f",
+                "sha256:21b7ebbd1b22499c4dac536abc7606696ea4d909fd755e00f09f3c0f2c05e3c8"
+            ],
+            "index": "pypi",
+            "version": "==0.7.0"
+        },
+        "certifi": {
+            "hashes": [
+                "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
+                "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
+            ],
+            "version": "==2021.10.8"
+        },
+        "cfgv": {
+            "hashes": [
+                "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426",
+                "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"
+            ],
+            "markers": "python_full_version >= '3.6.1'",
+            "version": "==3.3.1"
+        },
+        "charset-normalizer": {
+            "hashes": [
+                "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0",
+                "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==2.0.7"
+        },
+        "click": {
+            "hashes": [
+                "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3",
+                "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==8.0.3"
+        },
+        "colorama": {
+            "hashes": [
+                "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
+                "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==0.4.4"
+        },
+        "coverage": {
+            "extras": [
+                "toml"
+            ],
+            "hashes": [
+                "sha256:046647b96969fda1ae0605f61288635209dd69dcd27ba3ec0bf5148bc157f954",
+                "sha256:06d009e8a29483cbc0520665bc46035ffe9ae0e7484a49f9782c2a716e37d0a0",
+                "sha256:0cde7d9fe2fb55ff68ebe7fb319ef188e9b88e0a3d1c9c5db7dd829cd93d2193",
+                "sha256:1de9c6f5039ee2b1860b7bad2c7bc3651fbeb9368e4c4d93e98a76358cdcb052",
+                "sha256:24ed38ec86754c4d5a706fbd5b52b057c3df87901a8610d7e5642a08ec07087e",
+                "sha256:27a3df08a855522dfef8b8635f58bab81341b2fb5f447819bc252da3aa4cf44c",
+                "sha256:310c40bed6b626fd1f463e5a83dba19a61c4eb74e1ac0d07d454ebbdf9047e9d",
+                "sha256:3348865798c077c695cae00da0924136bb5cc501f236cfd6b6d9f7a3c94e0ec4",
+                "sha256:35b246ae3a2c042dc8f410c94bcb9754b18179cdb81ff9477a9089dbc9ecc186",
+                "sha256:3f546f48d5d80a90a266769aa613bc0719cb3e9c2ef3529d53f463996dd15a9d",
+                "sha256:586d38dfc7da4a87f5816b203ff06dd7c1bb5b16211ccaa0e9788a8da2b93696",
+                "sha256:5d3855d5d26292539861f5ced2ed042fc2aa33a12f80e487053aed3bcb6ced13",
+                "sha256:610c0ba11da8de3a753dc4b1f71894f9f9debfdde6559599f303286e70aeb0c2",
+                "sha256:62646d98cf0381ffda301a816d6ac6c35fc97aa81b09c4c52d66a15c4bef9d7c",
+                "sha256:66af99c7f7b64d050d37e795baadf515b4561124f25aae6e1baa482438ecc388",
+                "sha256:675adb3b3380967806b3cbb9c5b00ceb29b1c472692100a338730c1d3e59c8b9",
+                "sha256:6e5a8c947a2a89c56655ecbb789458a3a8e3b0cbf4c04250331df8f647b3de59",
+                "sha256:7a39590d1e6acf6a3c435c5d233f72f5d43b585f5be834cff1f21fec4afda225",
+                "sha256:80cb70264e9a1d04b519cdba3cd0dc42847bf8e982a4d55c769b9b0ee7cdce1e",
+                "sha256:82fdcb64bf08aa5db881db061d96db102c77397a570fbc112e21c48a4d9cb31b",
+                "sha256:8492d37acdc07a6eac6489f6c1954026f2260a85a4c2bb1e343fe3d35f5ee21a",
+                "sha256:94f558f8555e79c48c422045f252ef41eb43becdd945e9c775b45ebfc0cbd78f",
+                "sha256:958ac66272ff20e63d818627216e3d7412fdf68a2d25787b89a5c6f1eb7fdd93",
+                "sha256:95a58336aa111af54baa451c33266a8774780242cab3704b7698d5e514840758",
+                "sha256:96129e41405887a53a9cc564f960d7f853cc63d178f3a182fdd302e4cab2745b",
+                "sha256:97ef6e9119bd39d60ef7b9cd5deea2b34869c9f0b9777450a7e3759c1ab09b9b",
+                "sha256:98d44a8136eebbf544ad91fef5bd2b20ef0c9b459c65a833c923d9aa4546b204",
+                "sha256:9d2c2e3ce7b8cc932a2f918186964bd44de8c84e2f9ef72dc616f5bb8be22e71",
+                "sha256:a300b39c3d5905686c75a369d2a66e68fd01472ea42e16b38c948bd02b29e5bd",
+                "sha256:a34fccb45f7b2d890183a263578d60a392a1a218fdc12f5bce1477a6a68d4373",
+                "sha256:a4d48e42e17d3de212f9af44f81ab73b9378a4b2b8413fd708d0d9023f2bbde4",
+                "sha256:af45eea024c0e3a25462fade161afab4f0d9d9e0d5a5d53e86149f74f0a35ecc",
+                "sha256:ba6125d4e55c0b8e913dad27b22722eac7abdcb1f3eab1bd090eee9105660266",
+                "sha256:bc1ee1318f703bc6c971da700d74466e9b86e0c443eb85983fb2a1bd20447263",
+                "sha256:c18725f3cffe96732ef96f3de1939d81215fd6d7d64900dcc4acfe514ea4fcbf",
+                "sha256:c8e9c4bcaaaa932be581b3d8b88b677489975f845f7714efc8cce77568b6711c",
+                "sha256:cc799916b618ec9fd00135e576424165691fec4f70d7dc12cfaef09268a2478c",
+                "sha256:cd2d11a59afa5001ff28073ceca24ae4c506da4355aba30d1e7dd2bd0d2206dc",
+                "sha256:d0a595a781f8e186580ff8e3352dd4953b1944289bec7705377c80c7e36c4d6c",
+                "sha256:d3c5f49ce6af61154060640ad3b3281dbc46e2e0ef2fe78414d7f8a324f0b649",
+                "sha256:d9a635114b88c0ab462e0355472d00a180a5fbfd8511e7f18e4ac32652e7d972",
+                "sha256:e5432d9c329b11c27be45ee5f62cf20a33065d482c8dec1941d6670622a6fb8f",
+                "sha256:eab14fdd410500dae50fd14ccc332e65543e7b39f6fc076fe90603a0e5d2f929",
+                "sha256:ebcc03e1acef4ff44f37f3c61df478d6e469a573aa688e5a162f85d7e4c3860d",
+                "sha256:fae3fe111670e51f1ebbc475823899524e3459ea2db2cb88279bbfb2a0b8a3de",
+                "sha256:fd92ece726055e80d4e3f01fff3b91f54b18c9c357c48fcf6119e87e2461a091",
+                "sha256:ffa545230ca2ad921ad066bf8fd627e7be43716b6e0fcf8e32af1b8188ccb0ab"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==6.1.2"
+        },
+        "distlib": {
+            "hashes": [
+                "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31",
+                "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"
+            ],
+            "version": "==0.3.3"
+        },
+        "docutils": {
+            "hashes": [
+                "sha256:6c9a32514c7aea91b507093d695e4fefcd68112237155ac8ab039962c6bc88bb",
+                "sha256:803900c9fb1ab4a0ec549a51ac3949f545497cec0b40805ba702bc1986e1fe1b"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==0.18.1b0"
+        },
+        "filelock": {
+            "hashes": [
+                "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8",
+                "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.4.0"
+        },
+        "identify": {
+            "hashes": [
+                "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107",
+                "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"
+            ],
+            "markers": "python_full_version >= '3.6.1'",
+            "version": "==2.4.0"
+        },
+        "idna": {
+            "hashes": [
+                "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
+                "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==3.3"
+        },
+        "importlib-metadata": {
+            "hashes": [
+                "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100",
+                "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==4.8.2"
+        },
+        "iniconfig": {
+            "hashes": [
+                "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
+                "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
+            ],
+            "version": "==1.1.1"
+        },
+        "keyring": {
+            "hashes": [
+                "sha256:6334aee6073db2fb1f30892697b1730105b5e9a77ce7e61fca6b435225493efe",
+                "sha256:bd2145a237ed70c8ce72978b497619ddfcae640b6dcf494402d5143e37755c6e"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==23.2.1"
+        },
+        "mypy-extensions": {
+            "hashes": [
+                "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
+                "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
+            ],
+            "version": "==0.4.3"
+        },
+        "nodeenv": {
+            "hashes": [
+                "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b",
+                "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"
+            ],
+            "version": "==1.6.0"
+        },
+        "packaging": {
+            "hashes": [
+                "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
+                "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==21.3"
+        },
+        "pathspec": {
+            "hashes": [
+                "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a",
+                "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"
+            ],
+            "version": "==0.9.0"
+        },
+        "pep517": {
+            "hashes": [
+                "sha256:931378d93d11b298cf511dd634cf5ea4cb249a28ef84160b3247ee9afb4e8ab0",
+                "sha256:dd884c326898e2c6e11f9e0b64940606a93eb10ea022a2e067959f3a110cf161"
+            ],
+            "version": "==0.12.0"
+        },
+        "pkginfo": {
+            "hashes": [
+                "sha256:65175ffa2c807220673a41c371573ac9a1ea1b19ffd5eef916278f428319934f",
+                "sha256:bb55a6c017d50f2faea5153abc7b05a750e7ea7ae2cbb7fb3ad6f1dcf8d40988"
+            ],
+            "version": "==1.8.1"
+        },
+        "platformdirs": {
+            "hashes": [
+                "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2",
+                "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2.4.0"
+        },
+        "pluggy": {
+            "hashes": [
+                "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
+                "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==1.0.0"
+        },
+        "pre-commit": {
+            "hashes": [
+                "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7",
+                "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"
+            ],
+            "index": "pypi",
+            "version": "==2.15.0"
+        },
+        "py": {
+            "hashes": [
+                "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719",
+                "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==1.11.0"
+        },
+        "pygments": {
+            "hashes": [
+                "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380",
+                "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==2.10.0"
+        },
+        "pyparsing": {
+            "hashes": [
+                "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4",
+                "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.0.6"
+        },
+        "pytest": {
+            "hashes": [
+                "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89",
+                "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"
+            ],
+            "index": "pypi",
+            "version": "==6.2.5"
+        },
+        "pytest-cov": {
+            "hashes": [
+                "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6",
+                "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"
+            ],
+            "index": "pypi",
+            "version": "==3.0.0"
+        },
+        "pyyaml": {
+            "hashes": [
+                "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
+                "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
+                "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
+                "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
+                "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
+                "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
+                "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
+                "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
+                "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
+                "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
+                "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
+                "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
+                "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
+                "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
+                "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
+                "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
+                "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
+                "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
+                "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
+                "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
+                "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
+                "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
+                "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
+                "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
+                "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
+                "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
+                "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
+                "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
+                "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
+                "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
+                "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
+                "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
+                "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==6.0"
+        },
+        "readme-renderer": {
+            "hashes": [
+                "sha256:3286806450d9961d6e3b5f8a59f77e61503799aca5155c8d8d40359b4e1e1adc",
+                "sha256:8299700d7a910c304072a7601eafada6712a5b011a20139417e1b1e9f04645d8"
+            ],
+            "version": "==30.0"
+        },
+        "regex": {
+            "hashes": [
+                "sha256:05b7d6d7e64efe309972adab77fc2af8907bb93217ec60aa9fe12a0dad35874f",
+                "sha256:0617383e2fe465732af4509e61648b77cbe3aee68b6ac8c0b6fe934db90be5cc",
+                "sha256:07856afef5ffcc052e7eccf3213317fbb94e4a5cd8177a2caa69c980657b3cb4",
+                "sha256:162abfd74e88001d20cb73ceaffbfe601469923e875caf9118333b1a4aaafdc4",
+                "sha256:2207ae4f64ad3af399e2d30dde66f0b36ae5c3129b52885f1bffc2f05ec505c8",
+                "sha256:30ab804ea73972049b7a2a5c62d97687d69b5a60a67adca07eb73a0ddbc9e29f",
+                "sha256:3b5df18db1fccd66de15aa59c41e4f853b5df7550723d26aa6cb7f40e5d9da5a",
+                "sha256:3c5fb32cc6077abad3bbf0323067636d93307c9fa93e072771cf9a64d1c0f3ef",
+                "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f",
+                "sha256:432bd15d40ed835a51617521d60d0125867f7b88acf653e4ed994a1f8e4995dc",
+                "sha256:4aaa4e0705ef2b73dd8e36eeb4c868f80f8393f5f4d855e94025ce7ad8525f50",
+                "sha256:537ca6a3586931b16a85ac38c08cc48f10fc870a5b25e51794c74df843e9966d",
+                "sha256:53db2c6be8a2710b359bfd3d3aa17ba38f8aa72a82309a12ae99d3c0c3dcd74d",
+                "sha256:5537f71b6d646f7f5f340562ec4c77b6e1c915f8baae822ea0b7e46c1f09b733",
+                "sha256:6650f16365f1924d6014d2ea770bde8555b4a39dc9576abb95e3cd1ff0263b36",
+                "sha256:666abff54e474d28ff42756d94544cdfd42e2ee97065857413b72e8a2d6a6345",
+                "sha256:68a067c11463de2a37157930d8b153005085e42bcb7ad9ca562d77ba7d1404e0",
+                "sha256:780b48456a0f0ba4d390e8b5f7c661fdd218934388cde1a974010a965e200e12",
+                "sha256:788aef3549f1924d5c38263104dae7395bf020a42776d5ec5ea2b0d3d85d6646",
+                "sha256:7ee1227cf08b6716c85504aebc49ac827eb88fcc6e51564f010f11a406c0a667",
+                "sha256:7f301b11b9d214f83ddaf689181051e7f48905568b0c7017c04c06dfd065e244",
+                "sha256:83ee89483672b11f8952b158640d0c0ff02dc43d9cb1b70c1564b49abe92ce29",
+                "sha256:85bfa6a5413be0ee6c5c4a663668a2cad2cbecdee367630d097d7823041bdeec",
+                "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf",
+                "sha256:93a5051fcf5fad72de73b96f07d30bc29665697fb8ecdfbc474f3452c78adcf4",
+                "sha256:962b9a917dd7ceacbe5cd424556914cb0d636001e393b43dc886ba31d2a1e449",
+                "sha256:98ba568e8ae26beb726aeea2273053c717641933836568c2a0278a84987b2a1a",
+                "sha256:a3feefd5e95871872673b08636f96b61ebef62971eab044f5124fb4dea39919d",
+                "sha256:b43c2b8a330a490daaef5a47ab114935002b13b3f9dc5da56d5322ff218eeadb",
+                "sha256:b483c9d00a565633c87abd0aaf27eb5016de23fed952e054ecc19ce32f6a9e7e",
+                "sha256:ba05430e819e58544e840a68b03b28b6d328aff2e41579037e8bab7653b37d83",
+                "sha256:ca5f18a75e1256ce07494e245cdb146f5a9267d3c702ebf9b65c7f8bd843431e",
+                "sha256:d5ca078bb666c4a9d1287a379fe617a6dccd18c3e8a7e6c7e1eb8974330c626a",
+                "sha256:da1a90c1ddb7531b1d5ff1e171b4ee61f6345119be7351104b67ff413843fe94",
+                "sha256:dba70f30fd81f8ce6d32ddeef37d91c8948e5d5a4c63242d16a2b2df8143aafc",
+                "sha256:dd33eb9bdcfbabab3459c9ee651d94c842bc8a05fabc95edf4ee0c15a072495e",
+                "sha256:e0538c43565ee6e703d3a7c3bdfe4037a5209250e8502c98f20fea6f5fdf2965",
+                "sha256:e1f54b9b4b6c53369f40028d2dd07a8c374583417ee6ec0ea304e710a20f80a0",
+                "sha256:e32d2a2b02ccbef10145df9135751abea1f9f076e67a4e261b05f24b94219e36",
+                "sha256:e71255ba42567d34a13c03968736c5d39bb4a97ce98188fafb27ce981115beec",
+                "sha256:ed2e07c6a26ed4bea91b897ee2b0835c21716d9a469a96c3e878dc5f8c55bb23",
+                "sha256:eef2afb0fd1747f33f1ee3e209bce1ed582d1896b240ccc5e2697e3275f037c7",
+                "sha256:f23222527b307970e383433daec128d769ff778d9b29343fb3496472dc20dabe",
+                "sha256:f341ee2df0999bfdf7a95e448075effe0db212a59387de1a70690e4acb03d4c6",
+                "sha256:f7f325be2804246a75a4f45c72d4ce80d2443ab815063cdf70ee8fb2ca59ee1b",
+                "sha256:f8af619e3be812a2059b212064ea7a640aff0568d972cd1b9e920837469eb3cb",
+                "sha256:fa8c626d6441e2d04b6ee703ef2d1e17608ad44c7cb75258c09dd42bacdfc64b",
+                "sha256:fbb9dc00e39f3e6c0ef48edee202f9520dafb233e8b51b06b8428cfcb92abd30",
+                "sha256:fff55f3ce50a3ff63ec8e2a8d3dd924f1941b250b0aac3d3d42b687eeff07a8e"
+            ],
+            "version": "==2021.11.10"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
+                "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
+            "version": "==2.26.0"
+        },
+        "requests-toolbelt": {
+            "hashes": [
+                "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
+                "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
+            ],
+            "version": "==0.9.1"
+        },
+        "rfc3986": {
+            "hashes": [
+                "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
+                "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
+            ],
+            "version": "==1.5.0"
+        },
+        "six": {
+            "hashes": [
+                "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
+                "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.16.0"
+        },
+        "toml": {
+            "hashes": [
+                "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
+                "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
+            ],
+            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==0.10.2"
+        },
+        "tomli": {
+            "hashes": [
+                "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee",
+                "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==1.2.2"
+        },
+        "tqdm": {
+            "hashes": [
+                "sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c",
+                "sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==4.62.3"
+        },
+        "twine": {
+            "hashes": [
+                "sha256:4caad5ef4722e127b3749052fcbffaaf71719b19d4fd4973b29c469957adeba2",
+                "sha256:916070f8ecbd1985ebed5dbb02b9bda9a092882a96d7069d542d4fc0bb5c673c"
+            ],
+            "index": "pypi",
+            "version": "==3.6.0"
+        },
+        "typing-extensions": {
+            "hashes": [
+                "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed",
+                "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==4.0.0"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece",
+                "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+            "version": "==1.26.7"
+        },
+        "virtualenv": {
+            "hashes": [
+                "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814",
+                "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==20.10.0"
+        },
+        "webencodings": {
+            "hashes": [
+                "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
+                "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
+            ],
+            "version": "==0.5.1"
+        },
+        "zipp": {
+            "hashes": [
+                "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832",
+                "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.6.0"
+        }
+    }
+}
diff --git a/README.md b/README.md
index fdeed09..90af4b1 100644
--- a/README.md
+++ b/README.md
@@ -1,1 +1,157 @@
-# Metabase Python
+# metabase-python
+[![main](https://github.com/chasleslr/metabase-python/actions/workflows/main.yml/badge.svg)](https://github.com/chasleslr/metabase-python/actions/workflows/main.yml)
+[![codecov](https://codecov.io/gh/chasleslr/metabase-python/branch/main/graph/badge.svg?token=15G7HOQ1CM)](https://codecov.io/gh/chasleslr/metabase-python)
+[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
+
+An unofficial Python library for the [Metabase API](https://www.metabase.com/learn/administration/metabase-api).
+
+
+## Installation
+
+```
+pip install metabase-python
+```
+
+## Usage
+This API is still experimental and may change significantly between minor versions.
+
+
+Start by creating an instance of Metabase with your credentials. This connection will automatically be used by any
+object that interacts with the Metabase API.
+```python
+from metabase import Metabase
+
+metabase = Metabase(
+    host="<host>",
+    user="<username/email>",
+    password="<password>",
+)
+```
+
+You can then interact with any of the supported endpoints through the classes included in this package. All changes
+are reflected in Metabase instantly.
+
+```python
+from metabase import User
+
+# get all objects
+users = User.list()
+
+# get an object by ID
+user = User.get(1)
+
+# attributes are automatically loaded and available in the instance
+if user.is_active:
+    print("User is active!")
+
+# update any available attribute
+user.update(is_superuser=True)
+
+# delete an object
+user.delete()
+
+# create an object
+new_user = User.create(
+    first_name="<first_name>",
+    last_name="<last_name>",
+    email="<email>",
+    password="<password>"
+)
+```
+
+The methods `.list()`, `.get()`, `.create()`, `.update()`, `.delete()` are available on all
+endpoints that support them in Metabase API.
+
+Some endpoints also support additional methods:
+
+```python
+from metabase import User
+
+user = User.get(1)
+
+user.reactivate()   # Reactivate user
+user.send_invite()  # Resend the user invite email for a given user.
+```
+
+Here's a slightly more advanced example:
+```python
+from metabase import User, PermissionGroup, PermissionMembership
+
+# create a new PermissionGroup
+my_group = PermissionGroup.create(name="My Group")
+
+for user in User.list():
+    # add all users to my_group
+    PermissionMembership.create(
+        group_id=my_group.id,
+        user_id=user.id
+    )
+```
+
+You can also execute queries and get results back as a Pandas DataFrame. Currently, you need to provide
+the exact MBQL (i.e. Metabase Query Language) as the `query` argument.
+```python
+from metabase import Dataset
+
+dataset = Dataset.create(
+    database=1,
+    type="query",
+    query={
+        "source-table": 1,
+        "aggregation": [["count"]],
+        "breakout": ["field", 7, {"temporal-unit": "year"},],
+    },
+)
+
+df = dataset.to_pandas()
+```
+
+
+## Endpoints
+
+For a full list of endpoints and methods, see [Metabase API](https://www.metabase.com/docs/latest/api-documentation.html).
+
+| Endpoints             | Support    |
+|-----------------------|:----------:|
+| Activity              |  ❌        |
+| Alert                 |  ❌        |
+| Automagic dashboards  |  ❌        |
+| Card                  |  ❌        |
+| Collection            |  ❌        |
+| Card                  |  ❌        |
+| Dashboard             |  ❌        |
+| Database              |  ❌        |
+| Dataset               |  ✅        |
+| Email                 |  ❌        |
+| Embed                 |  ❌        |
+| Field                 |  ✅        |
+| Geojson               |  ❌        |
+| Ldap                  |  ❌        |
+| Login history         |  ❌        |
+| Metric                |  ✅        |
+| Native query snippet  |  ❌        |
+| Notify                |  ❌        |
+| Permissions           |  ❌        |
+| Premium features      |  ❌        |
+| Preview embed         |  ❌        |
+| Public                |  ❌        |
+| Pulse                 |  ❌        |
+| Revision              |  ❌        |
+| Search                |  ❌        |
+| Segment               |  ✅        |
+| Session               |  ❌        |
+| Setting               |  ❌        |
+| Setup                 |  ❌        |
+| Slack                 |  ❌        |
+| Table                 |  ✅        |
+| Task                  |  ❌        |
+| Tiles                 |  ❌        |
+| Transform             |  ❌        |
+| User                  |  ✅        |
+| Util                  |  ❌        |
+
+## Contributing
+Contributions are welcome!
+
+## License
+This library is distributed under the MIT license.
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..64a4d50
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,6 @@
+version: "3.9"
+services:
+  metabase:
+    image: metabase/metabase
+    ports:
+    - 3000:3000
diff --git a/metabase/__init__.py b/metabase/__init__.py
index e69de29..f285c10 100644
--- a/metabase/__init__.py
+++ b/metabase/__init__.py
@@ -0,0 +1,10 @@
+from metabase.metabase import Metabase
+from metabase.resources.database import Database
+from metabase.resources.dataset import Dataset
+from metabase.resources.field import Field
+from metabase.resources.metric import Metric
+from metabase.resources.permission_group import PermissionGroup
+from metabase.resources.permission_membership import PermissionMembership
+from metabase.resources.segment import Segment
+from metabase.resources.table import Table
+from metabase.resources.user import User
diff --git a/metabase/exceptions.py b/metabase/exceptions.py
new file mode 100644
index 0000000..c901a5c
--- /dev/null
+++ b/metabase/exceptions.py
@@ -0,0 +1,2 @@
+class NotFoundError(Exception):
+    pass
diff --git a/metabase/metabase.py b/metabase/metabase.py
new file mode 100644
index 0000000..ed46bf4
--- /dev/null
+++ b/metabase/metabase.py
@@ -0,0 +1,65 @@
+from weakref import WeakValueDictionary
+
+import requests
+
+
+class Singleton(type):
+    _instances = WeakValueDictionary()
+
+    def __call__(cls, *args, **kw):
+        if cls not in cls._instances:
+            instance = super(Singleton, cls).__call__(*args, **kw)
+            cls._instances[cls] = instance
+        return cls._instances[cls]
+
+
+class Metabase(metaclass=Singleton):
+    def __init__(self, host: str, user: str, password: str, token: str = None):
+        self._host = host
+        self.user = user
+        self.password = password
+        self._token = token
+
+    @property
+    def host(self):
+        host = self._host
+
+        if not host.startswith("http"):
+            host = "https://" + self._host
+
+        return host.rstrip("/")
+
+    @host.setter
+    def host(self, value):
+        self._host = value
+
+    @property
+    def token(self):
+        if self._token is None:
+            response = requests.post(
+                self.host + "/api/session",
+                json={"username": self.user, "password": self.password},
+            )
+            self._token = response.json()["id"]
+
+        return self._token
+
+    @token.setter
+    def token(self, value):
+        self._token = value
+
+    @property
+    def headers(self):
+        return {"X-Metabase-Session": self.token}
+
+    def get(self, endpoint: str, **kwargs):
+        return requests.get(self.host + endpoint, headers=self.headers, **kwargs)
+
+    def post(self, endpoint: str, **kwargs):
+        return requests.post(self.host + endpoint, headers=self.headers, **kwargs)
+
+    def put(self, endpoint: str, **kwargs):
+        return requests.put(self.host + endpoint, headers=self.headers, **kwargs)
+
+    def delete(self, endpoint: str, **kwargs):
+        return requests.delete(self.host + endpoint, headers=self.headers, **kwargs)
diff --git a/metabase/missing.py b/metabase/missing.py
new file mode 100644
index 0000000..c6d35f1
--- /dev/null
+++ b/metabase/missing.py
@@ -0,0 +1,2 @@
+class MISSING:
+    pass
diff --git a/metabase/resource.py b/metabase/resource.py
new file mode 100644
index 0000000..3893f21
--- /dev/null
+++ b/metabase/resource.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+from exceptions import NotFoundError
+from requests import HTTPError
+
+from metabase import Metabase
+from metabase.missing import MISSING
+
+
+class Resource:
+    ENDPOINT: str
+    PRIMARY_KEY: str = "id"
+
+    def __init__(self, **kwargs):
+        self._attributes = []
+
+        for k, v in kwargs.items():
+            self._attributes.append(k)
+            setattr(self, k, v)
+
+    def __repr__(self):
+        # move primary key to beginning of the list
+        attributes = self._attributes.copy()
+        if self.PRIMARY_KEY is not None:
+            attributes.insert(0, attributes.pop(attributes.index(self.PRIMARY_KEY)))
+
+        return (
+            self.__class__.__qualname__
+            + "("
+            + ", ".join([f"{attr}={getattr(self, attr)}" for attr in attributes])
+            + ")"
+        )
+
+    @staticmethod
+    def connection() -> Metabase:
+        return Metabase()
+
+
+class ListResource(Resource):
+    @classmethod
+    def list(cls):
+        """List all instances."""
+        response = cls.connection().get(cls.ENDPOINT)
+        records = [cls(**record) for record in response.json()]
+        return records
+
+
+class GetResource(Resource):
+    @classmethod
+    def get(cls, id: int):
+        """Get a single instance by ID."""
+        response = cls.connection().get(cls.ENDPOINT + f"/{id}")
+
+        if response.status_code == 404 or response.status_code == 204:
+            raise NotFoundError(f"{cls.__name__}(id={id}) was not found.")
+
+        return cls(**response.json())
+
+
+class CreateResource(Resource):
+    @classmethod
+    def create(cls, **kwargs):
+        """Create an instance and save it."""
+        response = cls.connection().post(cls.ENDPOINT, json=kwargs)
+
+        if response.status_code not in (200, 202):
+            raise HTTPError(response.content.decode())
+
+        return cls(**response.json())
+
+
+class UpdateResource(Resource):
+    def update(self, **kwargs) -> None:
+        """
+        Update an instance by providing function arguments.
+        Providing any argument with metabase.MISSING will result in this argument being
+        ignored from the request.
+        """
+        params = {k: v for k, v in kwargs.items() if v != MISSING}
+        response = self.connection().put(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}", json=params
+        )
+
+        if response.status_code != 200:
+            raise HTTPError(response.json())
+
+        for k, v in kwargs.items():
+            setattr(self, k, v)
+
+
+class DeleteResource(Resource):
+    def delete(self) -> None:
+        """Delete an instance."""
+        response = self.connection().delete(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}"
+        )
+
+        if response.status_code not in (200, 204):
+            raise HTTPError(response.content.decode())
diff --git a/metabase/resources/__init__.py b/metabase/resources/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/metabase/resources/database.py b/metabase/resources/database.py
new file mode 100644
index 0000000..4c6b7d8
--- /dev/null
+++ b/metabase/resources/database.py
@@ -0,0 +1,203 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+from metabase.missing import MISSING
+from metabase.resource import (
+    CreateResource,
+    DeleteResource,
+    GetResource,
+    ListResource,
+    UpdateResource,
+)
+from metabase.resources.field import Field
+from metabase.resources.table import Table
+
+
+class Database(
+    ListResource, CreateResource, GetResource, UpdateResource, DeleteResource
+):
+    ENDPOINT = "/api/database"
+
+    id: int
+    name: str
+    description: str
+    engine: str
+
+    features: List[Any]
+    details: Dict[str, str]
+    options: str
+    native_permissions: str
+
+    timezone: str
+    metadata_sync_schedule: str
+    cache_field_values_schedule: str
+    cache_ttl: int
+
+    caveats: str
+    refingerprint: str
+    points_of_interest: str
+
+    auto_run_queries: bool
+    is_full_sync: bool
+    is_on_demand: bool
+    is_sample: bool
+
+    updated_at: str
+    created_at: str
+
+    @classmethod
+    def list(cls) -> List[Database]:
+        response = cls.connection().get(cls.ENDPOINT)
+        records = [cls(**db) for db in response.json().get("data", [])]
+        return records
+
+    @classmethod
+    def get(cls, id: int) -> Database:
+        return super(Database, cls).get(id)
+
+    @classmethod
+    def create(
+        cls,
+        name: str,
+        engine: str,
+        details: dict,
+        is_full_sync: bool = None,
+        is_on_demand: bool = None,
+        schedules: dict = None,
+        auto_run_queries: bool = None,
+        cache_ttl: int = None,
+        **kwargs,
+    ) -> Database:
+        """
+        Add a new Database.
+
+        You must be a superuser to do this.
+        """
+        return super(Database, cls).create(
+            name=name,
+            engine=engine,
+            details=details,
+            is_full_sync=is_full_sync,
+            is_on_demand=is_on_demand,
+            schedules=schedules,
+            auto_run_queries=auto_run_queries,
+            cache_ttl=cache_ttl,
+            **kwargs,
+        )
+
+    def update(
+        self,
+        name: str = MISSING,
+        description: str = MISSING,
+        engine: str = MISSING,
+        schedules: dict = MISSING,
+        refingerprint: bool = MISSING,
+        points_of_interest: str = MISSING,
+        auto_run_queries: bool = MISSING,
+        caveats: str = MISSING,
+        is_full_sync: bool = MISSING,
+        cache_ttl: int = MISSING,
+        details: dict = MISSING,
+        is_on_demand: bool = MISSING,
+        **kwargs,
+    ) -> None:
+        """
+        Update a Database.
+
+        You must be a superuser to do this.
+        """
+        return super(Database, self).update(
+            name=name,
+            description=description,
+            engine=engine,
+            schedules=schedules,
+            refingerprint=refingerprint,
+            points_of_interest=points_of_interest,
+            auto_run_queries=auto_run_queries,
+            caveats=caveats,
+            is_full_sync=is_full_sync,
+            cache_ttl=cache_ttl,
+            details=details,
+            is_on_demand=is_on_demand,
+        )
+
+    def delete(self) -> None:
+        """Delete a Database."""
+        return super(Database, self).delete()
+
+    def fields(self) -> List[Field]:
+        """Get a list of all Fields in Database."""
+        fields = (
+            self.connection()
+            .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/fields")
+            .json()
+        )
+        return [Field(**payload) for payload in fields]
+
+    def idfields(self) -> List[Field]:
+        """Get a list of all primary key Fields for Database."""
+        fields = (
+            self.connection()
+            .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/idfields")
+            .json()
+        )
+        return [Field(**payload) for payload in fields]
+
+    def schemas(self) -> List[str]:
+        """Returns a list of all the schemas found for the database id."""
+        return (
+            self.connection()
+            .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/schemas")
+            .json()
+        )
+
+    def tables(self, schema: str) -> List[Table]:
+        """Returns a list of Tables for the given Database id and schema."""
+        tables = (
+            self.connection()
+            .get(
+                self.ENDPOINT
+                + f"/{getattr(self, self.PRIMARY_KEY)}"
+                + "/schema"
+                + f"/{schema}"
+            )
+            .json()
+        )
+        return [Table(**payload) for payload in tables]
+
+    def discard_values(self):
+        """
+        Discards all saved field values for this Database.
+
+        You must be a superuser to do this.
+        """
+        return self.connection().post(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/discard_values"
+        )
+
+    def rescan_values(self):
+        """
+        Trigger a manual scan of the field values for this Database.
+
+        You must be a superuser to do this.
+        """
+        return self.connection().post(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/rescan_values"
+        )
+
+    def sync(self):
+        """Update the metadata for this Database. This happens asynchronously."""
+        return self.connection().post(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/sync"
+        )
+
+    def sync_schema(self):
+        """
+        Trigger a manual update of the schema metadata for this Database.
+
+        You must be a superuser to do this.
+        """
+        return self.connection().post(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/sync_schema"
+        )
diff --git a/metabase/resources/dataset.py b/metabase/resources/dataset.py
new file mode 100644
index 0000000..a25de97
--- /dev/null
+++ b/metabase/resources/dataset.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from typing import Any, List
+
+import pandas as pd
+
+from metabase.resource import CreateResource, Resource
+
+
+class Data(Resource):
+    ENDPOINT = None
+    PRIMARY_KEY = None
+
+    rows: List[List[Any]]
+    cols: dict
+    native_form: dict
+
+    def to_pandas(self):
+        """Returns the query results as a Pandas DataFrame."""
+        columns = [col["display_name"] for col in self.cols]
+        return pd.DataFrame(data=self.rows, columns=columns)
+
+
+class Dataset(CreateResource):
+    ENDPOINT = "/api/dataset"
+    PRIMARY_KEY = None
+
+    context: str
+    status: str
+    database_id: int
+    data: Data
+    row_count: int
+
+    started_at: str
+    running_time: int
+    json_query: str
+    average_execution_time: int = None
+
+    @classmethod
+    def create(cls, database: int, type: str, query: dict, **kwargs) -> Dataset:
+        """Execute a query and retrieve the results in the usual format."""
+        dataset = super(Dataset, cls).create(database=database, type=type, query=query)
+        dataset.data = Data(**dataset.data)
+        return dataset
+
+    def to_pandas(self) -> pd.DataFrame:
+        """Returns the query results as a Pandas DataFrame."""
+        return self.data.to_pandas()
diff --git a/metabase/resources/field.py b/metabase/resources/field.py
new file mode 100644
index 0000000..5831b15
--- /dev/null
+++ b/metabase/resources/field.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any, Dict, List, Optional
+
+from metabase.missing import MISSING
+from metabase.resource import GetResource, UpdateResource
+
+
+class Field(GetResource, UpdateResource):
+    ENDPOINT = "/api/field"
+
+    id: int
+    table_id: int
+    fk_target_id: Optional[int]
+    parent_id: Optional[int]
+
+    name: str
+    display_name: str
+    description: str
+
+    database_type: str
+    semantic_type: Field.SemanticType
+    effective_type: str
+    base_type: str
+
+    dimensions: List[str]
+    dimension_options: List[str]
+    default_dimension_option: Optional[int]
+
+    database_position: int
+    custom_position: int
+    visibility_type: str
+    points_of_interest: str
+    has_field_values: Field.FieldValue
+
+    active: bool
+    preview_display: bool
+
+    target: Optional[Field]
+
+    settings: dict
+    caveats: str
+    coercion_strategy: str
+
+    updated_at: str
+    created_at: str
+
+    class FieldValue(str, Enum):
+        none = "none"
+        auto_list = "auto-list"
+        list = "list"
+        search = "search"
+
+    class SemanticType(str, Enum):
+        primary_key = "Type/PK"
+        foreign_key = "Type/FK"
+
+        avatar_url = "type/AvatarURL"
+        birthdate = "type/Birthdate"
+        cancelation_date = "type/CancelationDate"
+        cancelation_time = "type/CancelationTime"
+        cancelation_timestamp = "type/CancelationTimestamp"
+        category = "type/Category"
+        city = "type/City"
+        comment = "type/Comment"
+        company = "type/Company"
+        cost = "type/Cost"
+        country = "type/Country"
+        creation_date = "type/CreationDate"
+        creation_time = "type/CreationTime"
+        creation_timestamp = "type/CreationTimestamp"
+        currency = "type/Currency"
+        deletion_date = "type/DeletionDate"
+        deletion_time = "type/DeletionTime"
+        deletion_timestamp = "type/DeletionTimestamp"
+        description = "type/Description"
+        discount = "type/Discount"
+        email = "type/Email"
+        enum = "type/Enum"
+        gross_margin = "type/GrossMargin"
+        image_url = "type/ImageURL"
+        income = "type/Income"
+        join_date = "type/JoinDate"
+        join_time = "type/JoinTime"
+        join_timestamp = "type/JoinTimestamp"
+        latitude = "type/Latitude"
+        longitude = "type/Longitude"
+        name = "type/Name"
+        number = "type/Number"
+        owner = "type/Owner"
+        price = "type/Price"
+        product = "type/Product"
+        quantity = "type/Quantity"
+        score = "type/Score"
+        serialized_json = "type/SerializedJSON"
+        share = "type/Share"
+        source = "type/Source"
+        state = "type/State"
+        subscription = "type/Subscription"
+        title = "type/Title"
+        url = "type/URL"
+        user = "type/User"
+        zip_code = "type/ZipCode"
+
+    class VisibilityType(str, Enum):
+        details_only = "details-only"
+        hidden = "hidden"
+        normal = "normal"
+        retired = "retired"
+        sensitive = "sensitive"
+
+    @classmethod
+    def get(cls, id: int) -> Field:
+        """Get Field with ID."""
+        return super(Field, cls).get(id)
+
+    def update(
+        self,
+        display_name: str = MISSING,
+        description: str = MISSING,
+        semantic_type: Field.SemanticType = MISSING,
+        visibility_type: Field.VisibilityType = MISSING,
+        fk_target_field_id: int = MISSING,
+        has_field_values: Field.FieldValue = MISSING,
+        points_of_interest: str = MISSING,
+        settings: str = MISSING,
+        caveats: str = MISSING,
+        coercion_strategy: str = MISSING,
+        **kwargs,
+    ) -> None:
+        """Update Field with ID."""
+        return super(Field, self).update(
+            display_name=display_name,
+            description=description,
+            semantic_type=semantic_type,
+            visibility_type=visibility_type,
+            fk_target_field_id=fk_target_field_id,
+            has_field_values=has_field_values,
+            points_of_interest=points_of_interest,
+            settings=settings,
+            caveats=caveats,
+            coercion_strategy=coercion_strategy,
+        )
+
+    def related(self) -> Dict[str, Any]:
+        """Return related entities."""
+        return (
+            self.connection()
+            .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/related")
+            .json()
+        )
+
+    def discard_values(self):
+        """
+        Discard the FieldValues belonging to this Field. Only applies to fields
+        that have FieldValues. If this Field’s Database is set up to automatically
+        sync FieldValues, they will be recreated during the next cycle.
+
+        You must be a superuser to do this.
+        """
+        return self.connection().post(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/discard_values"
+        )
+
+    def rescan_values(self):
+        """
+        Manually trigger an update for the FieldValues for this Field. Only applies
+        to Fields that are eligible for FieldValues.
+
+        You must be a superuser to do this.
+        """
+        return self.connection().post(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/rescan_values"
+        )
diff --git a/metabase/resources/metric.py b/metabase/resources/metric.py
new file mode 100644
index 0000000..d803b89
--- /dev/null
+++ b/metabase/resources/metric.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import List
+
+from metabase.missing import MISSING
+from metabase.resource import CreateResource, GetResource, ListResource, UpdateResource
+
+
+class Metric(ListResource, CreateResource, GetResource, UpdateResource):
+    ENDPOINT = "/api/metric"
+
+    id: int
+    database_id: int
+    table_id: int
+    creator_id: dict
+
+    name: str
+    description: str
+    definition: dict
+    how_is_this_calculated: str
+    points_of_interest: str
+    caveats: str
+
+    archived: bool
+    show_in_getting_started: bool
+
+    created_at: datetime
+    updated_at: datetime
+
+    creator: dict
+
+    @classmethod
+    def list(cls) -> List[Metric]:
+        return super(Metric, cls).list()
+
+    @classmethod
+    def get(cls, id: int) -> Metric:
+        return super(Metric, cls).get(id)
+
+    @classmethod
+    def create(
+        cls,
+        name: str,
+        table_id: int,
+        definition: dict,
+        description: str = "Created by metabase-python.",
+        **kwargs
+    ) -> Metric:
+        return super(Metric, cls).create(
+            name=name,
+            table_id=table_id,
+            definition=definition,
+            description=description,
+            **kwargs
+        )
+
+    def update(
+        self,
+        revision_message: str = "Updated by metabase-python.",
+        name: str = MISSING,
+        description: str = MISSING,
+        definition: dict = MISSING,
+        how_is_this_calculated: str = MISSING,
+        points_of_interest: str = MISSING,
+        caveats: str = MISSING,
+        archived: bool = MISSING,
+        show_in_getting_started: bool = MISSING,
+        **kwargs
+    ) -> None:
+        return super(Metric, self).update(
+            revision_message=revision_message,
+            name=name,
+            description=description,
+            definition=definition,
+            how_is_this_calculated=how_is_this_calculated,
+            points_of_interest=points_of_interest,
+            caveats=caveats,
+            archived=archived,
+            show_in_getting_started=show_in_getting_started,
+            **kwargs
+        )
+
+    def archive(self):
+        """Archive a Metric."""
+        return self.update(
+            archived=True, revision_message="Archived by metabase-python."
+        )
diff --git a/metabase/resources/permission_group.py b/metabase/resources/permission_group.py
new file mode 100644
index 0000000..de13346
--- /dev/null
+++ b/metabase/resources/permission_group.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from typing import List
+
+from metabase.resource import (
+    CreateResource,
+    DeleteResource,
+    GetResource,
+    ListResource,
+    UpdateResource,
+)
+
+
+class PermissionGroup(
+    ListResource, CreateResource, GetResource, UpdateResource, DeleteResource
+):
+    ENDPOINT = "/api/permissions/group"
+
+    id: int
+    name: str
+    member_count: int
+
+    @classmethod
+    def list(cls) -> List[PermissionGroup]:
+        """
+        Fetch all PermissionsGroups, including a count of the number of :members in that group.
+
+        You must be a superuser to do this.
+        """
+        return super(PermissionGroup, cls).list()
+
+    @classmethod
+    def get(cls, id: int) -> PermissionGroup:
+        """
+        Fetch the details for a certain permissions group.
+
+        You must be a superuser to do this.
+        """
+        return super(PermissionGroup, cls).get(id)
+
+    @classmethod
+    def create(cls, name: str, **kwargs) -> PermissionGroup:
+        """
+        Create a new PermissionsGroup.
+
+        You must be a superuser to do this.
+        """
+        return super(PermissionGroup, cls).create(name=name, **kwargs)
+
+    def update(self, name: str, **kwargs) -> None:
+        """
+        Update the name of a PermissionsGroup.
+
+        You must be a superuser to do this.
+        """
+        return super(PermissionGroup, self).update(name=name, **kwargs)
+
+    def delete(self) -> None:
+        """
+        Delete a specific PermissionsGroup.
+
+        You must be a superuser to do this.
+        """
+        return super(PermissionGroup, self).delete()
diff --git a/metabase/resources/permission_membership.py b/metabase/resources/permission_membership.py
new file mode 100644
index 0000000..58b7ac6
--- /dev/null
+++ b/metabase/resources/permission_membership.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from typing import List
+
+from requests import HTTPError
+
+from metabase.resource import CreateResource, DeleteResource, ListResource
+
+
+class PermissionMembership(ListResource, CreateResource, DeleteResource):
+    ENDPOINT = "/api/permissions/membership"
+    PRIMARY_KEY = "membership_id"
+
+    membership_id: int
+    group_id: int
+    user_id: int
+
+    # TODO: allow for bulk updates through /api/permissions/membership/graph
+
+    @classmethod
+    def list(cls) -> List[PermissionMembership]:
+        """
+        Fetch a map describing the group memberships of various users. This map’s format is:
+
+        {<user-id> [{:membership_id <id>
+                     :group_id      <id>}]}.
+        You must be a superuser to do this.
+        """
+        response = cls.connection().get(cls.ENDPOINT)
+        all_memberships = [
+            item for sublist in response.json().values() for item in sublist
+        ]
+        records = [cls(**record) for record in all_memberships]
+        return records
+
+    @classmethod
+    def create(cls, group_id: int, user_id: int, **kwargs) -> PermissionMembership:
+        """
+        Add a User to a PermissionsGroup. Returns updated list of members belonging to the group.
+
+        You must be a superuser to do this.
+        """
+        response = cls.connection().post(
+            cls.ENDPOINT, json={"group_id": group_id, "user_id": user_id}
+        )
+
+        if response.status_code != 200:
+            raise HTTPError(response.content.decode())
+
+        # metabase returns a list of all memberships for the given group_id
+        membership = next(filter(lambda x: x["user_id"] == user_id, response.json()))
+
+        return cls(**membership)
diff --git a/metabase/resources/segment.py b/metabase/resources/segment.py
new file mode 100644
index 0000000..1cd1da7
--- /dev/null
+++ b/metabase/resources/segment.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from typing import List
+
+from metabase.missing import MISSING
+from metabase.resource import CreateResource, GetResource, ListResource, UpdateResource
+
+
+class Segment(ListResource, CreateResource, GetResource, UpdateResource):
+    ENDPOINT = "/api/segment"
+
+    id: int
+    table_id: int
+    creator_id: int
+
+    name: str
+    description: str
+    definition: dict
+    points_of_interest: str
+    caveats: str
+
+    show_in_getting_started: bool
+    archived: bool
+
+    updated_at: str
+    created_at: str
+
+    @classmethod
+    def list(cls) -> List[Segment]:
+        return super(Segment, cls).list()
+
+    @classmethod
+    def get(cls, id: int) -> Segment:
+        return super(Segment, cls).get(id)
+
+    @classmethod
+    def create(
+        cls,
+        name: str,
+        table_id: int,
+        definition: dict,
+        description: str = "Created by metabase-python.",
+        **kwargs
+    ) -> Segment:
+        return super(Segment, cls).create(
+            name=name,
+            table_id=table_id,
+            definition=definition,
+            description=description,
+            **kwargs
+        )
+
+    def update(
+        self,
+        revision_message: str = "Updated by metabase-python.",
+        name: str = MISSING,
+        description: str = MISSING,
+        definition: dict = MISSING,
+        show_in_getting_started: str = MISSING,
+        points_of_interest: str = MISSING,
+        caveats: str = MISSING,
+        archived: bool = MISSING,
+        **kwargs
+    ) -> None:
+        return super(Segment, self).update(
+            revision_message=revision_message,
+            name=name,
+            description=description,
+            definition=definition,
+            points_of_interest=points_of_interest,
+            caveats=caveats,
+            archived=archived,
+            show_in_getting_started=show_in_getting_started,
+            **kwargs
+        )
+
+    def archive(self):
+        """Archive a Segment."""
+        return self.update(
+            archived=True, revision_message="Archived by metabase-python."
+        )
diff --git a/metabase/resources/table.py b/metabase/resources/table.py
new file mode 100644
index 0000000..0910c69
--- /dev/null
+++ b/metabase/resources/table.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any, Dict, List
+
+from metabase.missing import MISSING
+from metabase.resource import GetResource, ListResource, Resource, UpdateResource
+from metabase.resources.field import Field
+from metabase.resources.metric import Metric
+from metabase.resources.segment import Segment
+
+
+class Dimension(Resource):
+    ENDPOINT = None
+
+    id: str
+    name: str
+    mbql: List[Any]
+    type: str
+
+
+class Table(ListResource, GetResource, UpdateResource):
+    ENDPOINT = "/api/table"
+
+    id: int
+    name: str
+    schema: str
+    db_id: int
+    db: dict
+
+    display_name: str
+    description: str
+
+    entity_name: str
+    entity_type: str
+
+    pk_field: int
+    visibility_type: Table.VisibilityType
+    field_order: Table.FieldOrder
+    points_of_interest: str
+
+    active: bool
+    show_in_getting_started: bool
+
+    created_at: str
+    updated_at: str
+
+    _metrics = None
+    _fields = None
+
+    class VisibilityType(str, Enum):
+        cruft = "cruft"
+        hidden = "hidden"
+        technical = "technical"
+
+    class FieldOrder(str, Enum):
+        alphabetical = "alphabetical"
+        custom = "custom"
+        database = "database"
+        smart = "smart"
+
+    @classmethod
+    def list(cls) -> List[Table]:
+        """Get all Tables."""
+        return super(Table, cls).list()
+
+    @classmethod
+    def get(cls, id: int) -> Table:
+        """Get Table with ID."""
+        return super(Table, cls).get(id)
+
+    def update(
+        self,
+        display_name: str = MISSING,
+        description: str = MISSING,
+        field_order: Table.FieldOrder = MISSING,
+        visibility_type: Table.VisibilityType = MISSING,
+        entity_type: str = MISSING,
+        points_of_interest: str = MISSING,
+        caveats: str = MISSING,
+        show_in_getting_started: bool = MISSING,
+        **kwargs,
+    ) -> None:
+        """Update Table with ID."""
+        return super(Table, self).update(
+            display_name=display_name,
+            description=description,
+            field_order=field_order,
+            visibility_type=visibility_type,
+            entity_type=entity_type,
+            points_of_interest=points_of_interest,
+            caveats=caveats,
+            show_in_getting_started=show_in_getting_started,
+        )
+
+    def fks(self) -> List[dict]:
+        """Get all foreign keys whose destination is a Field that belongs to this Table."""
+        return (
+            self.connection()
+            .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/fks")
+            .json()
+        )
+
+    def query_metadata(self) -> Dict[str, Any]:
+        """
+        Get metadata about a Table useful for running queries. Returns DB, fields,
+        field FKs, and field values.
+
+        Passing include_hidden_fields=true will include any hidden Fields in the response.
+        Defaults to false Passing include_sensitive_fields=true will include any sensitive
+        Fields in the response. Defaults to false.
+
+        These options are provided for use in the Admin Edit Metadata page.
+        """
+        return (
+            self.connection()
+            .get(
+                self.ENDPOINT
+                + f"/{getattr(self, self.PRIMARY_KEY)}"
+                + "/query_metadata"
+            )
+            .json()
+        )
+
+    def related(self) -> Dict[str, Any]:
+        """Return related entities."""
+        return (
+            self.connection()
+            .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/related")
+            .json()
+        )
+
+    def discard_values(self):
+        """
+        Discard the FieldValues belonging to the Fields in this Table. Only applies to
+        fields that have FieldValues. If this Table’s Database is set up to automatically
+        sync FieldValues, they will be recreated during the next cycle.
+
+        You must be a superuser to do this.
+        """
+        self.connection().post(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/discard_values"
+        )
+
+    def rescan_values(self):
+        """
+        Manually trigger an update for the FieldValues for the Fields belonging to this Table.
+        Only applies to Fields that are eligible for FieldValues.
+
+        You must be a superuser to do this.
+        """
+        self.connection().post(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/rescan_values"
+        )
+
+    def fields(self) -> List[Field]:
+        """Get all Fields associated with this Table.."""
+        return [Field(**field) for field in self.query_metadata().get("fields")]
+
+    def dimensions(self) -> List[Dimension]:
+        """Get all Dimensions associated with this Table."""
+        return [
+            Dimension(id=id, **dimension)
+            for id, dimension in self.query_metadata()
+            .get("dimension_options", {})
+            .items()
+        ]
+
+    def metrics(self) -> List[Metric]:
+        """Get all Metrics associated with this Table."""
+        return [Metric(**metric) for metric in self.related().get("metrics")]
+
+    def segments(self) -> List[Segment]:
+        """Get all Segments associated with this Table."""
+        return [Segment(**segment) for segment in self.related().get("segments")]
diff --git a/metabase/resources/user.py b/metabase/resources/user.py
new file mode 100644
index 0000000..2cbacc1
--- /dev/null
+++ b/metabase/resources/user.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Dict, List
+
+from metabase.missing import MISSING
+from metabase.resource import (
+    CreateResource,
+    DeleteResource,
+    GetResource,
+    ListResource,
+    UpdateResource,
+)
+
+
+class User(ListResource, CreateResource, GetResource, UpdateResource, DeleteResource):
+    ENDPOINT = "/api/user"
+
+    id: int
+    email: str
+    first_name: str
+    last_name: str
+    common_name: str
+    locale: str
+
+    is_superuser: bool
+    is_active: bool
+    is_qbnewb: bool
+    ldap_auth: bool
+    google_auth: bool
+
+    login_attributes: Dict[str, Any]
+    group_ids: List[int]
+
+    last_login: datetime
+    date_joined: datetime
+    updated_at: datetime
+
+    @classmethod
+    def list(cls) -> List[User]:
+        """
+        Fetch a list of Users. By default returns every active user but only active users.
+
+        If status is deactivated, include deactivated users only. If status is all, include all users (active and inactive). Also supports include_deactivated, which if true, is equivalent to status=all. status and included_deactivated requires superuser permissions.
+
+        For users with segmented permissions, return only themselves.
+
+        Takes limit, offset for pagination. Takes query for filtering on first name, last name, email. Also takes group_id, which filters on group id.
+        """
+        response = cls.connection().get(cls.ENDPOINT)
+        records = [cls(**user) for user in response.json().get("data", [])]
+        return records
+
+    @classmethod
+    def create(
+        cls,
+        first_name: str,
+        last_name: str,
+        email: str,
+        password: str,
+        group_ids: List[int] = None,
+        login_attributes: Dict[str, Any] = None,
+        **kwargs,
+    ) -> User:
+        """
+        Create a new User, return a 400 if the email address is already taken.
+
+        You must be a superuser to do this.
+        """
+        return super(User, cls).create(
+            first_name=first_name,
+            last_name=last_name,
+            email=email,
+            password=password,
+            group_ids=group_ids,
+            login_attributes=login_attributes,
+            **kwargs,
+        )
+
+    def update(
+        self,
+        first_name: str = MISSING,
+        last_name: str = MISSING,
+        email: str = MISSING,
+        group_ids: List[int] = MISSING,
+        is_superuser: bool = MISSING,
+        locale: str = MISSING,
+        **kwargs,
+    ) -> None:
+        """Update an existing, active User."""
+        return super(User, self).update(
+            first_name=first_name,
+            last_name=last_name,
+            email=email,
+            group_ids=group_ids,
+            locale=locale,
+            **kwargs,
+        )
+
+    def delete(self) -> None:
+        """
+        Disable a User. This does not remove the User from the DB, but instead disables their account.
+
+        You must be a superuser to do this.
+        """
+        return super(User, self).delete()
+
+    def password(self, password: str, old_password: str):
+        """Update a user’s password."""
+        return self.connection().put(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/password",
+            json={"password": password, "old_password": old_password},
+        )
+
+    def send_invite(self):
+        """
+        Resend the user invite email for a given user.
+
+        You must be a superuser to do this.
+        """
+        return self.connection().put(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/send_invite"
+        )
+
+    def reactivate(self):
+        """
+        Reactivate user at :id.
+
+        You must be a superuser to do this.
+        """
+        return self.connection().put(
+            self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/reactivate"
+        )
diff --git a/pyproject.toml b/pyproject.toml
index 374b58c..08f000d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,3 +4,6 @@ requires = [
     "wheel"
 ]
 build-backend = "setuptools.build_meta"
+
+[tool.isort]
+profile = "black"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 48710af..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,24 +0,0 @@
-[metadata]
-name = metabase-python
-version = 0.0.1
-author = Charles Lariviere
-author_email = charleslariviere1@gmail.com
-description = A Python wrapper for interacting with Metabase's API.
-long_description = file: README.md
-long_description_content_type = text/markdown
-url = https://github.com/chasleslr/metabase-python
-project_urls =
-    Bug Tracker = https://github.com/chasleslr/metabase-python
-classifiers =
-    Programming Language :: Python :: 3
-    License :: OSI Approved :: MIT License
-    Operating System :: OS Independent
-
-[options]
-package_dir =
-    = metabase
-packages = find:
-python_requires = >=3.6
-
-[options.packages.find]
-where = metabase
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..fe6fd4a
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,41 @@
+from glob import glob
+from os.path import basename, splitext
+
+from setuptools import find_packages, setup
+
+with open("README.md", "r", encoding="utf-8") as fh:
+    long_description = fh.read()
+
+setup(
+    name="metabase-python",
+    use_scm_version={
+        "local_scheme": "dirty-tag",
+        "write_to": "metabase/_version.py",
+        "fallback_version": "0.0.0",
+    },
+    description="A Python wrapper for interacting with Metabase's API.",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    author="Charles Lariviere",
+    author_email="charleslariviere1@gmail.com  ",
+    url="https://github.com/chasleslr/metabase-python",
+    packages=find_packages("metabase"),
+    package_dir={"": "metabase"},
+    py_modules=[splitext(basename(path))[0] for path in glob("metabase/*.py")],
+    include_package_data=True,
+    zip_safe=False,
+    classifiers=[
+        "Programming Language :: Python :: 3",
+        "License :: OSI Approved :: MIT License",
+        "Operating System :: OS Independent",
+    ],
+    python_requires=">=3.6",
+    install_requires=[
+        "requests",
+        "pandas",
+    ],
+    extras_require={},
+    setup_requires=[
+        "setuptools_scm>=3.3.1",
+    ],
+)


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..231b0a4
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,44 @@
+from unittest import TestCase
+
+import requests
+
+from metabase import Metabase
+
+
+class IntegrationTestCase(TestCase):
+    HOST = "http://0.0.0.0:3000"
+    FIRST_NAME = "test"
+    LAST_NAME = "test"
+    EMAIL = "test@example.com"
+    PASSWORD = "example123"
+    SITE_NAME = "metabase-python"
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.setup_metabase()
+
+    def setUp(self) -> None:
+        self.metabase = Metabase(
+            host=self.HOST, user=self.EMAIL, password=self.PASSWORD
+        )
+
+    @classmethod
+    def setup_metabase(cls):
+        response = requests.get(cls.HOST + "/api/session/properties")
+        token = response.json()["setup-token"]
+
+        if token is not None:
+            response = requests.post(
+                cls.HOST + "/api/setup",
+                json={
+                    "prefs": {"site_name": cls.SITE_NAME},
+                    "user": {
+                        "email": cls.EMAIL,
+                        "password": cls.PASSWORD,
+                        "first_name": cls.FIRST_NAME,
+                        "last_name": cls.LAST_NAME,
+                        "site_name": cls.SITE_NAME,
+                    },
+                    "token": token,
+                },
+            )
diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/test_database.py b/tests/resources/test_database.py
new file mode 100644
index 0000000..201412e
--- /dev/null
+++ b/tests/resources/test_database.py
@@ -0,0 +1,152 @@
+from exceptions import NotFoundError
+
+from metabase import Database
+from metabase.resources.field import Field
+from metabase.resources.table import Table
+from tests.helpers import IntegrationTestCase
+
+
+class DatabaseTests(IntegrationTestCase):
+    def setUp(self) -> None:
+        super(DatabaseTests, self).setUp()
+
+    def test_import(self):
+        """Ensure Database can be imported from Metabase."""
+        from metabase import Database
+
+        self.assertIsNotNone(Database())
+
+    def test_list(self):
+        """Ensure Database.list() returns a list of Database instances."""
+        databases = Database.list()
+
+        self.assertIsInstance(databases, list)
+        self.assertTrue(len(databases) > 0)
+        self.assertTrue(all(isinstance(t, Database) for t in databases))
+
+    def test_get(self):
+        """Ensure Database.get() returns a Database instance for a given ID."""
+        database = Database.get(1)
+
+        self.assertIsInstance(database, Database)
+        self.assertEqual(1, database.id)
+
+    def test_create(self):
+        """Ensure Database.create() creates a Database in Metabase and returns a Database instance."""
+        database = Database.create(
+            name="Test",
+            engine="h2",
+            details={
+                "db": "zip:/app/metabase.jar!/sample-dataset.db;USER=GUEST;PASSWORD=guest"
+            },
+        )
+
+        self.assertIsInstance(database, Database)
+        self.assertEqual("Test", database.name)
+        self.assertEqual("h2", database.engine)
+        self.assertIsInstance(
+            Database.get(database.id), Database
+        )  # instance exists in Metabase
+
+        # teardown
+        database.delete()
+
+    def test_update(self):
+        """Ensure Database.update() updates an existing Database in Metabase."""
+        database = Database.get(1)
+
+        name = database.name
+        database.update(name="New Name")
+
+        # assert local instance is mutated
+        self.assertEqual("New Name", database.name)
+
+        # assert metabase object is mutated
+        t = Database.get(database.id)
+        self.assertEqual("New Name", t.name)
+
+        # teardown
+        t.update(name=name)
+
+    def test_delete(self):
+        """Ensure Database.delete() deletes a Database in Metabase."""
+        # fixture
+        database = Database.create(
+            name="Test",
+            engine="h2",
+            details={
+                "db": "zip:/app/metabase.jar!/sample-dataset.db;USER=GUEST;PASSWORD=guest"
+            },
+        )
+        self.assertIsInstance(database, Database)
+
+        database.delete()
+
+        # assert metabase object is mutated
+        with self.assertRaises(NotFoundError):
+            _ = Database.get(database.id)
+
+    def test_fields(self):
+        """Ensure Database.fields() returns a list of Field instances."""
+        database = Database.get(1)
+        fields = database.fields()
+
+        self.assertIsInstance(fields, list)
+        self.assertTrue(len(fields) > 0)
+        self.assertTrue(all(isinstance(t, Field) for t in fields))
+
+    def test_idfields(self):
+        """Ensure Database.idfields() returns a list of Field instances."""
+        database = Database.get(1)
+        fields = database.idfields()
+
+        self.assertIsInstance(fields, list)
+        self.assertTrue(len(fields) > 0)
+        self.assertTrue(all(isinstance(t, Field) for t in fields))
+
+    def test_schemas(self):
+        """Ensure Database.schemas() returns a list of strings."""
+        database = Database.get(1)
+        schemas = database.schemas()
+
+        self.assertIsInstance(schemas, list)
+        self.assertTrue(len(schemas) > 0)
+        self.assertTrue(all(isinstance(t, str) for t in schemas))
+
+    def test_tables(self):
+        """Ensure Database.tables() returns a list of Table instances."""
+        database = Database.get(1)
+        schema = database.schemas()[0]
+        tables = database.tables(schema)
+
+        self.assertIsInstance(tables, list)
+        self.assertTrue(len(tables) > 0)
+        self.assertTrue(all(isinstance(t, Table) for t in tables))
+
+    def test_discard_values(self):
+        """Ensure Database.discard_values() does not raise an error."""
+        database = Database.get(1)
+        response = database.discard_values()
+
+        self.assertEqual(200, response.status_code)
+
+    def test_rescan_values(self):
+        """Ensure Database.rescan_values() does not raise an error."""
+        database = Database.get(1)
+        response = database.rescan_values()
+
+        self.assertEqual(200, response.status_code)
+
+    def test_sync(self):
+        """Ensure Database.sync() does not raise an error."""
+        database = Database.get(1)
+        response = database.sync()
+
+        self.assertEqual(200, response.status_code)
+
+    def test_sync_schema(self):
+        """Ensure Database.sync_schema() does not raise an error."""
+        database = Database.get(1)
+        response = database.sync_schema()
+
+        self.assertEqual(200, response.status_code)
diff --git a/tests/resources/test_dataset.py b/tests/resources/test_dataset.py
new file mode 100644
index 0000000..232df67
--- /dev/null
+++ b/tests/resources/test_dataset.py
@@ -0,0 +1,41 @@
+import pandas as pd
+
+from metabase.resources.dataset import Data, Dataset
+from tests.helpers import IntegrationTestCase
+
+
+class DatasetTests(IntegrationTestCase):
+    def test_import(self):
+        """Ensure Metric can be imported from Metabase."""
+        from metabase import Dataset
+
+        self.assertIsNotNone(Dataset())
+
+    def test_create(self):
+        """Ensure Dataset.create() executes a query and returns a Dataset instance with the query results."""
+        dataset = Dataset.create(
+            database=1,
+            type="query",
+            query={
+                "source-table": 1,
+                "breakout": [["field", 7, {"temporal-unit": "year"}]],
+                "aggregation": [["count"]],
+            },
+        )
+        self.assertIsInstance(dataset, Dataset)
+        self.assertIsInstance(dataset.data, Data)
+
+    def test_to_pandas(self):
+        """Ensure Dataset.to_pandas() returns a Pandas DataFrame."""
+        dataset = Dataset.create(
+            database=1,
+            type="query",
+            query={
+                "source-table": 1,
+                "breakout": [["field", 7, {"temporal-unit": "year"}]],
+                "aggregation": [["count"]],
+            },
+        )
+        df = dataset.to_pandas()
+        self.assertIsInstance(df, pd.DataFrame)
+        self.assertListEqual(["Created At", "Count"], df.columns.tolist())
diff --git a/tests/resources/test_field.py b/tests/resources/test_field.py
new file mode 100644
index 0000000..f34574e
--- /dev/null
+++ b/tests/resources/test_field.py
@@ -0,0 +1,55 @@
+from metabase.resources.field import Field
+from tests.helpers import IntegrationTestCase
+
+
+class FieldTests(IntegrationTestCase):
+    def setUp(self) -> None:
+        super(FieldTests, self).setUp()
+
+    def test_import(self):
+        """Ensure Field can be imported from Metabase."""
+        from metabase import Field
+
+        self.assertIsNotNone(Field())
+
+    def test_get(self):
+        """Ensure Field.get() returns a Field instance for a given ID."""
+        field = Field.get(1)
+
+        self.assertIsInstance(field, Field)
+        self.assertEqual(1, field.id)
+
+    def test_update(self):
+        """Ensure Field.update() updates an existing Field in Metabase."""
+        field = Field.get(1)
+
+        display_name = field.display_name
+        semantic_type = field.semantic_type
+        field.update(display_name="New Name", semantic_type=Field.SemanticType.zip_code)
+
+        # assert local instance is mutated
+        self.assertEqual("New Name", field.display_name)
+        self.assertEqual(Field.SemanticType.zip_code, field.semantic_type)
+
+        # assert metabase object is mutated
+        f = Field.get(field.id)
+        self.assertEqual("New Name", f.display_name)
+        self.assertEqual(Field.SemanticType.zip_code, f.semantic_type)
+
+        # teardown
+        f.update(display_name=display_name, semantic_type=semantic_type)
+
+    def test_related(self):
+        """Ensure Field.related() returns a dict."""
+        field = Field.get(1)
+        related = field.related()
+
+        self.assertIsInstance(related, dict)
+
+    def test_discard_values(self):
+        # TODO
+        pass
+
+    def test_rescan_values(self):
+        # TODO
+        pass
diff --git a/tests/resources/test_metric.py b/tests/resources/test_metric.py
new file mode 100644
index 0000000..05b2bf7
--- /dev/null
+++ b/tests/resources/test_metric.py
@@ -0,0 +1,122 @@
+from exceptions import NotFoundError
+
+from metabase.resources.metric import Metric
+from tests.helpers import IntegrationTestCase
+
+
+class MetricTests(IntegrationTestCase):
+    def tearDown(self) -> None:
+        metrics = Metric.list()
+        for metric in metrics:
+            metric.archive()
+
+    def test_import(self):
+        """Ensure Metric can be imported from Metabase."""
+        from metabase import Metric
+
+        self.assertIsNotNone(Metric())
+
+    def test_list(self):
+        """Ensure Metric.list returns a list of Metric instances."""
+        # fixture
+        _ = Metric.create(
+            name="My Metric",
+            table_id=1,
+            definition={
+                "aggregation": [["count"]],
+            },
+        )
+        _ = Metric.create(
+            name="My Metric",
+            table_id=1,
+            definition={
+                "aggregation": [["count"]],
+            },
+        )
+
+        metrics = Metric.list()
+
+        self.assertIsInstance(metrics, list)
+        self.assertEqual(2, len(metrics))
+        self.assertTrue(all([isinstance(m, Metric) for m in metrics]))
+
+    def test_get(self):
+        """
+        Ensure Metric.get returns a Metric instance for a given ID, or
+        raises a NotFoundError when it does not exist.
+        """
+        # fixture
+        metric = Metric.create(
+            name="My Metric",
+            table_id=1,
+            definition={
+                "aggregation": [["count"]],
+            },
+        )
+        self.assertIsInstance(metric, Metric)
+
+        m = Metric.get(metric.id)
+        self.assertIsInstance(m, Metric)
+        self.assertEqual(metric.id, m.id)
+
+        with self.assertRaises(NotFoundError):
+            _ = Metric.get(12345)
+
+    def test_create(self):
+        """Ensure Metric.create creates a Metric in Metabase and returns a Metric instance."""
+        metric = Metric.create(
+            name="My Metric",
+            table_id=1,
+            definition={
+                "aggregation": [["count"]],
+            },
+        )
+
+        self.assertIsInstance(metric, Metric)
+        self.assertEqual("My Metric", metric.name)
+        self.assertEqual(1, metric.table_id)
+        self.assertEqual({"aggregation": [["count"]]}, metric.definition)
+
+    def test_update(self):
+        """Ensure Metric.update updates an existing Metric in Metabase."""
+        # fixture
+        metric = Metric.create(
+            name="My Metric",
+            table_id=1,
+            definition={
+                "aggregation": [["count"]],
+            },
+        )
+
+        self.assertIsInstance(metric, Metric)
+        self.assertEqual("My Metric", metric.name)
+
+        metric.update(name="New Name")
+        # assert local instance is mutated
+        self.assertEqual("New Name", metric.name)
+
+        # assert metabase object is mutated
+        m = Metric.get(metric.id)
+        self.assertEqual("New Name", m.name)
+
+    def test_archive(self):
+        """Ensure Metric.archive updates archived=True."""
+        # fixture
+        metric = Metric.create(
+            name="My Metric",
+            table_id=1,
+            definition={
+                "aggregation": [["count"]],
+            },
+        )
+
+        self.assertIsInstance(metric, Metric)
+        self.assertEqual(False, metric.archived)
+
+        metric.archive()
+        # assert local instance is mutated
+        self.assertEqual(True, metric.archived)
+
+        # assert metabase object is mutated
+        m = Metric.get(metric.id)
+        self.assertEqual(True, m.archived)
diff --git a/tests/resources/test_permission_group.py b/tests/resources/test_permission_group.py
new file mode 100644
index 0000000..c21a92a
--- /dev/null
+++ b/tests/resources/test_permission_group.py
@@ -0,0 +1,85 @@
+from exceptions import NotFoundError
+
+from metabase import PermissionGroup, PermissionMembership
+from tests.helpers import IntegrationTestCase
+
+
+class PermissionMembershipTests(IntegrationTestCase):
+    def tearDown(self) -> None:
+        groups = PermissionGroup.list()
+        for group in groups:
+            if group.id not in (1, 2):
+                # can't delete default groups
+                group.delete()
+
+    def test_import(self):
+        """Ensure PermissionGroup can be imported from Metabase."""
+        from metabase import PermissionGroup
+
+        self.assertIsNotNone(PermissionGroup())
+
+    def test_list(self):
+        """Ensure PermissionGroup.list returns a list of PermissionGroup instances."""
+        groups = PermissionGroup.list()
+
+        self.assertIsInstance(groups, list)
+        self.assertEqual(2, len(groups))  # there are 2 default groups in Metabase
+        self.assertTrue(all([isinstance(g, PermissionGroup) for g in groups]))
+
+    def test_get(self):
+        """
+        Ensure PermissionGroup.get returns a PermissionGroup instance for a given ID, or
+        raises a NotFoundError when it does not exist.
+        """
+        # fixture
+        group = PermissionGroup.create(name="My Group")
+        self.assertIsInstance(group, PermissionGroup)
+
+        g = PermissionGroup.get(group.id)
+        self.assertIsInstance(g, PermissionGroup)
+        self.assertEqual(group.id, g.id)
+
+        with self.assertRaises(NotFoundError):
+            _ = PermissionGroup.get(12345)
+
+    def test_create(self):
+        """Ensure PermissionGroup.create creates a Metric in Metabase and returns a PermissionGroup instance."""
+        group = PermissionGroup.create(
+            name="My Group",
+        )
+
+        self.assertIsInstance(group, PermissionGroup)
+        self.assertEqual("My Group", group.name)
+
+    def test_update(self):
+        """Ensure PermissionGroup.update updates an existing PermissionGroup in Metabase."""
+        # fixture
+        group = PermissionGroup.create(
+            name="My Group",
+        )
+
+        self.assertIsInstance(group, PermissionGroup)
+        self.assertEqual("My Group", group.name)
+
+        group.update(name="New Name")
+        # assert local instance is mutated
+        self.assertEqual("New Name", group.name)
+
+        # assert metabase object is mutated
+        m = PermissionGroup.get(group.id)
+        self.assertEqual("New Name", m.name)
+
+    def test_delete(self):
+        """Ensure PermissionGroup.delete deletes a PermissionGroup in Metabase."""
+        # fixture
+        group = PermissionGroup.create(
+            name="My Metric",
+        )
+
+        self.assertIsInstance(group, PermissionGroup)
+
+        group.delete()
+
+        # assert metabase object is mutated
+        with self.assertRaises(NotFoundError):
+            _ = PermissionGroup.get(group.id)
diff --git a/tests/resources/test_permission_membership.py b/tests/resources/test_permission_membership.py
new file mode 100644
index 0000000..35356fb
--- /dev/null
+++ b/tests/resources/test_permission_membership.py
@@ -0,0 +1,58 @@
+from exceptions import NotFoundError
+
+from metabase import PermissionGroup, PermissionMembership
+from tests.helpers import IntegrationTestCase
+
+
+class PermissionMembershipTests(IntegrationTestCase):
+    def tearDown(self) -> None:
+        memberships = PermissionMembership.list()
+        for membership in memberships:
+            if membership.group_id not in (1, 2):
+                # can't delete memberships in the default groups
+                membership.delete()
+
+        groups = PermissionGroup.list()
+        for group in groups:
+            if group.id not in (1, 2):
+                # can't delete default groups
+                group.delete()
+
+    def test_import(self):
+        """Ensure PermissionMembership can be imported from Metabase."""
+        from metabase import PermissionMembership
+
+        self.assertIsNotNone(PermissionMembership())
+
+    def test_list(self):
+        """Ensure PermissionMembership.list returns a list of PermissionMembership instances."""
+        memberships = PermissionMembership.list()
+        self.assertIsInstance(memberships, list)
+        self.assertTrue(len(memberships) > 0)
+        self.assertTrue(all([isinstance(m, PermissionMembership) for m in memberships]))
+
+    def test_create(self):
+        """Ensure PermissionMembership.create creates a Metric in Metabase and returns a PermissionMembership instance."""
+        group = PermissionGroup.create(name="My Group")
+        membership = PermissionMembership.create(group_id=group.id, user_id=1)
+
+        self.assertIsInstance(membership, PermissionMembership)
+        self.assertEqual(1, membership.user_id)
+
+    def test_delete(self):
+        """Ensure PermissionMembership.delete deletes a PermissionMembership in Metabase."""
+        # fixture
+        group = PermissionGroup.create(name="My Group")
+        membership = PermissionMembership.create(group_id=group.id, user_id=1)
+        self.assertIsInstance(membership, PermissionMembership)
+        self.assertTrue(
+            membership.membership_id
+            in [m.membership_id for m in PermissionMembership.list()]
+        )
+
+        membership.delete()
+
+        self.assertFalse(
+            membership.membership_id
+            in [m.membership_id for m in PermissionMembership.list()]
+        )
diff --git a/tests/resources/test_segment.py b/tests/resources/test_segment.py
new file mode 100644
index 0000000..8b6ac01
--- /dev/null
+++ b/tests/resources/test_segment.py
@@ -0,0 +1,122 @@
+from exceptions import NotFoundError
+
+from metabase.resources.segment import Segment
+from tests.helpers import IntegrationTestCase
+
+
+class SegmentTests(IntegrationTestCase):
+    def tearDown(self) -> None:
+        segments = Segment.list()
+        for segment in segments:
+            segment.archive()
+
+    def test_import(self):
+        """Ensure Segment can be imported from Metabase."""
+        from metabase import Segment
+
+        self.assertIsNotNone(Segment())
+
+    def test_list(self):
+        """Ensure Segment.list returns a list of Segment instances."""
+        # fixture
+        _ = Segment.create(
+            name="My Segment",
+            table_id=1,
+            definition={
+                "filter": ["=", ["field", 1, None], 0],
+            },
+        )
+        _ = Segment.create(
+            name="My Segment",
+            table_id=1,
+            definition={
+                "filter": ["=", ["field", 1, None], 0],
+            },
+        )
+
+        segments = Segment.list()
+
+        self.assertIsInstance(segments, list)
+        self.assertEqual(2, len(segments))
+        self.assertTrue(all([isinstance(m, Segment) for m in segments]))
+
+    def test_get(self):
+        """
+        Ensure Segment.get returns a Segment instance for a given ID, or
+        raises a NotFoundError when it does not exist.
+        """
+        # fixture
+        segment = Segment.create(
+            name="My Segment",
+            table_id=1,
+            definition={
+                "filter": ["=", ["field", 1, None], 0],
+            },
+        )
+        self.assertIsInstance(segment, Segment)
+
+        m = Segment.get(segment.id)
+        self.assertIsInstance(m, Segment)
+        self.assertEqual(segment.id, m.id)
+
+        with self.assertRaises(NotFoundError):
+            _ = Segment.get(12345)
+
+    def test_create(self):
+        """Ensure Segment.create creates a Segment in Metabase and returns a Segment instance."""
+        segment = Segment.create(
+            name="My Segment",
+            table_id=1,
+            definition={
+                "filter": ["=", ["field", 1, None], 0],
+            },
+        )
+
+        self.assertIsInstance(segment, Segment)
+        self.assertEqual("My Segment", segment.name)
+        self.assertEqual(1, segment.table_id)
+        self.assertEqual({"filter": ["=", ["field", 1, None], 0]}, segment.definition)
+
+    def test_update(self):
+        """Ensure Segment.update updates an existing Segment in Metabase."""
+        # fixture
+        segment = Segment.create(
+            name="My Segment",
+            table_id=1,
+            definition={
+                "filter": ["=", ["field", 1, None], 0],
+            },
+        )
+
+        self.assertIsInstance(segment, Segment)
+        self.assertEqual("My Segment", segment.name)
+
+        segment.update(name="New Name")
+        # assert local instance is mutated
+        self.assertEqual("New Name", segment.name)
+
+        # assert metabase object is mutated
+        m = Segment.get(segment.id)
+        self.assertEqual("New Name", m.name)
+
+    def test_archive(self):
+        """Ensure Segment.archive updates archived=True."""
+        # fixture
+        segment = Segment.create(
+            name="My Segment",
+            table_id=1,
+            definition={
+                "filter": ["=", ["field", 1, None], 0],
+            },
+        )
+
+        self.assertIsInstance(segment, Segment)
+        self.assertEqual(False, segment.archived)
+
+        segment.archive()
+        # assert local instance is mutated
+        self.assertEqual(True, segment.archived)
+
+        # assert metabase object is mutated
+        m = Segment.get(segment.id)
+        self.assertEqual(True, m.archived)
diff --git a/tests/resources/test_table.py b/tests/resources/test_table.py
new file mode 100644
index 0000000..674cf3e
--- /dev/null
+++ b/tests/resources/test_table.py
@@ -0,0 +1,148 @@
+from resources.segment import Segment
+
+from metabase.resources.field import Field
+from metabase.resources.metric import Metric
+from metabase.resources.table import Dimension, Table
+from tests.helpers import IntegrationTestCase
+
+
+class TableTests(IntegrationTestCase):
+    def setUp(self) -> None:
+        super(TableTests, self).setUp()
+
+    def test_import(self):
+        """Ensure Table can be imported from Metabase."""
+        from metabase import Table
+
+        self.assertIsNotNone(Table())
+
+    def test_list(self):
+        """Ensure Table.list() returns a list of Table instances."""
+        tables = Table.list()
+
+        self.assertIsInstance(tables, list)
+        self.assertTrue(len(tables) > 0)
+        self.assertTrue(all(isinstance(t, Table) for t in tables))
+
+    def test_get(self):
+        """Ensure Table.get() returns a Table instance for a given ID."""
+        table = Table.get(1)
+
+        self.assertIsInstance(table, Table)
+        self.assertEqual(1, table.id)
+
+    def test_update(self):
+        """Ensure Table.update() updates an existing Table in Metabase."""
+        table = Table.get(1)
+
+        display_name = table.display_name
+        table.update(display_name="New Name")
+
+        # assert local instance is mutated
+        self.assertEqual("New Name", table.display_name)
+
+        # assert metabase object is mutated
+        t = Table.get(table.id)
+        self.assertEqual("New Name", t.display_name)
+
+        # teardown
+        t.update(display_name=display_name)
+
+    def test_foreign_keys(self):
+        """Ensure Table.fks() returns a list of foreign keys as dict."""
+        table = Table.get(1)
+        fks = table.fks()
+
+        self.assertIsInstance(fks, list)
+        self.assertTrue(len(fks) > 0)
+        self.assertTrue(all(isinstance(fk, dict) for fk in fks))
+
+    def test_query_metadata(self):
+        """Ensure Table.query_metadata() returns a dict."""
+        table = Table.get(1)
+        query_metadata = table.query_metadata()
+
+        self.assertIsInstance(query_metadata, dict)
+
+    def test_related(self):
+        """Ensure Table.related() returns a dict."""
+        table = Table.get(1)
+        related = table.related()
+
+        self.assertIsInstance(related, dict)
+
+    def test_discard_values(self):
+        # TODO
+        pass
+
+    def test_rescan_values(self):
+        # TODO
+        pass
+
+    def test_fields(self):
+        """Ensure Table.fields() returns a list of Field instances."""
+        table = Table.get(1)
+        fields = table.fields()
+
+        self.assertIsInstance(fields, list)
+        self.assertTrue(len(fields) > 0)
+        self.assertTrue(all(isinstance(field, Field) for field in fields))
+
+    def test_dimensions(self):
+        """Ensure Table.dimensions() returns a list of Dimension instances."""
+        table = Table.get(1)
+        dimensions = table.dimensions()
+
+        self.assertIsInstance(dimensions, list)
+        self.assertTrue(len(dimensions) > 0)
+        self.assertTrue(all(isinstance(field, Dimension) for field in dimensions))
+
+    def test_metrics(self):
+        """Ensure Table.metrics() returns a list of Metric instances."""
+        table = Table.get(1)
+        metrics = table.metrics()
+
+        self.assertIsInstance(metrics, list)
+        self.assertEqual(0, len(metrics))
+
+        # fixture
+        metric = Metric.create(
+            name="Products",
+            table_id=1,
+            definition={
+                "aggregation": [["count"]],
+            },
+        )
+
+        metrics = table.metrics()
+        self.assertIsInstance(metrics, list)
+        self.assertEqual(1, len(metrics))
+        self.assertEqual(metric.id, metrics[0].id)
+
+        # teardown
+        metric.archive()
+
+    def test_segments(self):
+        """Ensure Table.segments() returns a list of Segment instances."""
+        table = Table.get(1)
+        segments = table.segments()
+
+        self.assertIsInstance(segments, list)
+        self.assertEqual(0, len(segments))
+
+        # fixture
+        segment = Segment.create(
+            name="Free",
+            table_id=1,
+            definition={
+                "filter": ["=", ["field", 1, None], 0],
+            },
+        )
+
+        segments = table.segments()
+        self.assertIsInstance(segments, list)
+        self.assertEqual(1, len(segments))
+        self.assertEqual(segment.id, segments[0].id)
+
+        # teardown
+        segment.archive()
diff --git a/tests/resources/test_user.py b/tests/resources/test_user.py
new file mode 100644
index 0000000..8fe3bee
--- /dev/null
+++ b/tests/resources/test_user.py
@@ -0,0 +1,91 @@
+from random import randint
+
+from exceptions import NotFoundError
+
+from metabase.resources.user import User
+from tests.helpers import IntegrationTestCase
+
+
+class UserTests(IntegrationTestCase):
+    def tearDown(self) -> None:
+        users = User.list()
+        for user in users:
+            if user.id != 1:
+                user.delete()
+
+    def test_import(self):
+        """Ensure User can be imported from Metabase."""
+        from metabase import User
+
+        self.assertIsNotNone(User())
+
+    def test_get(self):
+        """
+        Ensure User.get() returns a User instance for a given ID, or
+        raises a NotFoundError when it does not exist.
+        """
+        # fixture
+        user = User.create(
+            first_name="Test",
+            last_name="Test",
+            email=f"{randint(2, 10000)}@example.com",
+            password="example123",
+        )
+        self.assertIsInstance(user, User)
+
+        u = User.get(user.id)
+        self.assertIsInstance(u, User)
+        self.assertEqual(user.id, u.id)
+
+        with self.assertRaises(NotFoundError):
+            _ = User.get(12345)
+
+    def test_create(self):
+        """Ensure User.create() creates a User in Metabase and returns a User instance."""
+        email = f"{randint(2, 10000)}@example.com"
+        user = User.create(
+            first_name="Test", last_name="Test", email=email, password="example123"
+        )
+
+        self.assertIsInstance(user, User)
+        self.assertEqual("Test", user.first_name)
+        self.assertEqual("Test", user.last_name)
+        self.assertEqual(email, user.email)
+
+    def test_update(self):
+        """Ensure User.update() updates an existing User in Metabase."""
+        # fixture
+        user = User.create(
+            first_name="Test",
+            last_name="Test",
+            email=f"{randint(2, 10000)}@example.com",
+            password="example123",
+        )
+
+        self.assertIsInstance(user, User)
+        self.assertEqual("Test", user.first_name)
+
+        user.update(first_name="Test1")
+        # assert local instance is mutated
+        self.assertEqual("Test1", user.first_name)
+
+        # assert metabase object is mutated
+        u = User.get(user.id)
+        self.assertEqual("Test1", u.first_name)
+
+    def test_delete(self):
+        """Ensure User.delete() updates archived=True."""
+        # fixture
+        user = User.create(
+            first_name="Test",
+            last_name="Test",
+            email=f"{randint(2, 10000)}@example.com",
+            password="example123",
+        )
+        self.assertIsInstance(user, User)
+
+        user.delete()
+
+        # assert metabase object is mutated
+        with self.assertRaises(NotFoundError):
+            _ = User.get(user.id)
diff --git a/tests/test_metabase.py b/tests/test_metabase.py
new file mode 100644
index 0000000..b9a7cfc
--- /dev/null
+++ b/tests/test_metabase.py
@@ -0,0 +1,62 @@
+from unittest import TestCase
+
+from metabase import Metabase
+
+from tests.helpers import IntegrationTestCase
+
+
+class MetabaseTests(TestCase):
+    def test_singleton(self):
+        """Ensure Metabase acts as a singleton; the same instance is always returned when instantiated."""
+        metabase = Metabase(host="", user="", password="")
+        metabase1 = Metabase()
+
+        self.assertEqual(metabase, metabase1)
+        self.assertEqual(metabase.host, metabase1.host)
+        self.assertEqual(Metabase(), Metabase())
+
+    def test_host(self):
+        """Ensure Metabase.host adds https:// and trims trailing /."""
+        metabase = Metabase(host="example.com/", user="", password="")
+        self.assertEqual(metabase.host, "https://example.com")
+
+        del metabase
+
+        metabase = Metabase(host="http://example.com/", user="", password="")
+        self.assertEqual(metabase.host, "http://example.com")
+
+    def test_token(self):
+        """Ensure Metabase.token returns Metabase._token if not None, else gets a new token."""
+        metabase = Metabase(host="example.co.", user="", password="", token="123")
+        self.assertEqual(metabase.token, "123")
+
+        # TODO: add test case when token is None
+
+    def test_headers(self):
+        """Ensure Metabase.headers returns a dictionary with the token."""
+        metabase = Metabase(host="example.com", user="", password="", token="123")
+        self.assertDictEqual(metabase.headers, {"X-Metabase-Session": "123"})
+
+    def test_get(self):
+        # TODO
+        pass
+
+    def test_post(self):
+        # TODO
+        pass
+
+    def test_put(self):
+        # TODO
+        pass
+
+    def test_delete(self):
+        # TODO
+        pass
+
+
+class MetabaseIntegrationTests(IntegrationTestCase):
+    def test_can_connect(self):
+        """Ensure Metabase test instance is setup and can be connected to."""
+        response = self.metabase.get("/api/user/current")
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(self.EMAIL, response.json().get("email"))
diff --git a/tests/test_resource.py b/tests/test_resource.py
new file mode 100644
index 0000000..1ef7d4c
--- /dev/null
+++ b/tests/test_resource.py
@@ -0,0 +1,150 @@
+from unittest.mock import patch
+
+from exceptions import NotFoundError
+from requests import HTTPError
+
+from metabase.metabase import Metabase
+from metabase.missing import MISSING
+from metabase.resource import (
+    CreateResource,
+    DeleteResource,
+    GetResource,
+    ListResource,
+    Resource,
+    UpdateResource,
+)
+from tests.helpers import IntegrationTestCase
+
+
+class ResourceTests(IntegrationTestCase):
+    def test_resource_initializes_all_attributes(self):
+        """Ensure Resource accepts arbitrary attributes when initializing an instance."""
+        resource = Resource(a="a", b="b")
+
+        self.assertEqual("a", resource.a)
+        self.assertEqual("b", resource.b)
+        self.assertListEqual(["a", "b"], resource._attributes)
+
+        # default class attributes
+        self.assertEqual(None, getattr(resource, "ENDPOINT", None))
+        self.assertEqual("id", resource.PRIMARY_KEY)
+
+    def test_repr(self):
+        """Ensure Resource repr prints all class attributes with the PRIMARY_KEY first, if any."""
+        resource = Resource(a="a", b="b", id=1)
+        self.assertEqual("Resource(id=1, a=a, b=b)", resource.__repr__())
+
+        resource.PRIMARY_KEY = None
+        self.assertEqual("Resource(a=a, b=b, id=1)", resource.__repr__())
+
+    def test_connection(self):
+        """Ensure Resource.connection() returns an instance of Metabase."""
+        resource = Resource()
+        self.assertIsInstance(resource.connection(), Metabase)
+
+
+class ListResourceTests(IntegrationTestCase):
+    def test_list(self):
+        """Ensure ListResource.list() returns a list of objects from Metabase, if any."""
+
+        class Setting(ListResource):
+            ENDPOINT = "/api/setting"
+            PRIMARY_KEY = None
+
+        settings = Setting.list()
+        self.assertIsInstance(settings, list)
+        self.assertTrue(all([isinstance(s, Setting) for s in settings]))
+
+
+class GetResourceTests(IntegrationTestCase):
+    def test_get(self):
+        """Ensure GetResource.get() returns an instance of an object, by ID, if any."""
+
+        class User(GetResource):
+            ENDPOINT = "/api/user"
+
+        user = User.get(1)
+        self.assertIsInstance(user, User)
+
+    def test_get_404(self):
+        """Ensure GetResource.get() raises NotFoundError if the object does not exist."""
+
+        class User(GetResource):
+            ENDPOINT = "/api/user"
+
+        with self.assertRaises(NotFoundError):
+            user = User.get(1234)
+
+
+class CreateResourceTests(IntegrationTestCase):
+    def test_create(self):
+        """Ensure CreateResource.create() creates an object in Metabase and returns an instance."""
+
+        class Collection(CreateResource, GetResource):
+            ENDPOINT = "/api/collection"
+
+        collection = Collection.create(name="My Collection", color="#123456")
+        self.assertIsInstance(collection, Collection)
+        self.assertIsNotNone(Collection.get(collection.id))  # metabase was updated
+
+
+class UpdateResourceTests(IntegrationTestCase):
+    def test_update(self):
+        """Ensure UpdateResource.update() updates an existing object in Metabase."""
+
+        class Collection(CreateResource, GetResource, UpdateResource):
+            ENDPOINT = "/api/collection"
+
+        # fixture
+        collection = Collection.create(name="My Collection", color="#123456")
+        self.assertIsInstance(collection, Collection)
+        self.assertIsNotNone(Collection.get(collection.id))
+
+        collection.update(name="My New Collection")
+        self.assertEqual("My New Collection", collection.name)
+
+        # metabase was updated
+        self.assertEqual("My New Collection", Collection.get(collection.id).name)
+
+    def test_update_missing(self):
+        """Ensure UpdateResource.update() ignores arguments equal to MISSING."""
+
+        class Collection(UpdateResource):
+            ENDPOINT = "/api/collection"
+
+        test_matrix = [
+            ({"a": "A", "b": "B"}, {"a": "A", "b": "B"}),
+            ({"a": "A", "b": MISSING}, {"a": "A"}),
+            ({"a": MISSING, "b": MISSING}, {}),
+        ]
+
+        for kwargs, expected in test_matrix:
+            with patch("metabase.resource.Metabase.put") as mock:
+                try:
+                    Collection(id=1).update(**kwargs)
+                except HTTPError:
+                    pass
+
+                self.assertTrue(mock.called)
+                self.assertIsNone(
+                    mock.assert_called_with("/api/collection/1", json=expected)
+                )
+
+
+class DeleteResourceTests(IntegrationTestCase):
+    def test_delete(self):
+        """Ensure DeleteResource.delete() deletes an existing object in Metabase."""
+
+        class Group(CreateResource, GetResource, DeleteResource):
+            ENDPOINT = "/api/permissions/group"
+            PRIMARY_KEY = "id"
+
+        group = Group.create(name="My Group 4")
+
+        self.assertIsNotNone(group)
+        self.assertIsNotNone(Group.get(group.id))
+
+        group.delete()
+
+        with self.assertRaises(NotFoundError):
+            Group.get(group.id)