Repository: doccano/doccano-client (language: python)

Issue:
Integrating Our Version of doccano-client?
Feature description
---------
Hey y'all,

First of all I just wanna say: Great job on this client!

Our company actually has been working on and maintaining a `doccano-client` as well around the time progress on this client started. It's currently internal, but we could easily open source it--hardly any of it is opinionated to our company's use cases.

The problem is, we don't have the hands at the moment to consistently maintain the package and keep it up to date with all the updates Doccano receives. It looks like this package has been able to, however, thanks to the open source visibility!

An idea: What are ya'll's thoughts on uniting the two? We can move our code to a fork of this repo, and create a major PR that integrates it right into this package if you like the results!

We believe our version of the client has a lot of features that this one can greatly benefit from:

1. Versioning lines up with Doccano release tags, so it is always clear when it will be compatible with which release of Doccano's API.
2. All interactions with the API are pythonic, rather than based on raw json. Example below.
3. VERY heavily unit tested.
4. Black, Isort, Flake8, Pydocstyle, Pylint, and Mypy compliant

```python
project_controllers = client.projects.all()

my_project_name_controller = next(controller for controller in project_controllers if controller.project.name == "My Project Name")

my_project_name_controller.labels.update([Label(...), Label(...)])

test_example = models.Example(text="This is an example text")
my_project_name_controller.examples.create(test_example)

```

Would you be interested in this style? There are a few more layers so may take a bit more code to maintain in some ways, but it should cut a lot of tech debt, making it easier to scale. If you're interested, we can port our code to a fork of this repo so you can take a look.

Thank you for your time!


Discussion / hints:

Fix patch:
diff --git a/Pipfile b/Pipfile
index c0c8266..ac88056 100644
--- a/Pipfile
+++ b/Pipfile
@@ -9,6 +9,10 @@ isort = "*"
 mypy = "*"
 darglint = "*"
 black = "*"
+responses = "*"
+dataclasses-json = "*"
+types-requests = "*"
+pytest = "*"
 
 [packages]
 requests = "*"
@@ -21,3 +25,4 @@ isort = "isort . -c"
 flake8 = "flake8 doccano_api_client"
 darglint = "darglint"
 black = "black --check ."
+test = "python -m pytest"
diff --git a/Pipfile.lock b/Pipfile.lock
index cc0c44d..120ea24 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "e870665975c24b058405d772492ed7fedde725b99487c85afff033f90c13bca7"
+            "sha256": "40646fcd17193c9406cc5536d7f2270fbefcb56cfa231a6751002059c9f5775e"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -18,91 +18,166 @@
     "default": {
         "certifi": {
             "hashes": [
-                "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee",
-                "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"
+                "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
+                "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
             ],
-            "version": "==2021.5.30"
+            "version": "==2021.10.8"
         },
-        "chardet": {
+        "charset-normalizer": {
             "hashes": [
-                "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
-                "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
+                "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
+                "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
             ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
-            "version": "==4.0.0"
+            "markers": "python_version >= '3'",
+            "version": "==2.0.12"
         },
         "idna": {
             "hashes": [
-                "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
-                "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+                "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
+                "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
             ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==2.10"
+            "markers": "python_version >= '3'",
+            "version": "==3.3"
         },
         "requests": {
             "hashes": [
-                "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
-                "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
+                "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
+                "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
             ],
             "index": "pypi",
-            "version": "==2.25.1"
+            "version": "==2.27.1"
         },
         "urllib3": {
             "hashes": [
-                "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4",
-                "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"
+                "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14",
+                "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"
             ],
             "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.6"
+            "version": "==1.26.9"
         }
     },
     "develop": {
-        "appdirs": {
+        "attrs": {
             "hashes": [
-                "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
-                "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
+                "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4",
+                "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"
             ],
-            "version": "==1.4.4"
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==21.4.0"
         },
         "black": {
             "hashes": [
-                "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04",
-                "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7"
+                "sha256:07e5c049442d7ca1a2fc273c79d1aecbbf1bc858f62e8184abe1ad175c4f7cc2",
+                "sha256:0e21e1f1efa65a50e3960edd068b6ae6d64ad6235bd8bfea116a03b21836af71",
+                "sha256:1297c63b9e1b96a3d0da2d85d11cd9bf8664251fd69ddac068b98dc4f34f73b6",
+                "sha256:228b5ae2c8e3d6227e4bde5920d2fc66cc3400fde7bcc74f480cb07ef0b570d5",
+                "sha256:2d6f331c02f0f40aa51a22e479c8209d37fcd520c77721c034517d44eecf5912",
+                "sha256:2ff96450d3ad9ea499fc4c60e425a1439c2120cbbc1ab959ff20f7c76ec7e866",
+                "sha256:3524739d76b6b3ed1132422bf9d82123cd1705086723bc3e235ca39fd21c667d",
+                "sha256:35944b7100af4a985abfcaa860b06af15590deb1f392f06c8683b4381e8eeaf0",
+                "sha256:373922fc66676133ddc3e754e4509196a8c392fec3f5ca4486673e685a421321",
+                "sha256:5fa1db02410b1924b6749c245ab38d30621564e658297484952f3d8a39fce7e8",
+                "sha256:6f2f01381f91c1efb1451998bd65a129b3ed6f64f79663a55fe0e9b74a5f81fd",
+                "sha256:742ce9af3086e5bd07e58c8feb09dbb2b047b7f566eb5f5bc63fd455814979f3",
+                "sha256:7835fee5238fc0a0baf6c9268fb816b5f5cd9b8793423a75e8cd663c48d073ba",
+                "sha256:8871fcb4b447206904932b54b567923e5be802b9b19b744fdff092bd2f3118d0",
+                "sha256:a7c0192d35635f6fc1174be575cb7915e92e5dd629ee79fdaf0dcfa41a80afb5",
+                "sha256:b1a5ed73ab4c482208d20434f700d514f66ffe2840f63a6252ecc43a9bc77e8a",
+                "sha256:c8226f50b8c34a14608b848dc23a46e5d08397d009446353dad45e04af0c8e28",
+                "sha256:ccad888050f5393f0d6029deea2a33e5ae371fd182a697313bdbd835d3edaf9c",
+                "sha256:dae63f2dbf82882fa3b2a3c49c32bffe144970a573cd68d247af6560fc493ae1",
+                "sha256:e2f69158a7d120fd641d1fa9a921d898e20d52e44a74a6fbbcc570a62a6bc8ab",
+                "sha256:efbadd9b52c060a8fc3b9658744091cb33c31f830b3f074422ed27bad2b18e8f",
+                "sha256:f5660feab44c2e3cb24b2419b998846cbb01c23c7fe645fee45087efa3da2d61",
+                "sha256:fdb8754b453fb15fad3f72cd9cad3e16776f0964d67cf30ebcbf10327a3777a3"
             ],
             "index": "pypi",
-            "version": "==21.6b0"
+            "version": "==22.1.0"
+        },
+        "certifi": {
+            "hashes": [
+                "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
+                "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
+            ],
+            "version": "==2021.10.8"
+        },
+        "charset-normalizer": {
+            "hashes": [
+                "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
+                "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==2.0.12"
         },
         "click": {
             "hashes": [
-                "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
-                "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"
+                "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1",
+                "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"
             ],
             "markers": "python_version >= '3.6'",
-            "version": "==8.0.1"
+            "version": "==8.0.4"
         },
         "darglint": {
             "hashes": [
-                "sha256:aa605ef47817a6d14797d32b390466edab621768ea4ca5cc0f3c54f6d8dcaec8",
-                "sha256:ac6797bcc918cd8d8f14c168a4a364f54e1aeb4ced59db58e7e4c6dfec2fe15c"
+                "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da",
+                "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"
             ],
             "index": "pypi",
-            "version": "==1.8.0"
+            "version": "==1.8.1"
+        },
+        "dataclasses-json": {
+            "hashes": [
+                "sha256:1d7f3a284a49d350ddbabde0e7d0c5ffa34a144aaf1bcb5b9f2c87673ff0c76e",
+                "sha256:1f60be3405dee30b86ffbf6a436db8ba5efaeeb676bfda358e516a97aa7dfce4"
+            ],
+            "index": "pypi",
+            "version": "==0.5.6"
         },
         "flake8": {
             "hashes": [
-                "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b",
-                "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"
+                "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d",
+                "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"
             ],
             "index": "pypi",
-            "version": "==3.9.2"
+            "version": "==4.0.1"
+        },
+        "idna": {
+            "hashes": [
+                "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
+                "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
+            ],
+            "markers": "python_version >= '3'",
+            "version": "==3.3"
+        },
+        "iniconfig": {
+            "hashes": [
+                "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
+                "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
+            ],
+            "version": "==1.1.1"
         },
         "isort": {
             "hashes": [
-                "sha256:83510593e07e433b77bd5bff0f6f607dbafa06d1a89022616f02d8b699cfcd56",
-                "sha256:8e2c107091cfec7286bc0f68a547d0ba4c094d460b732075b6fba674f1035c0c"
+                "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7",
+                "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"
             ],
             "index": "pypi",
-            "version": "==5.9.1"
+            "version": "==5.10.1"
+        },
+        "marshmallow": {
+            "hashes": [
+                "sha256:2aaaab4f01ef4f5a011a21319af9fce17ab13bf28a026d1252adab0e035648d5",
+                "sha256:ff79885ed43b579782f48c251d262e062bce49c65c52412458769a4fb57ac30f"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==3.15.0"
+        },
+        "marshmallow-enum": {
+            "hashes": [
+                "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58",
+                "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"
+            ],
+            "version": "==1.5.1"
         },
         "mccabe": {
             "hashes": [
@@ -113,32 +188,32 @@
         },
         "mypy": {
             "hashes": [
-                "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9",
-                "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a",
-                "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9",
-                "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e",
-                "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2",
-                "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212",
-                "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b",
-                "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885",
-                "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150",
-                "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703",
-                "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072",
-                "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457",
-                "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e",
-                "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0",
-                "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb",
-                "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97",
-                "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8",
-                "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811",
-                "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6",
-                "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de",
-                "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504",
-                "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921",
-                "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"
+                "sha256:080097eee5393fd740f32c63f9343580aaa0fb1cda0128fd859dfcf081321c3d",
+                "sha256:0d3bcbe146247997e03bf030122000998b076b3ac6925b0b6563f46d1ce39b50",
+                "sha256:0dd441fbacf48e19dc0c5c42fafa72b8e1a0ba0a39309c1af9c84b9397d9b15a",
+                "sha256:108f3c7e14a038cf097d2444fa0155462362c6316e3ecb2d70f6dd99cd36084d",
+                "sha256:3bada0cf7b6965627954b3a128903a87cac79a79ccd83b6104912e723ef16c7b",
+                "sha256:3cf77f138efb31727ee7197bc824c9d6d7039204ed96756cc0f9ca7d8e8fc2a4",
+                "sha256:42c216a33d2bdba08098acaf5bae65b0c8196afeb535ef4b870919a788a27259",
+                "sha256:465a6ce9ca6268cadfbc27a2a94ddf0412568a6b27640ced229270be4f5d394d",
+                "sha256:6a8e1f63357851444940351e98fb3252956a15f2cabe3d698316d7a2d1f1f896",
+                "sha256:745071762f32f65e77de6df699366d707fad6c132a660d1342077cbf671ef589",
+                "sha256:818cfc51c25a5dbfd0705f3ac1919fff6971eb0c02e6f1a1f6a017a42405a7c0",
+                "sha256:8e5974583a77d630a5868eee18f85ac3093caf76e018c510aeb802b9973304ce",
+                "sha256:8eaf55fdf99242a1c8c792247c455565447353914023878beadb79600aac4a2a",
+                "sha256:98f61aad0bb54f797b17da5b82f419e6ce214de0aa7e92211ebee9e40eb04276",
+                "sha256:b2ce2788df0c066c2ff4ba7190fa84f18937527c477247e926abeb9b1168b8cc",
+                "sha256:b30d29251dff4c59b2e5a1fa1bab91ff3e117b4658cb90f76d97702b7a2ae699",
+                "sha256:bf446223b2e0e4f0a4792938e8d885e8a896834aded5f51be5c3c69566495540",
+                "sha256:cbcc691d8b507d54cb2b8521f0a2a3d4daa477f62fe77f0abba41e5febb377b7",
+                "sha256:d051ce0946521eba48e19b25f27f98e5ce4dbc91fff296de76240c46b4464df0",
+                "sha256:d61b73c01fc1de799226963f2639af831307fe1556b04b7c25e2b6c267a3bc76",
+                "sha256:eea10982b798ff0ccc3b9e7e42628f932f552c5845066970e67cd6858655d52c",
+                "sha256:f79137d012ff3227866222049af534f25354c07a0d6b9a171dba9f1d6a1fdef4",
+                "sha256:fc5ecff5a3bbfbe20091b1cad82815507f5ae9c380a3a9bf40f740c70ce30a9b"
             ],
             "index": "pypi",
-            "version": "==0.910"
+            "version": "==0.941"
         },
         "mypy-extensions": {
             "hashes": [
@@ -147,90 +222,139 @@
             ],
             "version": "==0.4.3"
         },
+        "packaging": {
+            "hashes": [
+                "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
+                "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==21.3"
+        },
         "pathspec": {
             "hashes": [
-                "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd",
-                "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"
+                "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a",
+                "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"
+            ],
+            "version": "==0.9.0"
+        },
+        "platformdirs": {
+            "hashes": [
+                "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d",
+                "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.5.1"
+        },
+        "pluggy": {
+            "hashes": [
+                "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
+                "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
             ],
-            "version": "==0.8.1"
+            "markers": "python_version >= '3.6'",
+            "version": "==1.0.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"
         },
         "pycodestyle": {
             "hashes": [
-                "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068",
-                "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"
+                "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20",
+                "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"
             ],
-            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==2.7.0"
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==2.8.0"
         },
         "pyflakes": {
             "hashes": [
-                "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3",
-                "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"
+                "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c",
+                "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"
             ],
             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
-            "version": "==2.3.1"
-        },
-        "regex": {
-            "hashes": [
-                "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f",
-                "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad",
-                "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a",
-                "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf",
-                "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59",
-                "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d",
-                "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895",
-                "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4",
-                "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3",
-                "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222",
-                "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0",
-                "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c",
-                "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417",
-                "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d",
-                "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d",
-                "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761",
-                "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0",
-                "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026",
-                "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854",
-                "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb",
-                "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d",
-                "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068",
-                "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde",
-                "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d",
-                "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec",
-                "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa",
-                "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd",
-                "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b",
-                "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26",
-                "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2",
-                "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f",
-                "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694",
-                "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0",
-                "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407",
-                "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874",
-                "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035",
-                "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d",
-                "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c",
-                "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5",
-                "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985",
-                "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58"
-            ],
-            "version": "==2021.7.6"
-        },
-        "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"
+            "version": "==2.4.0"
+        },
+        "pyparsing": {
+            "hashes": [
+                "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea",
+                "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==3.0.7"
+        },
+        "pytest": {
+            "hashes": [
+                "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63",
+                "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"
+            ],
+            "index": "pypi",
+            "version": "==7.1.1"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
+                "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
+            ],
+            "index": "pypi",
+            "version": "==2.27.1"
+        },
+        "responses": {
+            "hashes": [
+                "sha256:3fc29c3117e14136b833a0a6d4e7f1217c6301bf08b6086db468e12f1e3290e2",
+                "sha256:53354b5de163aa2074312c71d8ebccb8bd1ab336cff7053abb75e84dc5637abe"
+            ],
+            "index": "pypi",
+            "version": "==0.19.0"
+        },
+        "tomli": {
+            "hashes": [
+                "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
+                "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.0.1"
+        },
+        "types-requests": {
+            "hashes": [
+                "sha256:5d6f77f3c7565659bdb7b7bce1d33d1abb7d0b056138cac714860e13da2f19df",
+                "sha256:cf0646031dd6307113b37814f743c04f0707a3357378c2bb1326f848412f5ba9"
+            ],
+            "index": "pypi",
+            "version": "==2.27.13"
+        },
+        "types-urllib3": {
+            "hashes": [
+                "sha256:24d64e441168851eb05f1d022de18ae31558f5649c8f1117e384c2e85e31315b",
+                "sha256:bd0abc01e9fb963e4fddd561a56d21cc371b988d1245662195c90379077139cd"
+            ],
+            "version": "==1.26.11"
         },
         "typing-extensions": {
             "hashes": [
-                "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
-                "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
-                "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
+                "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42",
+                "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"
             ],
-            "version": "==3.10.0.0"
+            "markers": "python_version < '3.10'",
+            "version": "==4.1.1"
+        },
+        "typing-inspect": {
+            "hashes": [
+                "sha256:047d4097d9b17f46531bf6f014356111a1b6fb821a24fe7ac909853ca2a782aa",
+                "sha256:3cd7d4563e997719a710a3bfe7ffb544c6b72069b6812a02e9b414a8fa3aaa6b",
+                "sha256:b1f56c0783ef0f25fb064a01be6e5407e54cf4a4bf4f3ba3fe51e0bd6dcea9e5"
+            ],
+            "version": "==0.7.1"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14",
+                "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"
+            ],
+            "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.9"
         }
     }
 }
diff --git a/doccano_api_client/beta_pythonic_client/README.md b/doccano_api_client/beta_pythonic_client/README.md
new file mode 100644
index 0000000..7c2228d
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/README.md
@@ -0,0 +1,38 @@
+### Usage
+
+The client can be instatiated with a base URL referring to the target Doccano instance. Once instantiated, you can login using your Doccano username and password.
+
+```python
+from doccano_api_client.beta_pythonic_client import Client, controllers
+
+client = Client("your_instance_url_here")
+client.login("my_username", "my_password")
+```
+
+Once the client is instantiated and a session login to Doccano is established, you can access your projects with Python-ized navigation.
+
+```python
+project_controllers = client.projects.all()
+
+my_project_name_controller = next(controller for controller in project_controllers if controller.project.name == "My Project Name")
+
+my_example = models.Example(text="This is an example text")
+my_project_name_controller.examples.create(my_example)
+
+```
+
+### Running local integration tests
+Given that the Doccano project can get updated with API-breaking changes, we introduced the ability to run local integration tests against all the main endpoints.
+This allows to make sure that our client is still operational with newer Doccano versions.
+
+To run the integration tests:
+- Spin up doccano locally. Clone the Doccano repo, and deploy or stand up your Doccano instance
+- Then, within the root of this repo, run
+```shell
+pipenv run test --runintegrationtest
+```
+
+## Potential Additions
+
+- Upgrade to 1.6.x compatibility
+- Potentially adding Pydantic for tighter field validation
diff --git a/doccano_api_client/beta_pythonic_client/__init__.py b/doccano_api_client/beta_pythonic_client/__init__.py
new file mode 100644
index 0000000..1d6af8d
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/__init__.py
@@ -0,0 +1,3 @@
+from .client import Client
+
+__all__ = ["Client"]
diff --git a/doccano_api_client/beta_pythonic_client/client.py b/doccano_api_client/beta_pythonic_client/client.py
new file mode 100644
index 0000000..3299952
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/client.py
@@ -0,0 +1,46 @@
+import requests
+
+from .controllers import ProjectsController
+from .utils.response import verbose_raise_for_status
+
+
+class Client:
+    """Base client for interacting with the Doccano API"""
+
+    __slots__ = ["_base_url", "client_session"]
+
+    def __init__(self, base_url: str) -> None:
+        """Initialize a Doccano client with a base url and authorization token for headers"""
+        self._base_url = base_url
+        self.client_session = requests.Session()
+        headers = {
+            "content-type": "application/json",
+            "accept": "application/json",
+        }
+        self.client_session.headers.update(headers)
+
+    @property
+    def login_url(self) -> str:
+        """Retrieve an API login url based on the base_url"""
+        return f"{self._base_url}/v1/auth/login/"
+
+    @property
+    def api_url(self) -> str:
+        """Retrieve an API url based on the base_url"""
+        return f"{self._base_url}/v1"
+
+    def login(self, username: str, password: str) -> None:
+        """Login to a session with the Doccano instance related to the base url"""
+        response = self.client_session.post(
+            self.login_url, json={"username": username, "password": password}
+        )
+        # TODO: do we want to do anything with the return value token in the future?
+        verbose_raise_for_status(response)
+        self.client_session.headers.update(
+            {"X-CSRFToken": self.client_session.cookies.get("csrftoken")}
+        )
+
+    @property
+    def projects(self) -> ProjectsController:
+        """Return a generated project controller based on api url and client session"""
+        return ProjectsController(self.api_url, self.client_session)
diff --git a/doccano_api_client/beta_pythonic_client/controllers/__init__.py b/doccano_api_client/beta_pythonic_client/controllers/__init__.py
new file mode 100644
index 0000000..39ef43b
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/controllers/__init__.py
@@ -0,0 +1,27 @@
+from .annotation import AnnotationController, AnnotationsController
+from .comment import CommentController, CommentsController
+from .example import (
+    DocumentController,
+    DocumentsController,
+    ExampleController,
+    ExamplesController,
+)
+from .label import LabelController, LabelsController
+from .project import ProjectController, ProjectsController
+
+__all__ = [
+    "AnnotationController",
+    "AnnotationsController",
+    "CommentController",
+    "CommentsController",
+    # TODO: Retained for backwards compatibility. Remove in v1.6.0
+    "DocumentController",
+    "DocumentsController",
+    # END TODO
+    "ExampleController",
+    "ExamplesController",
+    "LabelController",
+    "LabelsController",
+    "ProjectController",
+    "ProjectsController",
+]
diff --git a/doccano_api_client/beta_pythonic_client/controllers/annotation.py b/doccano_api_client/beta_pythonic_client/controllers/annotation.py
new file mode 100644
index 0000000..bb101f0
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/controllers/annotation.py
@@ -0,0 +1,61 @@
+from dataclasses import dataclass
+from typing import Iterable
+
+from requests import Session
+
+from ..models import Annotation, Project
+from ..utils.response import verbose_raise_for_status
+
+
+@dataclass
+class AnnotationController:
+    """Wraps an Annotation."""
+
+    id: int
+    annotation: Annotation
+    user: int
+    created_at: str
+    updated_at: str
+    example: int
+
+
+class AnnotationsController:
+    """Controls the creation and retrieval of individual annotations for an example."""
+
+    def __init__(
+        self, example_id: int, project: Project, example_url: str, client_session: Session
+    ):
+        """Initializes a AnnotationsController instance
+
+        Args:
+            example_id: int. The relevant example id to this annotations controller
+            example_url: str. Url of the parent example
+            project: Project. The project model of the annotations, which is needed to query
+                for the type of annotation used by the project.
+            client_session: requests.session. The current session passed from client to models
+        """
+        self.example_id = example_id
+        self.project = project
+        self._example_url = example_url
+        self.client_session = client_session
+
+    @property
+    def annotations_url(self) -> str:
+        """Return an api url for annotations list of a example"""
+        return f"{self._example_url}"
+
+    def all(self) -> Iterable[AnnotationController]:
+        """Return a sequence of AnnotationControllers."""
+        response = self.client_session.get(self.annotations_url)
+        verbose_raise_for_status(response)
+        annotation_dicts = response.json()["annotations"]
+
+        for annotation_dict in annotation_dicts:
+            yield AnnotationController(
+                id=annotation_dict["id"],
+                annotation=self.project.get_annotation_model().from_dict(annotation_dict),
+                user=annotation_dict["user"],
+                created_at=annotation_dict["created_at"],
+                updated_at=annotation_dict["updated_at"],
+                example=annotation_dict["example"],
+            )
diff --git a/doccano_api_client/beta_pythonic_client/controllers/comment.py b/doccano_api_client/beta_pythonic_client/controllers/comment.py
new file mode 100644
index 0000000..00c6310
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/controllers/comment.py
@@ -0,0 +1,74 @@
+from dataclasses import dataclass, fields
+from typing import Iterable
+
+from requests import Session
+
+from ..models.comments import Comment
+from ..utils.response import verbose_raise_for_status
+
+
+@dataclass
+class CommentController:
+    """Wraps a Comment with fields used for interacting directly with Doccano client"""
+
+    comment: Comment
+    username: str
+    created_at: str
+    example: int
+    id: int
+    comments_url: str
+    client_session: Session
+
+    @property
+    def comment_url(self) -> str:
+        """Return an api url for this comment"""
+        return f"{self.comments_url}/{self.id}"
+
+
+class CommentsController:
+    """Controls the creation and retrieval of CommentControllers for an object
+
+    A noticeable ommission compared to other API list controllers is a create() method.
+    While we could use the API to create new comments on examples, there is little to no
+    reason seen for not discouraging it compared to commenting directly in the GUI.
+    """
+
+    def __init__(self, parent_url: str, client_session: Session) -> None:
+        """Initializes a CommentsController instance
+
+        Args:
+            parent_url: str. Url of the parent model. Either a project or example can have a
+                comments property. For a example, it's all of the comments on that example. For a
+                project, it is all comments on all examples for a project.
+            client_session: requests.session. The current session passed from client to models
+        """
+        self._parent_url = parent_url
+        self.client_session = client_session
+
+    @property
+    def comments_url(self) -> str:
+        """Return an api url for comments list of an object"""
+        return f"{self._parent_url}/comments"
+
+    def all(self) -> Iterable[CommentController]:
+        """Return a sequence of Comments for a given controller, which maps to an object"""
+        response = self.client_session.get(self.comments_url)
+        verbose_raise_for_status(response)
+        comment_dicts = response.json()
+        comment_obj_fields = set(comment_field.name for comment_field in fields(Comment))
+
+        for comment_dict in comment_dicts:
+            # Sanitize comment_dict before converting to Comment
+            sanitized_comment_dict = {
+                comment_key: comment_dict[comment_key] for comment_key in comment_obj_fields
+            }
+
+            yield CommentController(
+                comment=Comment(**sanitized_comment_dict),
+                username=comment_dict["username"],
+                created_at=comment_dict["created_at"],
+                example=comment_dict["example"],
+                id=comment_dict["id"],
+                comments_url=self.comments_url,
+                client_session=self.client_session,
+            )
diff --git a/doccano_api_client/beta_pythonic_client/controllers/example.py b/doccano_api_client/beta_pythonic_client/controllers/example.py
new file mode 100644
index 0000000..3ef34e0
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/controllers/example.py
@@ -0,0 +1,159 @@
+from dataclasses import asdict, dataclass, fields
+from typing import Any, Dict, Iterable
+
+from requests import Session
+
+from ..models.examples import Example
+from ..models.projects import Project
+from ..utils.response import verbose_raise_for_status
+from .annotation import AnnotationsController
+from .comment import CommentsController
+
+EXAMPLES_PER_PAGE_LIMIT = 10
+
+
+@dataclass
+class ExampleController:
+    """Wraps a Example with fields used for interacting directly with Doccano client"""
+
+    example: Example
+    id: int
+    examples_url: str
+    client_session: Session
+    project: Project
+
+    @property
+    def example_url(self) -> str:
+        """Return an api url for this example"""
+        return f"{self.examples_url}/{self.id}"
+
+    @property
+    def comments(self) -> CommentsController:
+        """Return a CommentsController mapped to this example"""
+        return CommentsController(self.example_url, self.client_session)
+
+    @property
+    def annotations(self) -> AnnotationsController:
+        """Return an AnnotationsController mapped to this example"""
+        return AnnotationsController(self.id, self.project, self.example_url, self.client_session)
+
+
+class ExamplesController:
+    """Controls the creation and retrieval of individual ExampleControllers for a project"""
+
+    def __init__(self, project: Project, project_url: str, client_session: Session) -> None:
+        """Initializes a ExampleController instance"""
+        self.project = project
+        self._project_url = project_url
+        self.client_session = client_session
+
+    @property
+    def examples_url(self) -> str:
+        """Return an api url for examples list"""
+        return f"{self._project_url}/examples"
+
+    def _get_examples_response_from_api(
+        self, page_index: int = 0, limit: int = EXAMPLES_PER_PAGE_LIMIT
+    ) -> Dict[str, Any]:
+        """Return the json response from an API call to get examples, handling pagination as well
+
+        Args:
+            page_index: int. Page number, starting at 0, for examples list to retrieve
+            limit: int. examples-per-page limit set as an api query parameter
+
+        Returns:
+            json response from API call to Doccano instance
+        """
+        response = self.client_session.get(
+            self.examples_url, params={"limit": limit, "offset": page_index * limit}
+        )
+        verbose_raise_for_status(response)
+        return response.json()
+
+    def count(self) -> int:
+        """Return the total number of examples for a project"""
+        return self._get_examples_response_from_api(page_index=0, limit=1)["count"]
+
+    def get(self, example_id: int) -> ExampleController:
+        """Return a ExampleController given an id."""
+        response = self.client_session.get(f"{self.examples_url}/{example_id}")
+        verbose_raise_for_status(response)
+        example_dict = response.json()
+
+        return ExampleController(
+            example=Example.from_dict(example_dict),
+            project=self.project,
+            id=example_dict["id"],
+            examples_url=self.examples_url,
+            client_session=self.client_session,
+        )
+
+    def all(self) -> Iterable[ExampleController]:
+        """Return a sequence of Examples for a given controller, which maps to a Project"""
+        example_obj_fields = set(example_field.name for example_field in fields(Example))
+        # Examples are paginated in Doccano's API, so handle pagination with yields
+        page_index = 0
+        example_dicts = self._get_examples_response_from_api(page_index=page_index)["results"]
+
+        # When there are no more examples to paginate through, "results" recieved will be empty
+        # NOTE: The Doccano pagination API responses also have "next" field that can be used to
+        #       query the next page url. The problem is, this is returning urls with "http"
+        #       as the protocol even when the host is https, so we use this method for
+        #       pagination instead
+        #       This still works deterministically because on the API side, the example list
+        #       view/api endpoint is explicitly ordered. For non-randomized example order, it's
+        #       it's ordered by ID. For randomized order, it's ordered randomly but with a fixed
+        #       seed (the requesting user's id).
+        while len(example_dicts) != 0:
+            # Given a single page, iterate through the dicts on that page
+            for example_dict in example_dicts:
+                # TODO (louis): make a Example.from_json() method to handle this. Also, do this
+                #               for all other models
+                # Sanitize example_dict before converting to Example
+                sanitized_example_dict = {
+                    example_key: example_dict[example_key] for example_key in example_obj_fields
+                }
+
+                yield ExampleController(
+                    example=Example(**sanitized_example_dict),
+                    project=self.project,
+                    id=example_dict["id"],
+                    examples_url=self.examples_url,
+                    client_session=self.client_session,
+                )
+
+            page_index += 1
+            example_dicts = self._get_examples_response_from_api(page_index=page_index)["results"]
+
+    def create(self, example: Example) -> ExampleController:
+        """Upload new example for Doccano project, return the generated controller
+
+        Args:
+            example: Example. The only fields that will be uploaded are text, annnotations,
+                and meta.
+
+        Returns:
+            ExampleController. The ExampleController now wrapping around the newly created example
+        """
+        importable_keys = ["text", "meta"]
+        example_as_dict = asdict(example)
+        example_json = {
+            importable_key: example_as_dict[importable_key] for importable_key in importable_keys
+        }
+
+        response = self.client_session.post(self.examples_url, json=example_json)
+        verbose_raise_for_status(response)
+        response_id = response.json()["id"]
+
+        return ExampleController(
+            example=example,
+            project=self.project,
+            id=response_id,
+            examples_url=self.examples_url,
+            client_session=self.client_session,
+        )
+
+
+# TODO: Retained for backwards compatibility. Remove in v1.6.0
+DocumentController = ExampleController
+DocumentsController = ExamplesController
diff --git a/doccano_api_client/beta_pythonic_client/controllers/label.py b/doccano_api_client/beta_pythonic_client/controllers/label.py
new file mode 100644
index 0000000..571daed
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/controllers/label.py
@@ -0,0 +1,191 @@
+from dataclasses import asdict, dataclass, fields
+from typing import Iterable, NamedTuple, Optional, Set
+
+from requests import Session
+
+from ..models.labels import LABEL_COLOR_CYCLE, Label
+from ..utils.response import verbose_raise_for_status
+
+COLOR_CYCLE_RANGE = len(LABEL_COLOR_CYCLE)
+
+
+class Shortcut(NamedTuple):
+    """Represents a keyboard shortcut"""
+
+    prefix_key: Optional[str] = None
+    suffix_key: Optional[str] = None
+
+
+class LabelGenerator:
+    """Returns new generated label fields based on memory of previously picked fields
+
+    In Doccano, if you create labels on a project manually, it can be difficult to intuitively
+    color coordinate them and set shortcuts. The GUI for this is quite manual. This class
+    is intended to provide methods for regenerating a full set of labels from a given project with
+    color coordination and intuitively set labels based on the contents of a given label and
+    previously generated values.
+    """
+
+    def __init__(self) -> None:
+        """Initializes the LabelFieldPicker"""
+        self.color_cycle_index: int = 0
+        self.picked_letters: Set[str] = set()
+        self.picked_shift_letters: Set[str] = set()
+        self.next_number_picker: int = 0
+
+    def next_label_color(self) -> str:
+        """Returns the next color in a cycle of label highlights, bumps the index for next call"""
+        next_color = LABEL_COLOR_CYCLE[self.color_cycle_index % COLOR_CYCLE_RANGE]
+        self.color_cycle_index += 1
+        return next_color
+
+    def next_label_shortcut(self, label_text: str) -> Shortcut:
+        """Returns the next label keyboard shortcut, logs newly set shortcut to avoid overlap
+
+        If all available shortcuts are taken, return None
+
+        Args:
+            label_text: str. The text of the label, used for picking preferred shortcuts (first
+                letter of the text)
+
+        Returns:
+            A Shortcut named tuple containing the calculated prefix and suffix key
+
+        Raises:
+            AssertionError: If the label_text is empty
+        """
+        if len(label_text) == 0:
+            raise AssertionError("label_text is empty, must be non-empty")
+
+        first_char = label_text[0].lower()
+
+        # Check if the character shortcut alone is available
+        if first_char not in self.picked_letters:
+            self.picked_letters.add(first_char)
+            return Shortcut(prefix_key=None, suffix_key=first_char)
+        # Check if shift+character shortcut is available
+        elif first_char not in self.picked_shift_letters:
+            self.picked_shift_letters.add(first_char)
+            return Shortcut(prefix_key="shift", suffix_key=first_char)
+        # Check if the digits have not been cycled through once yet
+        elif self.next_number_picker < 10:
+            next_number = str(self.next_number_picker % 10)
+            self.next_number_picker += 1
+            return Shortcut(prefix_key=None, suffix_key=next_number)
+        # Check if the digits have been cycled through only once
+        elif self.next_number_picker < 20:
+            next_number = str(self.next_number_picker % 10)
+            self.next_number_picker += 1
+            return Shortcut(prefix_key="shift", suffix_key=next_number)
+
+        return Shortcut(prefix_key=None, suffix_key=None)
+
+    def next_label(self, label: Label) -> Label:
+        """Returns an updated Label with fields newly generated"""
+        shortcut = self.next_label_shortcut(label.text)
+        background_color = self.next_label_color()
+
+        return Label(
+            text=label.text,
+            prefix_key=shortcut.prefix_key,
+            suffix_key=shortcut.suffix_key,
+            background_color=background_color,
+        )
+
+
+@dataclass
+class LabelController:
+    """Wraps a Label with fields used for interacting directly with Doccano client"""
+
+    label: Label
+    id: int
+    labels_url: str
+    client_session: Session
+
+    @property
+    def label_url(self) -> str:
+        """Return an api url for this label"""
+        return f"{self.labels_url}/{self.id}"
+
+
+class LabelsController:
+    """Controls the creation and retrieval of individual LabelControllers for an assigned project"""
+
+    def __init__(self, project_url: str, client_session: Session) -> None:
+        """Initializes a LabelController instance"""
+        self._project_url = project_url
+        self.client_session = client_session
+
+    @property
+    def labels_url(self) -> str:
+        """Return an api url for labels list"""
+        return f"{self._project_url}/labels"
+
+    def all(self) -> Iterable[LabelController]:
+        """Return a sequence of all labels for a given controller, which maps to a project"""
+        response = self.client_session.get(self.labels_url)
+        verbose_raise_for_status(response)
+        label_dicts = response.json()
+        label_object_fields = set(label_field.name for label_field in fields(Label))
+
+        for label_dict in label_dicts:
+            # Sanitize label_dict before converting to Label
+            sanitized_label_dict = {
+                label_key: label_dict[label_key] for label_key in label_object_fields
+            }
+
+            yield LabelController(
+                label=Label(**sanitized_label_dict),
+                id=label_dict["id"],
+                labels_url=self.labels_url,
+                client_session=self.client_session,
+            )
+
+    def create(self, label: Label) -> LabelController:
+        """Create new label for Doccano project, assign session variables to label, return the id"""
+        label_json = asdict(label)
+
+        response = self.client_session.post(self.labels_url, json=label_json)
+        verbose_raise_for_status(response)
+        response_id = response.json()["id"]
+
+        return LabelController(
+            label=label,
+            id=response_id,
+            labels_url=self.labels_url,
+            client_session=self.client_session,
+        )
+
+    def all_regenerated(self) -> Iterable[LabelController]:
+        """Regenerates fields for all labels for a given controller, returns the full new set
+
+        Retrieves list of current labels for a given project and uses existing keyboard
+        shortcuts and colors in the picking process. This can be useful if you just created a
+        group of labels in the Doccano GUI, and don't want to manually go through and hand-assign
+        each label a color and a shortcut.
+
+        Yields:
+            Iterable[LabelController]: the sereis of newly regenerated labels
+        """
+        label_generator = LabelGenerator()
+        for label_controller in self.all():
+            yield LabelController(
+                label=label_generator.next_label(label_controller.label),
+                id=label_controller.id,
+                labels_url=label_controller.labels_url,
+                client_session=label_controller.client_session,
+            )
+
+    def update(self, label_controllers: Iterable[LabelController]) -> None:
+        """Updates the given labels in the remote project"""
+        for label_controller in label_controllers:
+            label_json = asdict(label_controller.label)
+            label_json = {
+                label_key: label_value
+                for label_key, label_value in label_json.items()
+                if label_value is not None
+            }
+            label_json["id"] = label_controller.id
+
+            response = self.client_session.put(label_controller.label_url, json=label_json)
+            verbose_raise_for_status(response)
diff --git a/doccano_api_client/beta_pythonic_client/controllers/project.py b/doccano_api_client/beta_pythonic_client/controllers/project.py
new file mode 100644
index 0000000..477f214
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/controllers/project.py
@@ -0,0 +1,110 @@
+from dataclasses import asdict, dataclass, fields
+from typing import Iterable
+
+from requests import Session
+
+from ..models.projects import Project
+from ..utils.response import verbose_raise_for_status
+from .comment import CommentsController
+from .example import DocumentsController, ExamplesController
+from .label import LabelsController
+
+
+@dataclass
+class ProjectController:
+    project: Project
+    id: int
+    projects_url: str
+    client_session: Session
+
+    @property
+    def project_url(self) -> str:
+        """Return an api url for this project"""
+        return f"{self.projects_url}/{self.id}"
+
+    @property
+    def labels(self) -> LabelsController:
+        """Return a LabelsController mapped to this project"""
+        return LabelsController(self.project_url, self.client_session)
+
+    # TODO: Retained for backwards compatibility. Remove in v1.6.0
+    @property
+    def documents(self) -> DocumentsController:
+        """Return a DocumentsController mapped to this project"""
+        return DocumentsController(self.project, self.project_url, self.client_session)
+
+    @property
+    def examples(self) -> ExamplesController:
+        """Return a ExamplesController mapped to this project"""
+        return ExamplesController(self.project, self.project_url, self.client_session)
+
+    @property
+    def comments(self) -> CommentsController:
+        """Return a CommentsController mapped to this project"""
+        return CommentsController(self.project_url, self.client_session)
+
+
+class ProjectsController:
+    """Controls the retrieval of individual ProjectControllers"""
+
+    __slots__ = ["_api_url", "client_session"]
+
+    def __init__(self, api_url: str, client_session: Session):
+        """Initializes a ProjectController instance"""
+        self._api_url = api_url
+        self.client_session = client_session
+
+    @property
+    def projects_url(self) -> str:
+        """Return an api url for projects list"""
+        return f"{self._api_url}/projects"
+
+    def get(self, project_id: int) -> ProjectController:
+        """Return a ProjectController for a given project id."""
+        response = self.client_session.get(f"{self.projects_url}/{project_id}")
+        verbose_raise_for_status(response)
+        project_dict = response.json()
+
+        return ProjectController(
+            project=Project.from_dict(project_dict),
+            id=project_dict["id"],
+            projects_url=self.projects_url,
+            client_session=self.client_session,
+        )
+
+    def all(self) -> Iterable[ProjectController]:
+        """Return a sequence of projects for a given controller, assigned to the user"""
+        response = self.client_session.get(self.projects_url)
+        verbose_raise_for_status(response)
+        project_dicts = response.json()
+        project_obj_fields = set(  # Only use fields that are part of the init, skips resourcetype
+            proj_field.name for proj_field in fields(Project) if proj_field.init
+        )
+
+        for project_dict in project_dicts:
+            # Sanitize project_dict before converting to Project
+            sanitized_project_dict = {
+                proj_key: project_dict[proj_key] for proj_key in project_obj_fields
+            }
+
+            yield ProjectController(
+                project=Project(**sanitized_project_dict),
+                id=project_dict["id"],
+                projects_url=self.projects_url,
+                client_session=self.client_session,
+            )
+
+    def create(self, project: Project) -> ProjectController:
+        """Create a new Doccano project, assign session variables to ProjectController, return it"""
+        project_json = asdict(project)
+
+        response = self.client_session.post(self.projects_url, json=project_json)
+        verbose_raise_for_status(response)
+        response_id = response.json()["id"]
+
+        return ProjectController(
+            project=project,
+            id=response_id,
+            projects_url=self.projects_url,
+            client_session=self.client_session,
+        )
diff --git a/doccano_api_client/beta_pythonic_client/models/__init__.py b/doccano_api_client/beta_pythonic_client/models/__init__.py
new file mode 100644
index 0000000..cd9c67a
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/models/__init__.py
@@ -0,0 +1,26 @@
+from .annotations import (
+    Annotation,
+    CategoryAnnotation,
+    SpanAnnotation,
+    TextLabelAnnotation,
+)
+from .comments import Comment
+from .examples import Document, Example
+from .labels import LABEL_COLOR_CYCLE, Label
+from .projects import Project, ProjectTypes
+
+__all__ = [
+    "Comment",
+    # TODO: Retained for backwards compatibility. Remove in v1.6.0
+    "Document",
+    # END TODO
+    "Example",
+    "Label",
+    "LABEL_COLOR_CYCLE",
+    "ProjectTypes",
+    "Project",
+    "CategoryAnnotation",
+    "SpanAnnotation",
+    "TextLabelAnnotation",
+    "Annotation",
+]
diff --git a/doccano_api_client/beta_pythonic_client/models/annotations.py b/doccano_api_client/beta_pythonic_client/models/annotations.py
new file mode 100644
index 0000000..62b9042
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/models/annotations.py
@@ -0,0 +1,33 @@
+from dataclasses import dataclass
+from typing import Union
+
+from dataclasses_json import DataClassJsonMixin
+
+
+@dataclass
+class CategoryAnnotation(DataClassJsonMixin):
+    """Represents an annotation on an example in a Text Classification project."""
+
+    label: int
+    prob: float
+
+
+@dataclass
+class SpanAnnotation(DataClassJsonMixin):
+    """Represents an annotation on an example in a Sequence Labeling project."""
+
+    label: int
+    prob: float
+    start_offset: int
+    end_offset: int
+
+
+@dataclass
+class TextLabelAnnotation(DataClassJsonMixin):
+    """Represents an annotation on an example in a Seq2seq project."""
+
+    text: str
+    prob: float
+
+
+Annotation = Union[CategoryAnnotation, SpanAnnotation, TextLabelAnnotation]
diff --git a/doccano_api_client/beta_pythonic_client/models/comments.py b/doccano_api_client/beta_pythonic_client/models/comments.py
new file mode 100644
index 0000000..bc1ef10
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/models/comments.py
@@ -0,0 +1,8 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class Comment:
+    """Contains the data and operations relevant to a comment on a Doccano document"""
+
+    text: str
diff --git a/doccano_api_client/beta_pythonic_client/models/examples.py b/doccano_api_client/beta_pythonic_client/models/examples.py
new file mode 100644
index 0000000..292573a
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/models/examples.py
@@ -0,0 +1,21 @@
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+from dataclasses_json import DataClassJsonMixin
+
+from . import Annotation
+
+
+@dataclass
+class Example(DataClassJsonMixin):
+    """Contains the data and operations relevant to a example on a Doccano project"""
+
+    text: str
+    meta: Dict[str, Any] = field(default_factory=dict)
+    annotation_approver: Optional[str] = None
+    annotations: List[Annotation] = field(default_factory=list)
+    comment_count: int = 0
+
+
+# TODO: Retained for backwards compatibility. Remove in v1.6.0
+Document = Example
diff --git a/doccano_api_client/beta_pythonic_client/models/labels.py b/doccano_api_client/beta_pythonic_client/models/labels.py
new file mode 100644
index 0000000..13ac641
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/models/labels.py
@@ -0,0 +1,30 @@
+from dataclasses import dataclass
+from typing import Optional
+
+LABEL_COLOR_CYCLE = [
+    "#EF5350",
+    "#7E57C2",
+    "#29B6F6",
+    "#66BB6A",
+    "#FFEE58",
+    "#FF7043",
+    "#BDBDBD",
+    "#EC407A",
+    "#5C6BC0",
+    "#26C6DA",
+    "#9CCC65",
+    "#FFCA28",
+    "#8D6E63",
+    "#AB47BC",
+    "#42A5F5",
+]
+
+
+@dataclass
+class Label:
+    """Contains the data and operations relevant to a label on a Doccano project"""
+
+    text: str
+    prefix_key: Optional[str] = None
+    suffix_key: Optional[str] = None
+    background_color: str = LABEL_COLOR_CYCLE[0]
diff --git a/doccano_api_client/beta_pythonic_client/models/projects.py b/doccano_api_client/beta_pythonic_client/models/projects.py
new file mode 100644
index 0000000..f616bbf
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/models/projects.py
@@ -0,0 +1,57 @@
+from dataclasses import dataclass, field
+from typing import Dict, Type
+
+from dataclasses_json import DataClassJsonMixin
+
+from .annotations import (
+    Annotation,
+    CategoryAnnotation,
+    SpanAnnotation,
+    TextLabelAnnotation,
+)
+
+
+class ProjectTypes:
+    """Constants pointing to the types of project resource types available for new projects"""
+
+    DOCUMENT_CLASSIFICATION = "DocumentClassification"
+    SEQUENCE_LABELING = "SequenceLabeling"
+    SEQ2SEQ = "Seq2seq"
+
+
+PROJECT_TO_RESOURCE_TYPE = {
+    # For some reason, DocumentClassification's resource type on Doccano doesn't match up directly
+    ProjectTypes.DOCUMENT_CLASSIFICATION: "TextClassificationProject",
+    ProjectTypes.SEQUENCE_LABELING: "SequenceLabelingProject",
+    ProjectTypes.SEQ2SEQ: "Seq2seqProject",
+}
+
+
+# Each project type is associated with a specific annotation type
+PROJECT_TO_ANNOTATION_MODEL: Dict[str, Type[Annotation]] = {
+    ProjectTypes.DOCUMENT_CLASSIFICATION: CategoryAnnotation,
+    ProjectTypes.SEQUENCE_LABELING: SpanAnnotation,
+    ProjectTypes.SEQ2SEQ: TextLabelAnnotation,
+}
+
+
+@dataclass
+class Project(DataClassJsonMixin):
+    name: str
+    description: str
+    project_type: str
+    resourcetype: str = field(init=False)
+
+    guideline: str = "Please write annotation guideline."
+    random_order: bool = False
+    collaborative_annotation: bool = False
+
+    def __post_init__(self) -> None:
+        """Propogates resourcetype field based on project_type field, needed for project creation"""
+        if self.project_type not in PROJECT_TO_RESOURCE_TYPE:
+            raise AssertionError(f"project_type not in: {PROJECT_TO_RESOURCE_TYPE.keys()}")
+        self.resourcetype = PROJECT_TO_RESOURCE_TYPE.get(self.project_type, "NotAResource")
+
+    def get_annotation_model(self) -> Type[Annotation]:
+        """Return the appropriate Annotation type for this project type."""
+        return PROJECT_TO_ANNOTATION_MODEL[self.project_type]
diff --git a/doccano_api_client/beta_pythonic_client/py.typed b/doccano_api_client/beta_pythonic_client/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/doccano_api_client/beta_pythonic_client/utils/__init__.py b/doccano_api_client/beta_pythonic_client/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/doccano_api_client/beta_pythonic_client/utils/response.py b/doccano_api_client/beta_pythonic_client/utils/response.py
new file mode 100644
index 0000000..2b3687a
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/utils/response.py
@@ -0,0 +1,17 @@
+from requests import Response, exceptions
+
+
+class DoccanoAPIError(exceptions.HTTPError):
+    def __init__(self, response: Response):
+        """Initialize the exception with the response."""
+        super().__init__(str(response.json()), response=response)
+
+
+def verbose_raise_for_status(response: Response) -> None:
+    """Output a bad response's text before raising for verbosity, return response otherwise"""
+    try:
+        response.raise_for_status()
+    except exceptions.HTTPError as err:
+        raise DoccanoAPIError(err.response)
+
+    return response
diff --git a/setup.py b/setup.py
index e43cce4..ff3bd4a 100644
--- a/setup.py
+++ b/setup.py
@@ -11,11 +11,17 @@
     "flake8-docstrings==1.5.0",
     "darglint==1.4.0",
     "pre-commit>=2.2.0",
+    "dataclasses>=0.7,<1; python_version<'3.7'",
+    "dataclasses-json>=0.5.0,<1.0.0",
+    "types-dataclasses>=0.1.3,<1; python_version<'3.7'",
+    "types-requests>=0.1.8,<1",
 ]
 
 setup(
     name="doccano-client",
-    use_scm_version=True,
+    # Semver for this package should match Doccano release tags to denote compatibility
+    # up to the minor version (patch version can be more flexible)
+    version="1.5.1",  # Tested for compatibility against Doccano >=1.5.0, <=1.5.5
     setup_requires=["setuptools_scm"],
     description="A simple client wrapper for the doccano API.",
     long_description="This simple API wrapper allows users to easily get data \


Test patch:
diff --git a/doccano_api_client/beta_pythonic_client/tests/__init__.py b/doccano_api_client/beta_pythonic_client/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/doccano_api_client/beta_pythonic_client/tests/conftest.py b/doccano_api_client/beta_pythonic_client/tests/conftest.py
new file mode 100644
index 0000000..7cddb70
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/conftest.py
@@ -0,0 +1,31 @@
+from typing import Sequence
+
+import pytest
+from _pytest.config import Config, argparsing
+from _pytest.unittest import TestCaseFunction
+
+
+def pytest_addoption(parser: argparsing.Parser) -> None:
+    """Add specified option to pytest cli command."""
+    parser.addoption(
+        "--runintegrationtest",
+        default=False,
+        action="store_true",
+        help="run local integration test",
+    )
+
+
+def pytest_configure(config: Config) -> None:
+    """Add marker value in config. Goes in tandem with the pytest_addoption."""
+    config.addinivalue_line("markers", "localintegrationtest: mark test as local integration test")
+
+
+def pytest_collection_modifyitems(config: Config, items: Sequence[TestCaseFunction]) -> None:
+    """Does not run tests marked as slow when the runslow option is set to true."""
+    if config.getoption("--runintegrationtest"):
+        # --runintegrationtest given in cli: do not skip integration tests
+        return
+    skip_slow = pytest.mark.skip(reason="need --runintegrationtest option to run")
+    for item in items:
+        if "localintegrationtest" in item.keywords:
+            item.add_marker(skip_slow)
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/__init__.py b/doccano_api_client/beta_pythonic_client/tests/controllers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/__init__.py b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/__init__.py
new file mode 100644
index 0000000..5133ec1
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/__init__.py
@@ -0,0 +1,3 @@
+# NOTE: The unit tests that use these mocks should not be testing the actual API/mocks
+#       The purpose is to test functionality AROUND the API. That's why the mocks here are focused
+#       on good responses, not bad ones.
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/annotations.py b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/annotations.py
new file mode 100644
index 0000000..93c7c3a
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/annotations.py
@@ -0,0 +1,56 @@
+import re
+
+import responses
+
+annotations_get_json = {
+    "id": 6,
+    "text": "Sentiment Analysis is neat?",
+    "annotations": [
+        {
+            "id": 1,
+            "user": 1,
+            "created_at": "2021-03-23T15:20:51.971295Z",
+            "updated_at": "2021-03-23T15:20:51.971295Z",
+            "example": 123,
+            "label": 50,
+            "prob": 0.2,
+        },
+        {
+            "id": 2,
+            "user": 1,
+            "created_at": "2021-03-23T15:20:51.971295Z",
+            "updated_at": "2021-03-23T15:20:51.971295Z",
+            "example": 123,
+            "label": 23,
+            "prob": 0.0,
+        },
+    ],
+    "meta": {},
+    "annotation_approver": None,
+    "comment_count": 0,
+}
+
+annotation_create_json = {
+    "id": 2,
+    "user": 1,
+    "created_at": "2021-03-23T15:20:51.971295Z",
+    "updated_at": "2021-03-23T15:20:51.971295Z",
+    "example": 123,
+    "label": 23,
+    "prob": 0.0,
+}
+
+
+annotations_get_response = responses.Response(
+    method="GET",
+    url=re.compile(r".*/v1/projects/\d+/examples/\d+"),
+    json=annotations_get_json,
+    status=200,
+)
+
+annotation_create_response = responses.Response(
+    method="POST",
+    url=re.compile(r".*/v1/projects/\d+/examples"),
+    json=annotation_create_json,
+    status=201,
+)
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/bad.py b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/bad.py
new file mode 100644
index 0000000..a4bf321
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/bad.py
@@ -0,0 +1,15 @@
+import re
+
+import responses
+
+generic_bad_response = {"detail": "Mock 400 Response Hit"}
+
+bad_get_response = responses.Response(
+    method="GET", url=re.compile(".*"), json=generic_bad_response, status=400
+)
+bad_post_response = responses.Response(
+    method="POST", url=re.compile(".*"), json=generic_bad_response, status=400
+)
+bad_put_response = responses.Response(
+    method="PUT", url=re.compile(".*"), json=generic_bad_response, status=400
+)
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/comments.py b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/comments.py
new file mode 100644
index 0000000..782a81d
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/comments.py
@@ -0,0 +1,36 @@
+import re
+
+import responses
+
+from . import projects
+
+comments_get_json = [
+    {
+        "id": 109,
+        "user": 1,
+        "username": "mock_user",
+        "example": 11,
+        "text": "You thought it was a comment text, but it was me, Dio!",
+        "created_at": "2021-07-23T17:29:21.797607Z",
+    },
+    {
+        "id": 108,
+        "user": 1,
+        "username": "mock_user",
+        "example": 11,
+        "text": "General Kenobi",
+        "created_at": "2021-07-23T17:29:14.890131Z",
+    },
+]
+
+comments_regex = (
+    rf".*/v1/projects/{projects.valid_project_ids_regex_insert}/(examples/\d+/)*comments"
+)
+
+comments_get_empty_response = responses.Response(
+    method="GET", url=re.compile(comments_regex), json=[], status=200
+)
+
+comments_get_response = responses.Response(
+    method="GET", url=re.compile(comments_regex), json=comments_get_json, status=200
+)
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/examples.py b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/examples.py
new file mode 100644
index 0000000..5010646
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/examples.py
@@ -0,0 +1,118 @@
+import re
+
+import responses
+
+from . import projects
+
+examples_get_json = {
+    "count": 6,
+    "next": "fake_next_url_should_not_be_used",
+    "previous": None,
+    "results": [
+        {
+            "id": 4,
+            "text": "Sentiment Analysis is pretty cool",
+            "annotations": [],
+            "meta": {},
+            "annotation_approver": None,
+            "comment_count": 0,
+        },
+        {
+            "id": 5,
+            "text": "Sentiment Analysis is meh",
+            "annotations": [],
+            "meta": {},
+            "annotation_approver": None,
+            "comment_count": 0,
+        },
+        {
+            "id": 6,
+            "text": "Sentiment Analysis is neat?",
+            "annotations": [],
+            "meta": {},
+            "annotation_approver": None,
+            "comment_count": 0,
+        },
+    ],
+}
+
+examples_get_json_second_page = {
+    "count": 6,
+    "next": None,
+    "previous": None,
+    "results": [
+        {
+            "id": 7,
+            "text": "Sentiment Analysis is pretty cool",
+            "annotations": [],
+            "meta": {},
+            "annotation_approver": None,
+            "comment_count": 0,
+        },
+        {
+            "id": 8,
+            "text": "Sentiment Analysis is meh",
+            "annotations": [],
+            "meta": {},
+            "annotation_approver": None,
+            "comment_count": 0,
+        },
+        {
+            "id": 9,
+            "text": "Sentiment Analysis is neat?",
+            "annotations": [],
+            "meta": {},
+            "annotation_approver": None,
+            "comment_count": 0,
+        },
+    ],
+}
+
+example_create_json = {
+    "id": 49,
+    "text": "This is an example text2",
+    "annotations": [],
+    "meta": {"key": "val"},
+    "annotation_approver": None,
+    "comment_count": 0,
+}
+
+example_get_json = {
+    "id": 9,
+    "text": "Sentiment Analysis is neat?",
+    "annotations": [],
+    "meta": {},
+    "annotation_approver": None,
+    "comment_count": 0,
+}
+
+examples_regex = f".*/v1/projects/{projects.valid_project_ids_regex_insert}/examples"
+
+examples_get_empty_response = responses.Response(
+    method="GET",
+    url=re.compile(examples_regex),
+    json={"count": 0, "next": None, "results": []},
+    status=200,
+)
+
+examples_get_response = responses.Response(
+    method="GET", url=re.compile(examples_regex), json=examples_get_json, status=200
+)
+
+example_get_response = responses.Response(
+    method="GET",
+    url=re.compile(f"{examples_regex}/{example_get_json['id']}"),
+    json=example_get_json,
+    status=200,
+)
+
+examples_get_response_second_page = responses.Response(
+    method="GET",
+    url=re.compile(examples_regex + ".*?offset=.*"),
+    json=examples_get_json_second_page,
+    status=200,
+)
+
+example_create_response = projects_get_updated_response = responses.Response(
+    method="POST", url=re.compile(examples_regex), json=example_create_json, status=201
+)
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/labels.py b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/labels.py
new file mode 100644
index 0000000..d79d052
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/labels.py
@@ -0,0 +1,59 @@
+import re
+
+import responses
+
+from . import projects
+
+labels_get_json = [
+    {
+        "id": 6,
+        "text": "test_label",
+        "prefix_key": None,
+        "suffix_key": "1",
+        "background_color": "#2196F3",
+        "text_color": "#ffffff",
+    },
+    {
+        "id": 7,
+        "text": "next_test_label",
+        "prefix_key": None,
+        "suffix_key": None,
+        "background_color": "#2196F3",
+        "text_color": "#ffffff",
+    },
+]
+
+label_create_json = {
+    "id": 7,
+    "text": "next_test_label",
+    "prefix_key": None,
+    "suffix_key": None,
+    "background_color": "#2196F3",
+    "text_color": "#ffffff",
+}
+
+labels_regex = f".*/v1/projects/{projects.valid_project_ids_regex_insert}/labels"
+
+labels_get_empty_response = responses.Response(
+    method="GET", url=re.compile(labels_regex), json=[], status=200
+)
+
+labels_get_response = responses.Response(
+    method="GET", url=re.compile(labels_regex), json=labels_get_json, status=200
+)
+
+label_create_response = projects_get_updated_response = responses.Response(
+    method="POST",
+    url=re.compile(labels_regex),
+    json=label_create_json,
+    status=201,
+)
+
+label_update_response = responses.Response(
+    method="PUT",
+    url=re.compile(rf"{labels_regex}/\d+"),
+    # The json here in practice is way more complicated, but we don't need to test or use the
+    # response outside of the status code, so it is moot for testing.
+    json={"status": "accepted"},
+    status=200,
+)
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/projects.py b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/projects.py
new file mode 100644
index 0000000..42caeff
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/mock_api_responses/projects.py
@@ -0,0 +1,230 @@
+import re
+
+import responses
+
+projects_get_json = [
+    {
+        "id": 2,
+        "name": "somethingsomething2",
+        "description": "test 2",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "SequenceLabeling",
+        "updated_at": "2021-02-16T16:26:52.395900Z",
+        "random_order": False,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "SequenceLabelingProject",
+    },
+    {
+        "id": 4,
+        "name": "DoesHTMLWork",
+        "description": "Does it?",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "Seq2seq",
+        "updated_at": "2021-02-18T20:55:17.148231Z",
+        "random_order": False,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "Seq2seqProject",
+    },
+    {
+        "id": 3,
+        "name": "somethingsomething3",
+        "description": "test 3",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "Seq2seq",
+        "updated_at": "2021-02-22T17:18:26.942433Z",
+        "random_order": True,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "Seq2seqProject",
+    },
+    {
+        "id": 5,
+        "name": "DoesHTMLWork2",
+        "description": "Hithere",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "Seq2seq",
+        "updated_at": "2021-02-26T15:21:09.097168Z",
+        "random_order": False,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "Seq2seqProject",
+    },
+    {
+        "id": 1,
+        "name": "somethingsomething",
+        "description": "test 1",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "DocumentClassification",
+        "updated_at": "2021-03-10T22:07:35.323946Z",
+        "random_order": False,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "TextClassificationProject",
+    },
+    {
+        "id": 21,
+        "name": "Test Admin 1",
+        "description": "Test Admin 1",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "DocumentClassification",
+        "updated_at": "2021-03-23T07:01:50.729801Z",
+        "random_order": False,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "TextClassificationProject",
+    },
+    {
+        "id": 22,
+        "name": "Test Admin 2",
+        "description": "something",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "DocumentClassification",
+        "updated_at": "2021-03-23T15:05:42.133855Z",
+        "random_order": False,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "TextClassificationProject",
+    },
+    {
+        "id": 23,
+        "name": "Test Admin 3",
+        "description": "test",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "DocumentClassification",
+        "updated_at": "2021-03-23T15:09:00.433330Z",
+        "random_order": False,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "TextClassificationProject",
+    },
+    {
+        "id": 24,
+        "name": "Test Admin 4",
+        "description": "test",
+        "guideline": "Please write annotation guideline.",
+        "users": [1],
+        "current_users_role": {
+            "is_project_admin": True,
+            "is_annotator": False,
+            "is_annotation_approver": False,
+        },
+        "project_type": "Seq2seq",
+        "updated_at": "2021-03-23T15:20:51.971295Z",
+        "random_order": False,
+        "collaborative_annotation": False,
+        "single_class_classification": False,
+        "resourcetype": "Seq2seqProject",
+    },
+]
+
+project_create_json = {
+    "id": 26,
+    "name": "new client project",
+    "description": "we da best",
+    "guideline": "this kong has a funny face",
+    "users": [1],
+    "current_users_role": {
+        "is_project_admin": True,
+        "is_annotator": False,
+        "is_annotation_approver": False,
+    },
+    "project_type": "SequenceLabeling",
+    "updated_at": "2021-06-03T18:30:16.116377Z",
+    "random_order": False,
+    "collaborative_annotation": False,
+    "single_class_classification": False,
+    "resourcetype": "SequenceLabelingProject",
+}
+
+project_get_json = {
+    "id": 24,
+    "name": "Test Admin 4",
+    "description": "test",
+    "guideline": "Please write annotation guideline.",
+    "users": [1],
+    "current_users_role": {
+        "is_project_admin": True,
+        "is_annotator": False,
+        "is_annotation_approver": False,
+    },
+    "project_type": "Seq2seq",
+    "updated_at": "2021-03-23T15:20:51.971295Z",
+    "random_order": False,
+    "collaborative_annotation": False,
+    "single_class_classification": False,
+    "resourcetype": "Seq2seqProject",
+}
+
+# Used for creating the labels and examples valid mock urls in their respective modules
+valid_project_ids_regex_insert = (
+    "(" + "|".join([str(project["id"]) for project in projects_get_json]) + ")"
+)
+
+projects_get_empty_response = responses.Response(
+    method="GET", url=re.compile(".*/v1/projects"), json=[], status=200
+)
+
+projects_get_response = responses.Response(
+    method="GET", url=re.compile(".*/v1/projects"), json=projects_get_json, status=200
+)
+
+project_get_response = responses.Response(
+    method="GET",
+    url=re.compile(f".*/v1/projects/{project_get_json['id']}"),
+    json=project_get_json,
+    status=200,
+)
+
+project_create_response = responses.Response(
+    method="POST", url=re.compile(".*/v1/projects"), json=project_create_json, status=201
+)
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/test_annotation.py b/doccano_api_client/beta_pythonic_client/tests/controllers/test_annotation.py
new file mode 100644
index 0000000..68c521c
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/test_annotation.py
@@ -0,0 +1,69 @@
+from typing import cast
+from unittest import TestCase
+
+import responses
+from requests import Session
+
+from ...controllers import AnnotationsController
+from ...models import CategoryAnnotation, Project, ProjectTypes
+from ...utils.response import DoccanoAPIError
+from .mock_api_responses import annotations as mocks
+from .mock_api_responses import bad
+
+
+class AnnotationsControllerTest(TestCase):
+    def setUp(self) -> None:
+        self.project = Project(
+            name="my_project",
+            description="Project description",
+            project_type=ProjectTypes.DOCUMENT_CLASSIFICATION,
+        )
+
+        self.annotations_controller = AnnotationsController(
+            example_id=123,
+            project=self.project,
+            example_url="http://api/v1/projects/123/examples/123",
+            client_session=Session(),
+        )
+
+    @responses.activate
+    def test_all(self) -> None:
+        responses.add(mocks.annotations_get_response)
+        annotation_controllers = list(self.annotations_controller.all())
+
+        expected_annotations_id_map = {
+            annotation_json["id"]: annotation_json
+            for annotation_json in mocks.annotations_get_json["annotations"]
+        }
+
+        for annotation_controller in annotation_controllers:
+            annotation_id = annotation_controller.id
+            annotation = cast(CategoryAnnotation, annotation_controller.annotation)
+            self.assertIn(annotation_id, expected_annotations_id_map)
+            self.assertEqual(annotation.label, expected_annotations_id_map[annotation_id]["label"])
+            self.assertEqual(annotation.prob, expected_annotations_id_map[annotation_id]["prob"])
+            self.assertEqual(
+                annotation_controller.user, expected_annotations_id_map[annotation_id]["user"]
+            )
+            self.assertEqual(
+                annotation_controller.created_at,
+                expected_annotations_id_map[annotation_id]["created_at"],
+            )
+            self.assertEqual(
+                annotation_controller.updated_at,
+                expected_annotations_id_map[annotation_id]["updated_at"],
+            )
+            self.assertEqual(
+                annotation_controller.example,
+                expected_annotations_id_map[annotation_id]["example"],
+            )
+
+        self.assertEqual(
+            len(annotation_controllers), len(mocks.annotations_get_json["annotations"])
+        )
+
+    @responses.activate
+    def test_all_with_bad_response(self) -> None:
+        responses.add(bad.bad_get_response)
+        with self.assertRaises(DoccanoAPIError):
+            list(self.annotations_controller.all())
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/test_comment.py b/doccano_api_client/beta_pythonic_client/tests/controllers/test_comment.py
new file mode 100644
index 0000000..75e57c4
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/test_comment.py
@@ -0,0 +1,114 @@
+from unittest import TestCase
+
+import responses
+from requests import Session
+
+from ...controllers import CommentController, CommentsController
+from ...models import Comment
+from ...utils.response import DoccanoAPIError
+from .mock_api_responses import bad
+from .mock_api_responses import comments as mocks
+
+
+class CommentControllerTest(TestCase):
+    def setUp(self):
+        self.comment_a = Comment(text="my text")
+        self.comment_controller_a = CommentController(
+            comment=self.comment_a,
+            id=43,
+            username="kenobi",
+            created_at="sometimestamp",
+            example=11,
+            comments_url="http://my_comments_url",
+            client_session=Session(),
+        )
+
+    def test_urls(self):
+        self.assertEqual(self.comment_controller_a.comment_url, "http://my_comments_url/43")
+
+
+class CommentsControllerTest(TestCase):
+    def setUp(self):
+        self.comments_controller_from_example = CommentsController(
+            "http://my_comments_url/v1/projects/23/examples/11", Session()
+        )
+        self.comments_controller_from_project = CommentsController(
+            "http://my_comments_url/v1/projects/23", Session()
+        )
+
+    def test_controller_urls(self):
+        self.assertEqual(
+            self.comments_controller_from_example.comments_url,
+            "http://my_comments_url/v1/projects/23/examples/11/comments",
+        )
+        self.assertEqual(
+            self.comments_controller_from_project.comments_url,
+            "http://my_comments_url/v1/projects/23/comments",
+        )
+
+    @responses.activate
+    def test_all_with_no_comments_from_example(self):
+        responses.add(mocks.comments_get_empty_response)
+        comment_controllers = self.comments_controller_from_example.all()
+        self.assertEqual(len(list(comment_controllers)), 0)
+
+    @responses.activate
+    def test_all_with_no_comments_from_project(self):
+        responses.add(mocks.comments_get_empty_response)
+        comment_controllers = self.comments_controller_from_project.all()
+        self.assertEqual(len(list(comment_controllers)), 0)
+
+    @responses.activate
+    def test_all_from_example(self):
+        responses.add(mocks.comments_get_response)
+        comment_controllers = self.comments_controller_from_example.all()
+
+        total_comments = 0
+        expected_comment_id_dict = {
+            comment_json["id"]: comment_json for comment_json in mocks.comments_get_json
+        }
+        for comment_controller in comment_controllers:
+            self.assertIn(comment_controller.id, expected_comment_id_dict)
+            self.assertEqual(
+                comment_controller.comment.text,
+                expected_comment_id_dict[comment_controller.id]["text"],
+            )
+            self.assertIs(
+                comment_controller.client_session,
+                self.comments_controller_from_example.client_session,
+            )
+            total_comments += 1
+
+        self.assertEqual(total_comments, len(mocks.comments_get_json))
+
+    @responses.activate
+    def test_all_from_project(self):
+        responses.add(mocks.comments_get_response)
+        comment_controllers = self.comments_controller_from_project.all()
+
+        total_comments = 0
+        expected_comment_id_dict = {
+            comment_json["id"]: comment_json for comment_json in mocks.comments_get_json
+        }
+        for comment_controller in comment_controllers:
+            self.assertIn(comment_controller.id, expected_comment_id_dict)
+            self.assertEqual(
+                comment_controller.comment.text,
+                expected_comment_id_dict[comment_controller.id]["text"],
+            )
+            self.assertIs(
+                comment_controller.client_session,
+                self.comments_controller_from_project.client_session,
+            )
+            total_comments += 1
+
+        self.assertEqual(total_comments, len(mocks.comments_get_json))
+
+    @responses.activate
+    def test_all_with_bad_response(self):
+        responses.add(bad.bad_get_response)
+        with self.assertRaises(DoccanoAPIError):
+            list(self.comments_controller_from_example.all())
+
+        with self.assertRaises(DoccanoAPIError):
+            list(self.comments_controller_from_project.all())
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/test_example.py b/doccano_api_client/beta_pythonic_client/tests/controllers/test_example.py
new file mode 100644
index 0000000..800bb87
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/test_example.py
@@ -0,0 +1,170 @@
+from unittest import TestCase
+
+import responses
+from requests import Session
+
+from ...controllers import ExampleController, ExamplesController
+from ...models import CategoryAnnotation, Example, Project, ProjectTypes, SpanAnnotation
+from ...utils.response import DoccanoAPIError
+from .mock_api_responses import annotations as annotation_mocks
+from .mock_api_responses import bad
+from .mock_api_responses import examples as mocks
+
+
+class ExampleControllerTest(TestCase):
+    def setUp(self):
+        project = Project(
+            name="my-project",
+            description="project description",
+            project_type=ProjectTypes.DOCUMENT_CLASSIFICATION,
+        )
+        self.example_a = Example(text="my text")
+        self.example_controller_a = ExampleController(
+            project=project,
+            example=self.example_a,
+            id=43,
+            examples_url="http://my_example_url",
+            client_session=Session(),
+        )
+
+    def test_urls(self):
+        self.assertEqual(self.example_controller_a.example_url, "http://my_example_url/43")
+
+
+class ExamplesControllerTest(TestCase):
+    def setUp(self):
+        project = Project(
+            name="my-project",
+            description="project description",
+            project_type=ProjectTypes.DOCUMENT_CLASSIFICATION,
+        )
+        self.example_a = Example(text="This is an example text2", meta={"key": "val"})
+        self.examples_controller = ExamplesController(
+            project, "http://my_examples_url/v1/projects/23", Session()
+        )
+
+    def test_controller_urls(self):
+        self.assertEqual(
+            self.examples_controller.examples_url, "http://my_examples_url/v1/projects/23/examples"
+        )
+
+    @responses.activate
+    def test_rejects_incorrect_annotation_type(self) -> None:
+        """Only annotations of the appropriate type should be allowed."""
+        responses.add(bad.bad_post_response)
+        with self.assertRaises(DoccanoAPIError):
+            annotation = SpanAnnotation(label=1, prob=1.0, start_offset=0, end_offset=10)
+            example = Example(text="blablabla", annotations=[annotation])
+            self.examples_controller.create(example)
+
+    @responses.activate
+    def test_create_annotations(self) -> None:
+        responses.add(annotation_mocks.annotation_create_response)
+        responses.add(annotation_mocks.annotations_get_response)
+        annotation = CategoryAnnotation(label=23, prob=0)
+        example = Example(text="blablabla", annotations=[annotation])
+        example_controller = self.examples_controller.create(example)
+
+        # Client should choose the correct annotation serializer for creation given the project type
+        self.assertTrue(
+            isinstance(list(example_controller.annotations.all())[1].annotation, CategoryAnnotation)
+        )
+
+        # created_annotation = cast(CategoryAnnotation, annotation_controller.annotation)
+        self.assertEqual(
+            list(example_controller.annotations.all())[1].id,
+            annotation_mocks.annotation_create_json["id"],
+        )
+        self.assertEqual(
+            list(example_controller.annotations.all())[1].annotation.label,
+            annotation_mocks.annotation_create_json["label"],
+        )
+
+    @responses.activate
+    def test_create_annotations_with_bad_response(self) -> None:
+        responses.add(bad.bad_post_response)
+        with self.assertRaises(DoccanoAPIError):
+            example = Example(text="blablabla", annotations=[CategoryAnnotation(label=1, prob=0)])
+            self.examples_controller.create(example)
+
+    @responses.activate
+    def test_count_empty(self):
+        responses.add(mocks.examples_get_empty_response)
+        examples_count = self.examples_controller.count()
+        self.assertEqual(examples_count, 0)
+
+    @responses.activate
+    def test_count_nonempty(self):
+        responses.add(mocks.examples_get_response)
+        examples_count = self.examples_controller.count()
+        self.assertEqual(examples_count, 6)
+
+    @responses.activate
+    def test_all_with_no_examples(self):
+        responses.add(mocks.examples_get_empty_response)
+        example_controllers = self.examples_controller.all()
+        self.assertEqual(len(list(example_controllers)), 0)
+
+    @responses.activate
+    def test_all(self):
+        responses.add(mocks.examples_get_response)
+        responses.add(mocks.examples_get_response_second_page)
+        responses.add(mocks.examples_get_empty_response)
+        example_controllers = self.examples_controller.all()
+
+        total_examples = 0
+        expected_example_id_dict = {
+            example_json["id"]: example_json
+            for example_json in mocks.examples_get_json["results"]
+            + mocks.examples_get_json_second_page["results"]
+        }
+        for example_controller in example_controllers:
+            self.assertIn(example_controller.id, expected_example_id_dict)
+            self.assertEqual(
+                example_controller.example.text,
+                expected_example_id_dict[example_controller.id]["text"],
+            )
+            self.assertIs(
+                example_controller.client_session, self.examples_controller.client_session
+            )
+            total_examples += 1
+
+        self.assertEqual(
+            total_examples,
+            len(
+                mocks.examples_get_json["results"] + mocks.examples_get_json_second_page["results"]
+            ),
+        )
+        self.assertEqual(total_examples, mocks.examples_get_json["count"])
+
+    @responses.activate
+    def test_all_with_bad_response(self):
+        responses.add(bad.bad_get_response)
+        with self.assertRaises(DoccanoAPIError):
+            list(self.examples_controller.all())
+
+    @responses.activate
+    def test_create(self):
+        responses.add(mocks.example_create_response)
+        example_a_controller = self.examples_controller.create(self.example_a)
+
+        self.assertEqual(example_a_controller.id, mocks.example_create_json["id"])
+        self.assertEqual(example_a_controller.example.text, mocks.example_create_json["text"])
+        self.assertEqual(example_a_controller.example.meta, {"key": "val"})
+
+    @responses.activate
+    def test_create_with_bad_response(self):
+        responses.add(bad.bad_post_response)
+        with self.assertRaises(DoccanoAPIError):
+            self.examples_controller.create(self.example_a)
+
+    @responses.activate
+    def test_get(self) -> None:
+        responses.add(mocks.example_get_response)
+
+        example_id = mocks.example_get_json["id"]
+        example_controller = self.examples_controller.get(example_id)
+
+        self.assertEqual(example_controller.id, example_id)
+        self.assertEqual(example_controller.project, self.examples_controller.project)
+        self.assertEqual(example_controller.example.text, mocks.example_get_json["text"])
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/test_label.py b/doccano_api_client/beta_pythonic_client/tests/controllers/test_label.py
new file mode 100644
index 0000000..a5d63e0
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/test_label.py
@@ -0,0 +1,221 @@
+from unittest import TestCase
+
+import responses
+from requests import Session
+
+from ...controllers import LabelController, LabelsController
+from ...controllers.label import LabelGenerator
+from ...models import LABEL_COLOR_CYCLE, Label
+from ...utils.response import DoccanoAPIError
+from .mock_api_responses import bad
+from .mock_api_responses import labels as mocks
+
+
+class LabelGeneratorTest(TestCase):
+    def setUp(self):
+        self.label_generator = LabelGenerator()
+
+    def test_next_label_color(self):
+        # Test color cycle once through
+        for color in LABEL_COLOR_CYCLE:
+            next_color = self.label_generator.next_label_color()
+            self.assertEqual(next_color, color)
+
+    def test_next_label_color_eventually_repeats_color(self):
+        # Test color cycle once through
+        for _ in LABEL_COLOR_CYCLE:
+            self.label_generator.next_label_color()
+
+        # Test that it starts back at the beginning after looping
+        self.assertEqual(self.label_generator.next_label_color(), LABEL_COLOR_CYCLE[0])
+
+    def test_next_label_shortcut_on_empty(self):
+        with self.assertRaises(AssertionError):
+            self.label_generator.next_label_shortcut("")
+
+    def test_next_label_shortcut(self):
+        text = "my_text"
+        expected_shortcut = text.lower()[0]
+
+        # Test that the first new shortcut is the first letter of the word
+        shortcut = self.label_generator.next_label_shortcut(text)
+        self.assertEqual(shortcut.prefix_key, None)
+        self.assertEqual(shortcut.suffix_key, expected_shortcut)
+
+        text = "my_different_text_but_same_first_letter"
+
+        # The next generated shortcut on the same letter in the cycle includes a shift key
+        shortcut = self.label_generator.next_label_shortcut(text)
+        self.assertEqual(shortcut.prefix_key, "shift")
+        self.assertEqual(shortcut.suffix_key, expected_shortcut)
+
+        # The next generated on the same letter cycle through the digits as shortcuts
+        for next_number in range(0, 10):
+            shortcut = self.label_generator.next_label_shortcut(text)
+            self.assertEqual(shortcut.prefix_key, None)
+            self.assertEqual(shortcut.suffix_key, str(next_number))
+
+        # The next generated after the digits is the digits with a shift key
+        for next_number in range(0, 10):
+            shortcut = self.label_generator.next_label_shortcut(text)
+            self.assertEqual(shortcut.prefix_key, "shift")
+            self.assertEqual(shortcut.suffix_key, str(next_number))
+        # Check to make sure that, on many repeats, it will still return None when available
+        # shortcuts have run out
+        for _ in range(0, 20):
+            shortcut = self.label_generator.next_label_shortcut(text)
+            self.assertEqual(shortcut.prefix_key, None)
+            self.assertEqual(shortcut.suffix_key, None)
+
+        # But another first letter is still available
+        second_text = "different_text"
+        expected_shortcut = second_text.lower()[0]
+        shortcut = self.label_generator.next_label_shortcut(second_text)
+        self.assertEqual(shortcut.prefix_key, None)
+        self.assertEqual(shortcut.suffix_key, expected_shortcut)
+
+    def test_next_label(self):
+        stock_label = Label(text="my text")
+        self.assertEqual(stock_label.suffix_key, None)
+        self.assertEqual(stock_label.prefix_key, None)
+        self.assertEqual(stock_label.background_color, LABEL_COLOR_CYCLE[0])
+
+        updated_label_a = self.label_generator.next_label(stock_label)
+        self.assertEqual(updated_label_a.text, stock_label.text)
+        self.assertEqual(updated_label_a.prefix_key, None)
+        self.assertEqual(updated_label_a.suffix_key, "m")
+        self.assertEqual(updated_label_a.background_color, LABEL_COLOR_CYCLE[0])
+
+        updated_label_b = self.label_generator.next_label(stock_label)
+        self.assertEqual(updated_label_b.text, stock_label.text)
+        self.assertEqual(updated_label_b.prefix_key, "shift")
+        self.assertEqual(updated_label_b.suffix_key, "m")
+        self.assertEqual(updated_label_b.background_color, LABEL_COLOR_CYCLE[1])
+
+    def test_new_generator_refreshes_all(self):
+        stock_label = Label(text="my text")
+        self.assertEqual(stock_label.suffix_key, None)
+        self.assertEqual(stock_label.prefix_key, None)
+        self.assertEqual(stock_label.background_color, LABEL_COLOR_CYCLE[0])
+
+        updated_label_a = self.label_generator.next_label(stock_label)
+        self.assertEqual(updated_label_a.text, stock_label.text)
+        self.assertEqual(updated_label_a.prefix_key, None)
+        self.assertEqual(updated_label_a.suffix_key, "m")
+        self.assertEqual(updated_label_a.background_color, LABEL_COLOR_CYCLE[0])
+
+        self.label_generator = LabelGenerator()
+        updated_label_c = self.label_generator.next_label(stock_label)
+        self.assertEqual(updated_label_c, updated_label_a)
+
+
+class LabelControllerTest(TestCase):
+    def setUp(self):
+        self.label_a = Label(text="my text")
+        self.label_controller_a = LabelController(
+            label=self.label_a, id=43, labels_url="http://my_labels_url", client_session=Session()
+        )
+
+    def test_urls(self):
+        self.assertEqual(self.label_controller_a.label_url, "http://my_labels_url/43")
+
+
+class LabelsControllerTest(TestCase):
+    def setUp(self):
+        self.label_a = Label(text="next_test_label")
+        self.label_controller_a = LabelController(
+            label=self.label_a,
+            id=43,
+            labels_url="http://my_labels_url/v1/projects/23",
+            client_session=Session(),
+        )
+        self.labels_controller = LabelsController("http://my_labels_url/v1/projects/23", Session())
+
+    def test_controller_urls(self):
+        self.assertEqual(
+            self.labels_controller.labels_url, "http://my_labels_url/v1/projects/23/labels"
+        )
+
+    @responses.activate
+    def test_all_with_no_labels(self):
+        responses.add(mocks.labels_get_empty_response)
+        label_controllers = self.labels_controller.all()
+        self.assertEqual(len(list(label_controllers)), 0)
+
+    @responses.activate
+    def test_all(self):
+        responses.add(mocks.labels_get_response)
+        label_controllers = self.labels_controller.all()
+
+        total_labels = 0
+        expected_label_id_dict = {
+            label_json["id"]: label_json for label_json in mocks.labels_get_json
+        }
+        for label_controller in label_controllers:
+            self.assertIn(label_controller.id, expected_label_id_dict)
+            self.assertEqual(
+                label_controller.label.text, expected_label_id_dict[label_controller.id]["text"]
+            )
+            self.assertEqual(
+                label_controller.label.suffix_key,
+                expected_label_id_dict[label_controller.id]["suffix_key"],
+            )
+            self.assertIs(label_controller.client_session, self.labels_controller.client_session)
+            total_labels += 1
+
+        self.assertEqual(total_labels, len(mocks.labels_get_json))
+
+    @responses.activate
+    def test_all_with_bad_response(self):
+        responses.add(bad.bad_get_response)
+        with self.assertRaises(DoccanoAPIError):
+            list(self.labels_controller.all())
+
+    @responses.activate
+    def test_create(self):
+        responses.add(mocks.label_create_response)
+        label_a_controller = self.labels_controller.create(self.label_a)
+
+        self.assertEqual(label_a_controller.id, mocks.label_create_json["id"])
+        self.assertEqual(label_a_controller.label.text, mocks.label_create_json["text"])
+
+    @responses.activate
+    def test_create_with_bad_response(self):
+        responses.add(bad.bad_post_response)
+        with self.assertRaises(DoccanoAPIError):
+            list(self.labels_controller.create(self.label_a))
+
+    @responses.activate
+    def test_all_regenerated_with_no_labels(self):
+        responses.add(mocks.labels_get_empty_response)
+        label_controllers = self.labels_controller.all_regenerated()
+        self.assertEqual(len(list(label_controllers)), 0)
+
+    @responses.activate
+    def test_all_regenerated(self):
+        responses.add(mocks.labels_get_response)
+        label_controllers = self.labels_controller.all_regenerated()
+
+        next_color_cyle_index = 0
+        for label_controller in label_controllers:
+            self.assertEqual(
+                label_controller.label.suffix_key, label_controller.label.text.lower()[0]
+            )
+            self.assertEqual(
+                label_controller.label.background_color, LABEL_COLOR_CYCLE[next_color_cyle_index]
+            )
+
+            next_color_cyle_index += 1
+
+    @responses.activate
+    def test_update(self):
+        responses.add(mocks.labels_get_response)
+        responses.add(mocks.label_update_response)
+        label_controllers = self.labels_controller.all_regenerated()
+        self.labels_controller.update(label_controllers)
+
+    @responses.activate
+    def test_update_with_bad_response(self):
+        responses.add(bad.bad_put_response)
+        with self.assertRaises(DoccanoAPIError):
+            list(self.labels_controller.update([self.label_controller_a]))
diff --git a/doccano_api_client/beta_pythonic_client/tests/controllers/test_project.py b/doccano_api_client/beta_pythonic_client/tests/controllers/test_project.py
new file mode 100644
index 0000000..67ea21e
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/controllers/test_project.py
@@ -0,0 +1,136 @@
+from unittest import TestCase
+
+import responses
+from requests import Session
+
+from ...controllers import ProjectController, ProjectsController
+from ...models import Project
+from ...utils.response import DoccanoAPIError
+from .mock_api_responses import bad
+from .mock_api_responses import projects as mocks
+
+
+class ProjectControllerTest(TestCase):
+    def setUp(self):
+        self.project_a = Project(
+            name=mocks.project_create_json["name"],
+            description=mocks.project_create_json["description"],
+            project_type=mocks.project_create_json["project_type"],
+        )
+        self.project_controller_a = ProjectController(
+            project=self.project_a,
+            id=42,
+            projects_url="http://my_projects_url",
+            client_session=Session(),
+        )
+
+    def test_rejects_improper_project_type(self):
+        with self.assertRaises(AssertionError):
+            Project(
+                name=mocks.project_create_json["name"],
+                description=mocks.project_create_json["description"],
+                project_type="bad_project_type",
+            )
+
+    def test_urls(self):
+        self.assertEqual(self.project_controller_a.project_url, "http://my_projects_url/42")
+
+    def test_labels(self):
+        labels = self.project_controller_a.labels
+
+        # Disable protected-access check for test case
+        self.assertEqual(
+            labels._project_url,  # pylint: disable=protected-access
+            self.project_controller_a.project_url,
+        )
+        self.assertIs(labels.client_session, self.project_controller_a.client_session)
+
+    def test_examples(self):
+        examples = self.project_controller_a.examples
+
+        # Disable protected-access check for test case
+        self.assertEqual(
+            examples._project_url,  # pylint: disable=protected-access
+            self.project_controller_a.project_url,
+        )
+        self.assertIs(examples.client_session, self.project_controller_a.client_session)
+
+
+class ProjectsControllerTest(TestCase):
+    def setUp(self):
+        self.project_a = Project(
+            name=mocks.project_create_json["name"],
+            description=mocks.project_create_json["description"],
+            project_type=mocks.project_create_json["project_type"],
+        )
+        self.projects_controller = ProjectsController("http://my_api_url/v1", Session())
+
+    def test_controller_urls(self):
+        self.assertEqual(self.projects_controller.projects_url, "http://my_api_url/v1/projects")
+
+    @responses.activate
+    def test_all_with_no_projects(self):
+        responses.add(mocks.projects_get_empty_response)
+        project_controllers = self.projects_controller.all()
+        self.assertEqual(len(list(project_controllers)), 0)
+
+    @responses.activate
+    def test_all(self):
+        responses.add(mocks.projects_get_response)
+        project_controllers = self.projects_controller.all()
+
+        total_projects = 0
+        expected_project_id_dict = {
+            proj_json["id"]: proj_json for proj_json in mocks.projects_get_json
+        }
+        for project_controller in project_controllers:
+            self.assertIn(project_controller.id, expected_project_id_dict)
+            self.assertEqual(
+                project_controller.project.name,
+                expected_project_id_dict[project_controller.id]["name"],
+            )
+            self.assertEqual(
+                project_controller.project.description,
+                expected_project_id_dict[project_controller.id]["description"],
+            )
+            self.assertIs(
+                project_controller.client_session, self.projects_controller.client_session
+            )
+            total_projects += 1
+
+        self.assertEqual(total_projects, len(mocks.projects_get_json))
+
+    @responses.activate
+    def test_all_with_bad_response(self):
+        responses.add(bad.bad_get_response)
+        with self.assertRaises(DoccanoAPIError):
+            list(self.projects_controller.all())
+
+    @responses.activate
+    def test_create(self):
+        responses.add(mocks.project_create_response)
+        project_a_controller = self.projects_controller.create(self.project_a)
+
+        self.assertEqual(project_a_controller.id, mocks.project_create_json["id"])
+        self.assertEqual(project_a_controller.project.name, mocks.project_create_json["name"])
+
+    @responses.activate
+    def test_create_with_bad_response(self):
+        responses.add(bad.bad_post_response)
+        with self.assertRaises(DoccanoAPIError):
+            list(self.projects_controller.create(self.project_a))
+
+    @responses.activate
+    def test_get(self) -> None:
+        responses.add(mocks.project_get_response)
+
+        project_id = mocks.project_get_json["id"]
+        project_controller = self.projects_controller.get(project_id)
+
+        self.assertEqual(project_controller.id, project_id)
+        self.assertEqual(
+            project_controller.project_url, f"http://my_api_url/v1/projects/{project_id}"
+        )
+        self.assertEqual(
+            project_controller.project.project_type, mocks.project_get_json["project_type"]
+        )
diff --git a/doccano_api_client/beta_pythonic_client/tests/test_client.py b/doccano_api_client/beta_pythonic_client/tests/test_client.py
new file mode 100644
index 0000000..824d231
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/test_client.py
@@ -0,0 +1,69 @@
+import json
+from unittest import TestCase
+
+import responses
+
+from ..client import Client
+from ..models import Example
+from ..utils.response import DoccanoAPIError
+from .controllers.mock_api_responses import examples as mock_examples
+from .controllers.mock_api_responses import projects as mock_projects
+
+
+class ClientTest(TestCase):
+    def setUp(self):
+        self.client = Client("https://doccano.notaningress.com")
+
+    def test_url_properties(self):
+        self.assertEqual(self.client.login_url, "https://doccano.notaningress.com/v1/auth/login/")
+        self.assertEqual(self.client.api_url, "https://doccano.notaningress.com/v1")
+
+    @responses.activate
+    def test_client_login(self):
+        def request_callback(request):
+            payload = json.loads(request.body)
+            headers = {"request-id": "unique_id_a"}
+            if payload["username"] != "good_username" or payload["password"] != "good_password":
+                return (403, headers, json.dumps(payload))
+
+            response_body = {"key": "valid_token"}
+            return (200, headers, json.dumps(response_body))
+
+        responses.add_callback(
+            responses.POST,
+            "https://doccano.notaningress.com/v1/auth/login/",
+            callback=request_callback,
+            content_type="application/json",
+        )
+
+        context = None
+        with self.assertRaises(DoccanoAPIError) as context:
+            self.client.login("some_user", "some_pass")
+
+        self.assertEqual(context.exception.response.status_code, 403)
+
+        self.client.login("good_username", "good_password")
+        self.assertEqual(len(responses.calls), 2)
+        self.assertEqual(responses.calls[1].response.status_code, 200)
+
+    def test_client_projects(self):
+        projects = self.client.projects
+
+        self.assertIs(projects.client_session, self.client.client_session)
+        # Disable protected-access check for test case
+        self.assertEqual(projects._api_url, self.client.api_url)  # pylint: disable=protected-access
+
+    @responses.activate
+    def test_can_access_nested_project_elements(self):
+        responses.add(mock_projects.projects_get_response)
+        responses.add(mock_examples.example_create_response)
+        create_example = Example(text="This is a test example")
+
+        my_project_controller = next(
+            proj_controller
+            for proj_controller in self.client.projects.all()
+            if proj_controller.project.name == "somethingsomething3"
+        )
+        created_example = my_project_controller.examples.create(create_example)
+
+        self.assertEqual(created_example.id, 49)
diff --git a/doccano_api_client/beta_pythonic_client/tests/test_full_integration_tests.py b/doccano_api_client/beta_pythonic_client/tests/test_full_integration_tests.py
new file mode 100644
index 0000000..a1dc343
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/test_full_integration_tests.py
@@ -0,0 +1,90 @@
+from unittest import TestCase
+
+import pytest
+
+from ..client import Client
+from ..models import Example, Label, Project
+
+DOCCANO_ENDPOINT = "http://0.0.0.0"  # TODO: Make this a pytest parameter
+
+
+@pytest.mark.localintegrationtest
+class IntegrationTests(TestCase):
+    """Only works if Doccano is spinned up locally and accessible at 0.0.0.0
+
+    Goal is to test integration of the client fully with the Doccano project,
+    for instance in case of API breaking changes."""
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.client = Client(DOCCANO_ENDPOINT)
+        cls.client.login("admin", "password")
+        cls.projects_controller = cls.client.projects
+
+        # Create a project
+        cls.project = Project(
+            name="Test Project",
+            description="Test project",
+            project_type="SequenceLabeling",
+        )
+        cls.project_controller = cls.client.projects.create(cls.project)
+        cls.new_project_id = cls.project_controller.id
+
+        # Create a example and associate it with the project
+        cls.example = Example(text="Netflix and Amazon are both big tech companies.")
+        cls.examples_controller = cls.project_controller.examples
+
+        cls.example_controller = cls.examples_controller.create(cls.example)
+        cls.new_example_id = cls.example_controller.id
+
+        cls.label = Label(text="company")
+        cls.label_controller = cls.project_controller.labels.create(cls.label)
+
+        # Generate a comment for the example.
+        # Currently, we do not have a CommentsController.create method, which is
+        # by design. For the sake of integration test simulations, however,
+        # we can still use the API to manually create them.
+        cls.example_controller.client_session.post(
+            cls.example_controller.comments.comments_url, json={"text": "hi there i'm a comment"}
+        )
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.project_controller.client_session.delete(cls.project_controller.project_url)
+
+    def test_project_got_created(self) -> None:
+        project_controllers = self.projects_controller.all()
+        self.assertNotEqual(len(list(project_controllers)), 0)
+
+    def test_projects_controller_get(self) -> None:
+        # Id should be 1 since it's the only project
+        project_controller = self.projects_controller.get(self.new_project_id)
+        self.assertEqual(project_controller, self.project_controller)
+
+    def test_examples_comments_annotations_controllers(self) -> None:
+        examples = self.examples_controller.all()
+        self.assertEqual(len(list(examples)), 1)
+        n_examples = self.examples_controller.count()
+        self.assertEqual(n_examples, 1)
+        example = self.examples_controller.get(self.new_example_id)
+
+        example_with_expected_comments = self.example
+        example_with_expected_comments.comment_count = 1
+
+        self.assertEqual(example.example, self.example)
+
+        comments_controller = example.comments
+        comments = list(comments_controller.all())
+        self.assertEqual(comments[0].comment.text, "hi there i'm a comment")
+
+        # TODO: Right now, we can't create annotations intuitively in the API. Doccano v1.6.0
+        #       should once it's released. Add annotations creation to integration tests and
+        #       test structure so that we're exhaustively checking that endpoint.
+        annotations_controller = example.annotations
+        annotations = list(annotations_controller.all())
+        self.assertEqual(len(annotations), 0)
+
+    def test_labels_controller(self) -> None:
+        expected_label = Label(text="company")
+        label_controllers = list(self.project_controller.labels.all())
+        self.assertEqual(expected_label, label_controllers[0].label)
diff --git a/doccano_api_client/beta_pythonic_client/tests/utils/__init__.py b/doccano_api_client/beta_pythonic_client/tests/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/doccano_api_client/beta_pythonic_client/tests/utils/test_response.py b/doccano_api_client/beta_pythonic_client/tests/utils/test_response.py
new file mode 100644
index 0000000..5a0dad1
--- /dev/null
+++ b/doccano_api_client/beta_pythonic_client/tests/utils/test_response.py
@@ -0,0 +1,26 @@
+import re
+from unittest import TestCase
+
+import requests
+import responses
+
+from ...utils.response import DoccanoAPIError, verbose_raise_for_status
+
+
+class ResponseTest(TestCase):
+    @responses.activate
+    def test_verbose_raise_for_status_does_nothing_for_good_status(self):
+        responses.add("GET", url=re.compile(".*"), json={"detail": "something"}, status=200)
+        resp = requests.get("http://mockurlplace.com")
+        self.assertEqual(verbose_raise_for_status(resp), resp)
+
+    @responses.activate
+    def test_verbose_raise_for_status_adds_new_details(self):
+        responses.add("GET", url=re.compile(".*"), json={"detail": "something bad"}, status=400)
+        resp = requests.get("http://mockurlplace.com")
+
+        context = None
+        with self.assertRaises(DoccanoAPIError) as context:
+            verbose_raise_for_status(resp)
+
+        self.assertEqual(str(context.exception), "{'detail': 'something bad'}")