Repository: cihai/unihan-db (language: python)

Issue:
`mypy`: `--strict` type checking
https://github.com/cihai/unihan-etl/pull/257, **https://github.com/cihai/unihan-db/pull/298**, https://github.com/cihai/cihai/pull/324, https://github.com/cihai/cihai-cli/pull/277

Ramp up type safety / create a typed library


Discussion / hints:

Fix patch:
diff --git a/CHANGES b/CHANGES
index 4ce86691..df482934 100644
--- a/CHANGES
+++ b/CHANGES
@@ -13,8 +13,31 @@ $ pip install --user --upgrade --pre unihan-db
 
 <!-- Maintainers, insert changes / features for the next release here -->
 
+## Development
+
+- **Improved typings**
+
+  Move to strict mypy typings (#311)
+
+  This will make future refactoring simplifications easier and maintain code
+  quality in the long term, in addition to more intelligent completions.
+
+  [`mypy --strict`]: https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict
+
 ### Breaking
 
+- SQLAlchemy: Upgraded to v2 (#311)
+
+  Downstream packages will require SQLAlchemy v2 at a minimum.
+
+  Benefits in include: Built-in types for mypy, being able to use SQLAlchemy
+  core API against ORM entities.
+
+  See also: [What's new in SQLAlchemy
+  2.0](https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html),
+  [Migrating to SQLAlchemy
+  2.0](https://docs.sqlalchemy.org/en/20/changelog/migration_20.html)
+
 - **Python 3.7 Dropped**
 
   Python 3.7 support has been dropped (#309)
@@ -39,6 +62,7 @@ _Maintenance only, no bug fixes or features_
   formatting can be done almost instantly.
 
   This change replaces isort, flake8 and flake8 plugins.
+
 - poetry: 1.4.0 -> 1.5.0
 
   See also: https://github.com/python-poetry/poetry/releases/tag/1.5.0
diff --git a/docs/conf.py b/docs/conf.py
index b4c77499..759f2a09 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -171,7 +171,9 @@
 }
 
 
-def linkcode_resolve(domain, info):  # NOQA: C901
+def linkcode_resolve(
+    domain: str, info: t.Dict[str, str]
+) -> t.Union[None, str]:  # NOQA: C901
     """
     Determine the URL corresponding to Python object
 
@@ -204,7 +206,8 @@ def linkcode_resolve(domain, info):  # NOQA: C901
     except AttributeError:
         pass
     else:
-        obj = unwrap(obj)
+        if callable(obj):
+            obj = unwrap(obj)
 
     try:
         fn = inspect.getsourcefile(obj)
diff --git a/examples/01_bootstrap.py b/examples/01_bootstrap.py
index a4474221..627bbebd 100755
--- a/examples/01_bootstrap.py
+++ b/examples/01_bootstrap.py
@@ -10,8 +10,10 @@
 
 bootstrap.bootstrap_unihan(session)
 
-random_row = session.query(Unhn).order_by(func.random()).limit(1).first()
+random_row_query = session.query(Unhn).order_by(func.random()).limit(1)
 
-pp = pprint.PrettyPrinter(indent=0)
+assert random_row_query is not None
 
-pp.pprint(random_row.to_dict())
+random_row = random_row_query.first()
+
+pprint.pprint(bootstrap.to_dict(random_row))
diff --git a/poetry.lock b/poetry.lock
index e0cae4e6..63a5c299 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1243,93 +1243,81 @@ sphinx = ">=2.0"
 
 [[package]]
 name = "sqlalchemy"
-version = "1.4.48"
+version = "2.0.15"
 description = "Database Abstraction Library"
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
-files = [
-    {file = "SQLAlchemy-1.4.48-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4bac3aa3c3d8bc7408097e6fe8bf983caa6e9491c5d2e2488cfcfd8106f13b6a"},
-    {file = "SQLAlchemy-1.4.48-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dbcae0e528d755f4522cad5842f0942e54b578d79f21a692c44d91352ea6d64e"},
-    {file = "SQLAlchemy-1.4.48-cp27-cp27m-win32.whl", hash = "sha256:cbbe8b8bffb199b225d2fe3804421b7b43a0d49983f81dc654d0431d2f855543"},
-    {file = "SQLAlchemy-1.4.48-cp27-cp27m-win_amd64.whl", hash = "sha256:627e04a5d54bd50628fc8734d5fc6df2a1aa5962f219c44aad50b00a6cdcf965"},
-    {file = "SQLAlchemy-1.4.48-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9af1db7a287ef86e0f5cd990b38da6bd9328de739d17e8864f1817710da2d217"},
-    {file = "SQLAlchemy-1.4.48-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ce7915eecc9c14a93b73f4e1c9d779ca43e955b43ddf1e21df154184f39748e5"},
-    {file = "SQLAlchemy-1.4.48-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5381ddd09a99638f429f4cbe1b71b025bed318f6a7b23e11d65f3eed5e181c33"},
-    {file = "SQLAlchemy-1.4.48-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:87609f6d4e81a941a17e61a4c19fee57f795e96f834c4f0a30cee725fc3f81d9"},
-    {file = "SQLAlchemy-1.4.48-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb0808ad34167f394fea21bd4587fc62f3bd81bba232a1e7fbdfa17e6cfa7cd7"},
-    {file = "SQLAlchemy-1.4.48-cp310-cp310-win32.whl", hash = "sha256:d53cd8bc582da5c1c8c86b6acc4ef42e20985c57d0ebc906445989df566c5603"},
-    {file = "SQLAlchemy-1.4.48-cp310-cp310-win_amd64.whl", hash = "sha256:4355e5915844afdc5cf22ec29fba1010166e35dd94a21305f49020022167556b"},
-    {file = "SQLAlchemy-1.4.48-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:066c2b0413e8cb980e6d46bf9d35ca83be81c20af688fedaef01450b06e4aa5e"},
-    {file = "SQLAlchemy-1.4.48-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c99bf13e07140601d111a7c6f1fc1519914dd4e5228315bbda255e08412f61a4"},
-    {file = "SQLAlchemy-1.4.48-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee26276f12614d47cc07bc85490a70f559cba965fb178b1c45d46ffa8d73fda"},
-    {file = "SQLAlchemy-1.4.48-cp311-cp311-win32.whl", hash = "sha256:49c312bcff4728bffc6fb5e5318b8020ed5c8b958a06800f91859fe9633ca20e"},
-    {file = "SQLAlchemy-1.4.48-cp311-cp311-win_amd64.whl", hash = "sha256:cef2e2abc06eab187a533ec3e1067a71d7bbec69e582401afdf6d8cad4ba3515"},
-    {file = "SQLAlchemy-1.4.48-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:3509159e050bd6d24189ec7af373359f07aed690db91909c131e5068176c5a5d"},
-    {file = "SQLAlchemy-1.4.48-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc2ab4d9f6d9218a5caa4121bdcf1125303482a1cdcfcdbd8567be8518969c0"},
-    {file = "SQLAlchemy-1.4.48-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e1ddbbcef9bcedaa370c03771ebec7e39e3944782bef49e69430383c376a250b"},
-    {file = "SQLAlchemy-1.4.48-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f82d8efea1ca92b24f51d3aea1a82897ed2409868a0af04247c8c1e4fef5890"},
-    {file = "SQLAlchemy-1.4.48-cp36-cp36m-win32.whl", hash = "sha256:e3e98d4907805b07743b583a99ecc58bf8807ecb6985576d82d5e8ae103b5272"},
-    {file = "SQLAlchemy-1.4.48-cp36-cp36m-win_amd64.whl", hash = "sha256:25887b4f716e085a1c5162f130b852f84e18d2633942c8ca40dfb8519367c14f"},
-    {file = "SQLAlchemy-1.4.48-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:0817c181271b0ce5df1aa20949f0a9e2426830fed5ecdcc8db449618f12c2730"},
-    {file = "SQLAlchemy-1.4.48-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe1dd2562313dd9fe1778ed56739ad5d9aae10f9f43d9f4cf81d65b0c85168bb"},
-    {file = "SQLAlchemy-1.4.48-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:68413aead943883b341b2b77acd7a7fe2377c34d82e64d1840860247cec7ff7c"},
-    {file = "SQLAlchemy-1.4.48-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbde5642104ac6e95f96e8ad6d18d9382aa20672008cf26068fe36f3004491df"},
-    {file = "SQLAlchemy-1.4.48-cp37-cp37m-win32.whl", hash = "sha256:11c6b1de720f816c22d6ad3bbfa2f026f89c7b78a5c4ffafb220e0183956a92a"},
-    {file = "SQLAlchemy-1.4.48-cp37-cp37m-win_amd64.whl", hash = "sha256:eb5464ee8d4bb6549d368b578e9529d3c43265007193597ddca71c1bae6174e6"},
-    {file = "SQLAlchemy-1.4.48-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:92e6133cf337c42bfee03ca08c62ba0f2d9695618c8abc14a564f47503157be9"},
-    {file = "SQLAlchemy-1.4.48-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44d29a3fc6d9c45962476b470a81983dd8add6ad26fdbfae6d463b509d5adcda"},
-    {file = "SQLAlchemy-1.4.48-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:005e942b451cad5285015481ae4e557ff4154dde327840ba91b9ac379be3b6ce"},
-    {file = "SQLAlchemy-1.4.48-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c8cfe951ed074ba5e708ed29c45397a95c4143255b0d022c7c8331a75ae61f3"},
-    {file = "SQLAlchemy-1.4.48-cp38-cp38-win32.whl", hash = "sha256:2b9af65cc58726129d8414fc1a1a650dcdd594ba12e9c97909f1f57d48e393d3"},
-    {file = "SQLAlchemy-1.4.48-cp38-cp38-win_amd64.whl", hash = "sha256:2b562e9d1e59be7833edf28b0968f156683d57cabd2137d8121806f38a9d58f4"},
-    {file = "SQLAlchemy-1.4.48-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:a1fc046756cf2a37d7277c93278566ddf8be135c6a58397b4c940abf837011f4"},
-    {file = "SQLAlchemy-1.4.48-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d9b55252d2ca42a09bcd10a697fa041e696def9dfab0b78c0aaea1485551a08"},
-    {file = "SQLAlchemy-1.4.48-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6dab89874e72a9ab5462997846d4c760cdb957958be27b03b49cf0de5e5c327c"},
-    {file = "SQLAlchemy-1.4.48-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd8b5ee5a3acc4371f820934b36f8109ce604ee73cc668c724abb054cebcb6e"},
-    {file = "SQLAlchemy-1.4.48-cp39-cp39-win32.whl", hash = "sha256:eee09350fd538e29cfe3a496ec6f148504d2da40dbf52adefb0d2f8e4d38ccc4"},
-    {file = "SQLAlchemy-1.4.48-cp39-cp39-win_amd64.whl", hash = "sha256:7ad2b0f6520ed5038e795cc2852eb5c1f20fa6831d73301ced4aafbe3a10e1f6"},
-    {file = "SQLAlchemy-1.4.48.tar.gz", hash = "sha256:b47bc287096d989a0838ce96f7d8e966914a24da877ed41a7531d44b55cdb8df"},
+python-versions = ">=3.7"
+files = [
+    {file = "SQLAlchemy-2.0.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:78303719c6f72af97814b0072ad18bee72e70adca8d95cf8fecd59c5e1ddb040"},
+    {file = "SQLAlchemy-2.0.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9d810b4aacd5ef4e293aa4ea01f19fca53999e9edcfc4a8ef1146238b30bdc28"},
+    {file = "SQLAlchemy-2.0.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fb5d09f1d51480f711b69fe28ad42e4f8b08600a85ab2473baee669e1257800"},
+    {file = "SQLAlchemy-2.0.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51b19887c96d405599880da6a7cbdf8545a7e78ec5683e46a43bac8885e32d0f"},
+    {file = "SQLAlchemy-2.0.15-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6b17cb86908e7f88be14007d6afe7d2ab11966e373044137f96a6a4d83eb21c"},
+    {file = "SQLAlchemy-2.0.15-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df25052b92bd514357a9b370d74f240db890ea79aaa428fb893520e10ee5bc18"},
+    {file = "SQLAlchemy-2.0.15-cp310-cp310-win32.whl", hash = "sha256:55ec62ddc0200b4fee94d11abbec7aa25948d5d21cb8df8807f4bdd3c51ba44b"},
+    {file = "SQLAlchemy-2.0.15-cp310-cp310-win_amd64.whl", hash = "sha256:ae1d8deb391ab39cc8f0d5844e588a115ae3717e607d91482023917f920f777f"},
+    {file = "SQLAlchemy-2.0.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4670ce853cb25f72115a1bbe366ae13cf3f28fc5c87222df14f8d3d55d51816e"},
+    {file = "SQLAlchemy-2.0.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cea7c4a3dfc2ca61f88a2b1ddd6b0bfbd116c9b1a361b3b66fd826034b833142"},
+    {file = "SQLAlchemy-2.0.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f5784dfb2d45c19cde03c45c04a54bf47428610106197ed6e6fa79f33bc63d3"},
+    {file = "SQLAlchemy-2.0.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b31ebde27575b3b0708673ec14f0c305c4564d995b545148ab7ac0f4d9b847a"},
+    {file = "SQLAlchemy-2.0.15-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b42913a0259267e9ee335da0c36498077799e59c5e332d506e72b4f32de781d"},
+    {file = "SQLAlchemy-2.0.15-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a3f8020e013e9b3b7941dcf20b0fc8f7429daaf7158760846731cbd8caa5e45"},
+    {file = "SQLAlchemy-2.0.15-cp311-cp311-win32.whl", hash = "sha256:88ab245ed2c96265441ed2818977be28c840cfa5204ba167425d6c26eb67b7e7"},
+    {file = "SQLAlchemy-2.0.15-cp311-cp311-win_amd64.whl", hash = "sha256:5cc48a7fda2b5c5b8860494d6c575db3a101a68416492105fed6591dc8a2728a"},
+    {file = "SQLAlchemy-2.0.15-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f6fd3c88ea4b170d13527e93be1945e69facd917661d3725a63470eb683fbffe"},
+    {file = "SQLAlchemy-2.0.15-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e885dacb167077df15af2f9ccdacbd7f5dd0d538a6d74b94074f2cefc7bb589"},
+    {file = "SQLAlchemy-2.0.15-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:201a99f922ac8c780b3929128fbd9df901418877c70e160e19adb05665e51c31"},
+    {file = "SQLAlchemy-2.0.15-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e17fdcb8971e77c439113642ca8861f9465e21fc693bd3916654ceef3ac26883"},
+    {file = "SQLAlchemy-2.0.15-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db269f67ed17b07e80aaa8fba1f650c0d84aa0bdd9d5352e4ac38d5bf47ac568"},
+    {file = "SQLAlchemy-2.0.15-cp37-cp37m-win32.whl", hash = "sha256:994a75b197662e0608b6a76935d7c345f7fd874eac0b7093d561033db61b0e8c"},
+    {file = "SQLAlchemy-2.0.15-cp37-cp37m-win_amd64.whl", hash = "sha256:4d61731a35eddb0f667774fe15e5a4831e444d066081d1e809e1b8a0e3f97cae"},
+    {file = "SQLAlchemy-2.0.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f7f994a53c0e6b44a2966fd6bfc53e37d34b7dca34e75b6be295de6db598255e"},
+    {file = "SQLAlchemy-2.0.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:79bfe728219239bdc493950ea4a4d15b02138ecb304771f9024d0d6f5f4e3706"},
+    {file = "SQLAlchemy-2.0.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6320a1d175447dce63618ec997a53836de48ed3b44bbe952f0b4b399b19941"},
+    {file = "SQLAlchemy-2.0.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f80a9c9a9af0e4bd5080cc0955ce70274c28e9b931ad7e0fb07021afcd32af6"},
+    {file = "SQLAlchemy-2.0.15-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4a75fdb9a84072521bb2ebd31eefe1165d4dccea3039dda701a864f4b5daa17f"},
+    {file = "SQLAlchemy-2.0.15-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:21c89044fc48a25c2184eba332edeffbbf9367913bb065cd31538235d828f06f"},
+    {file = "SQLAlchemy-2.0.15-cp38-cp38-win32.whl", hash = "sha256:1a0754c2d9f0c7982bec0a31138e495ed1f6b8435d7e677c45be60ec18370acf"},
+    {file = "SQLAlchemy-2.0.15-cp38-cp38-win_amd64.whl", hash = "sha256:bc5c2b0da46c26c5f73f700834f871d0723e1e882641932468d56833bab09775"},
+    {file = "SQLAlchemy-2.0.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:670ecf74ee2e70b917028a06446ad26ff9b1195e84b09c3139c215123d57dc30"},
+    {file = "SQLAlchemy-2.0.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d14282bf5b4de87f922db3c70858953fd081ef4f05dba6cca3dd705daffe1cc9"},
+    {file = "SQLAlchemy-2.0.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:256b2b9660e51ad7055a9835b12717416cf7288afcf465107413917b6bb2316f"},
+    {file = "SQLAlchemy-2.0.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810199d1c5b43603a9e815ae9487aef3ab1ade7ed9c0c485e12519358929fbfe"},
+    {file = "SQLAlchemy-2.0.15-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:536c86ec81ca89291d533ff41a3a05f9e4e88e01906dcee0751fc7082f3e8d6c"},
+    {file = "SQLAlchemy-2.0.15-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:435f6807fa6a0597d84741470f19db204a7d34625ea121abd63e8d95f673f0c4"},
+    {file = "SQLAlchemy-2.0.15-cp39-cp39-win32.whl", hash = "sha256:da7381a883aee20b7d2ffda17d909b38134b6a625920e65239a1c681881df800"},
+    {file = "SQLAlchemy-2.0.15-cp39-cp39-win_amd64.whl", hash = "sha256:788d1772fb8dcd12091ca82809eef504ce0f2c423e45284bc351b872966ff554"},
+    {file = "SQLAlchemy-2.0.15-py3-none-any.whl", hash = "sha256:933d30273861fe61f014ce2a7e3c364915f5efe9ed250ec1066ca6ea5942c0bd"},
+    {file = "SQLAlchemy-2.0.15.tar.gz", hash = "sha256:2e940a8659ef870ae10e0d9e2a6d5aaddf0ff6e91f7d0d7732afc9e8c4be9bbc"},
 ]
 
 [package.dependencies]
-greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\")"}
+greenlet = {version = "!=0.4.17", markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""}
+typing-extensions = ">=4.2.0"
 
 [package.extras]
 aiomysql = ["aiomysql", "greenlet (!=0.4.17)"]
 aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"]
 asyncio = ["greenlet (!=0.4.17)"]
-asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"]
-mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)"]
+asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"]
+mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"]
 mssql = ["pyodbc"]
 mssql-pymssql = ["pymssql"]
 mssql-pyodbc = ["pyodbc"]
-mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"]
-mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"]
+mypy = ["mypy (>=0.910)"]
+mysql = ["mysqlclient (>=1.4.0)"]
 mysql-connector = ["mysql-connector-python"]
-oracle = ["cx-oracle (>=7)", "cx-oracle (>=7,<8)"]
+oracle = ["cx-oracle (>=7)"]
+oracle-oracledb = ["oracledb (>=1.0.1)"]
 postgresql = ["psycopg2 (>=2.7)"]
 postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
-postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"]
+postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
+postgresql-psycopg = ["psycopg (>=3.0.7)"]
 postgresql-psycopg2binary = ["psycopg2-binary"]
 postgresql-psycopg2cffi = ["psycopg2cffi"]
-pymysql = ["pymysql", "pymysql (<1)"]
+pymysql = ["pymysql"]
 sqlcipher = ["sqlcipher3-binary"]
 
-[[package]]
-name = "sqlalchemy-stubs"
-version = "0.4"
-description = "SQLAlchemy stubs and mypy plugin"
-optional = false
-python-versions = "*"
-files = [
-    {file = "sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae"},
-    {file = "sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5"},
-]
-
-[package.dependencies]
-mypy = ">=0.790"
-typing-extensions = ">=3.7.4"
-
 [[package]]
 name = "tomli"
 version = "2.0.1"
@@ -1500,4 +1488,4 @@ test = []
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "fd8795583a7d13a1f3fdfd08eee2c3b7b0d4a6fc314b91430d9cdc92f0a26b46"
+content-hash = "92be3bcf3ed889f3570f9c6e2ec29077e7fcb1225be807cedb06bb8175768679"
diff --git a/pyproject.toml b/pyproject.toml
index d6a3d6ca..2f7b48a7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,7 +50,7 @@ Repository = "https://github.com/cihai/unihan-db"
 [tool.poetry.dependencies]
 python = "^3.8"
 appdirs = "*"
-SQLAlchemy = "<2"
+SQLAlchemy = ">=2"
 unihan-etl = "~=0.19.1"
 
 [tool.poetry.group.dev.dependencies]
@@ -81,7 +81,6 @@ pytest-cov = "*"
 black = "*"
 ruff = "*"
 mypy = "*"
-sqlalchemy-stubs = "*"
 types-appdirs = "*"
 
 [tool.poetry.extras]
@@ -104,14 +103,11 @@ lint = [
   "black",
   "ruff",
   "mypy",
-  "sqlalchemy-stubs",
   "types-appdirs",
 ]
 
 [tool.mypy]
-plugins = [
-  "sqlmypy",
-]
+strict = true
 files = [
   "src/",
   "tests/",
diff --git a/src/unihan_db/bootstrap.py b/src/unihan_db/bootstrap.py
index 6bba5265..0f81026b 100644
--- a/src/unihan_db/bootstrap.py
+++ b/src/unihan_db/bootstrap.py
@@ -1,11 +1,14 @@
 import logging
 import sys
 from datetime import datetime
-
+import typing as t
 from sqlalchemy import create_engine, event
-from sqlalchemy.orm import class_mapper, mapper, scoped_session, sessionmaker
-
+import sqlalchemy
+from sqlalchemy.orm import Session, class_mapper, scoped_session, sessionmaker
+from sqlalchemy.orm.decl_api import registry
+from sqlalchemy.orm.scoping import ScopedSession
 from unihan_etl import process as unihan
+from unihan_etl.types import UntypedUnihanData
 from unihan_etl.util import merge_dict
 
 from . import dirs, importer
@@ -13,8 +16,18 @@
 
 log = logging.getLogger(__name__)
 
+mapper_reg = registry()
+
+if t.TYPE_CHECKING:
+    from unihan_etl.types import (
+        UntypedNormalizedData,
+    )
 
-def setup_logger(logger=None, level="INFO"):
+
+def setup_logger(
+    logger: t.Optional[logging.Logger] = None,
+    level: t.Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
+) -> None:
     """
     Setup logging for CLI use.
 
@@ -119,7 +132,7 @@ def setup_logger(logger=None, level="INFO"):
 DEFAULT_FIELDS = ["ucn", "char"]
 
 
-def is_bootstrapped(metadata):
+def is_bootstrapped(metadata: sqlalchemy.MetaData) -> bool:
     """Return True if cihai is correctly bootstrapped."""
     fields = UNIHAN_FIELDS + DEFAULT_FIELDS
     if TABLE_NAME in metadata.tables.keys():
@@ -133,24 +146,30 @@ def is_bootstrapped(metadata):
         return False
 
 
-def bootstrap_data(options=None):
+def bootstrap_data(
+    options: t.Union[UntypedUnihanData, None] = None,
+) -> t.Optional["UntypedNormalizedData"]:
     if options is None:
         options = {}
+    _options = options
 
-    options = merge_dict(UNIHAN_ETL_DEFAULT_OPTIONS.copy(), options)
+    _options = merge_dict(UNIHAN_ETL_DEFAULT_OPTIONS.copy(), _options)
 
-    p = unihan.Packager(options)
+    p = unihan.Packager(_options)
     p.download()
     return p.export()
 
 
-def bootstrap_unihan(session, options=None):
-    if options is None:
-        options = {}
+def bootstrap_unihan(
+    session: t.Union[Session, ScopedSession[t.Any]],
+    options: t.Optional[UntypedUnihanData] = None,
+) -> None:
+    _options = options if options is not None else {}
 
     """Download, extract and import unihan to database."""
     if session.query(Unhn).count() == 0:
-        data = bootstrap_data(options)
+        data = bootstrap_data(_options)
+        assert data is not None
         log.info("bootstrap Unhn table")
         log.info("bootstrap Unhn table finished")
         count = 0
@@ -158,6 +177,7 @@ def bootstrap_unihan(session, options=None):
         items = []
 
         for char in data:
+            assert isinstance(char, dict)
             c = Unhn(char=char["char"], ucn=char["ucn"])
             importer.import_char(c, char)
             items.append(c)
@@ -177,7 +197,7 @@ def bootstrap_unihan(session, options=None):
         log.info("Done adding rows.")
 
 
-def to_dict(obj, found=None):
+def to_dict(obj: t.Any, found: t.Optional[t.Set[t.Any]] = None) -> t.Dict[str, object]:
     """
     Return dictionary of an SQLAlchemy Query result.
 
@@ -196,21 +216,26 @@ def to_dict(obj, found=None):
         dictionary representation of a SQLAlchemy query
     """
 
-    def _get_key_value(c):
+    def _get_key_value(c: str) -> t.Any:
         if isinstance(getattr(obj, c), datetime):
             return (c, getattr(obj, c).isoformat())
         else:
             return (c, getattr(obj, c))
 
+    _found: t.Set[t.Any]
+
     if found is None:
-        found = set()
-    mapper = class_mapper(obj.__class__)
-    columns = [column.key for column in mapper.columns]
+        _found = set()
+    else:
+        _found = found
+
+    _mapper = class_mapper(obj.__class__)
+    columns = [column.key for column in _mapper.columns]
 
     result = dict(map(_get_key_value, columns))
-    for name, relation in mapper.relationships.items():
-        if relation not in found:
-            found.add(relation)
+    for name, relation in _mapper.relationships.items():
+        if relation not in _found:
+            _found.add(relation)
             related_obj = getattr(obj, name)
             if related_obj is not None:
                 if relation.uselist:
@@ -220,7 +245,7 @@ def _get_key_value(c):
     return result
 
 
-def add_to_dict(b):
+def add_to_dict(b: t.Any) -> t.Any:
     """
     Add :func:`.to_dict` method to SQLAlchemy Base object.
 
@@ -233,7 +258,9 @@ def add_to_dict(b):
     return b
 
 
-def get_session(engine_url="sqlite:///{user_data_dir}/unihan_db.db"):
+def get_session(
+    engine_url: str = "sqlite:///{user_data_dir}/unihan_db.db",
+) -> "ScopedSession[t.Any]":
     """
     Return new SQLAlchemy session object from engine string.
 
@@ -251,9 +278,8 @@ def get_session(engine_url="sqlite:///{user_data_dir}/unihan_db.db"):
     engine_url = engine_url.format(**{"user_data_dir": dirs.user_data_dir})
     engine = create_engine(engine_url)
 
-    event.listen(mapper, "after_configured", add_to_dict(Base))
-    Base.metadata.bind = engine
-    Base.metadata.create_all()
+    event.listen(mapper_reg, "after_configured", add_to_dict(Base))
+    Base.metadata.create_all(bind=engine)
     session_factory = sessionmaker(bind=engine)
     session = scoped_session(session_factory)
 
diff --git a/src/unihan_db/importer.py b/src/unihan_db/importer.py
index b0d0c634..51feafb2 100644
--- a/src/unihan_db/importer.py
+++ b/src/unihan_db/importer.py
@@ -1,4 +1,6 @@
+import typing as t
 from unihan_db.tables import (
+    Unhn,
     UnhnLocation,
     UnhnLocationkXHC1983,
     UnhnReading,
@@ -43,29 +45,51 @@
 )
 
 
-def import_char(c, char):  # NOQA: C901
+def import_char(
+    c: Unhn,
+    char: t.Dict[
+        str,
+        t.Union[
+            t.Dict[
+                str,
+                t.Union[int, str, t.List[t.Dict[str, object]], t.Dict[str, object]],
+            ],
+            t.List[t.Dict[str, object]],
+            t.Dict[str, object],
+        ],
+    ],
+) -> None:  # NOQA: C901
     if "kDefinition" in char:
-        for d in char["kDefinition"]:
-            c.kDefinition.append(kDefinition(definition=d))
+        for kd in char["kDefinition"]:
+            c.kDefinition.append(kDefinition(definition=kd))
     if "kCantonese" in char:
-        for d in char["kCantonese"]:
-            c.kCantonese.append(kCantonese(definition=d))
+        for kc in char["kCantonese"]:
+            c.kCantonese.append(kCantonese(definition=kc))
     if "kCCCII" in char:
-        for d in char["kCCCII"]:
-            c.kCCCII.append(kCCCII(hex=d))
+        for kci in char["kCCCII"]:
+            assert isinstance(kci, dict)
+            c.kCCCII.append(kCCCII(hex=kci))
     if "kMandarin" in char:
-        d = char["kMandarin"]
-        c.kMandarin.append(kMandarin(hans=d["zh-Hans"], hant=d["zh-Hant"]))
+        km = char["kMandarin"]
+        assert isinstance(km, dict)
+        c.kMandarin.append(kMandarin(hans=km["zh-Hans"], hant=km["zh-Hant"]))
 
     if "kTotalStrokes" in char:
-        d = char["kTotalStrokes"]
-        c.kTotalStrokes.append(kTotalStrokes(hans=d["zh-Hans"], hant=d["zh-Hant"]))
+        kts = char["kTotalStrokes"]
+        assert isinstance(kts, dict)
+        c.kTotalStrokes.append(kTotalStrokes(hans=kts["zh-Hans"], hant=kts["zh-Hant"]))
 
     if "kHanyuPinyin" in char:
-        for d in char["kHanyuPinyin"]:
-            k = kHanyuPinyin()
-            for loc in d["locations"]:
-                k.locations.append(
+        for _khp in char["kHanyuPinyin"]:
+            assert isinstance(_khp, dict)
+            assert isinstance(_khp["locations"], list)
+            assert isinstance(_khp["readings"], list)
+            khp_readings: t.List[UnhnReading] = []
+            khp_locations: t.List[UnhnLocation] = []
+
+            for loc in _khp["locations"]:
+                assert isinstance(loc, dict)
+                khp_locations.append(
                     UnhnLocation(
                         volume=loc["volume"],
                         page=loc["page"],
@@ -73,41 +97,51 @@ def import_char(c, char):  # NOQA: C901
                         virtual=loc["virtual"],
                     )
                 )
-            for reading in d["readings"]:
-                k.readings.append(UnhnReading(reading=reading))
-            c.kHanyuPinyin.append(k)
+            for reading in _khp["readings"]:
+                khp_readings.append(UnhnReading(reading=reading))
+            c.kHanyuPinyin.append(
+                kHanyuPinyin(
+                    locations=khp_readings,
+                    readings=khp_readings,
+                )
+            )
 
     if "kHanYu" in char:
-        k = kHanYu()
-        for d in char["kHanYu"]:
-            k.locations.append(
+        khy_locations: t.List[UnhnLocation] = []
+        for _khy in char["kHanYu"]:
+            assert isinstance(_khy, dict)
+            khy_locations.append(
                 UnhnLocation(
-                    volume=d["volume"],
-                    page=d["page"],
-                    character=d["character"],
-                    virtual=d["virtual"],
+                    volume=_khy["volume"],
+                    page=_khy["page"],
+                    character=_khy["character"],
+                    virtual=_khy["virtual"],
                 )
             )
-        c.kHanYu.append(k)
+        c.kHanYu.append(kHanYu(locations=khy_locations))
 
     if "kIRGHanyuDaZidian" in char:
-        for d in char["kIRGHanyuDaZidian"]:
-            k = kIRGHanyuDaZidian()
-            k.locations.append(
+        _khdz_locations: t.List[UnhnLocation] = []
+        for _khdz in char["kIRGHanyuDaZidian"]:
+            assert isinstance(_khdz, dict)
+
+            _khdz_locations.append(
                 UnhnLocation(
-                    volume=d["volume"],
-                    page=d["page"],
-                    character=d["character"],
-                    virtual=d["virtual"],
+                    volume=_khdz["volume"],
+                    page=_khdz["page"],
+                    character=_khdz["character"],
+                    virtual=_khdz["virtual"],
                 )
             )
-            c.kIRGHanyuDaZidian.append(k)
+        c.kIRGHanyuDaZidian.append(kIRGHanyuDaZidian(locations=_khdz_locations))
 
     if "kXHC1983" in char:
-        for d in char["kXHC1983"]:
-            k = kXHC1983()
-            for loc in d["locations"]:
-                k.locations.append(
+        for kxhc in char["kXHC1983"]:
+            assert isinstance(kxhc, dict)
+            assert isinstance(kxhc["locations"], list)
+            kxhc_locations: t.List[UnhnLocationkXHC1983] = []
+            for loc in kxhc["locations"]:
+                kxhc_locations.append(
                     UnhnLocationkXHC1983(
                         page=loc["page"],
                         character=loc["character"],
@@ -115,100 +149,136 @@ def import_char(c, char):  # NOQA: C901
                         substituted=loc["substituted"],
                     )
                 )
-            k.readings.append(UnhnReading(reading=d["reading"]))
-            c.kXHC1983.append(k)
+            c.kXHC1983.append(kXHC1983(readings=kxhc_locations))
 
     if "kCheungBauer" in char:
-        for d in char["kCheungBauer"]:
-            k = kCheungBauer(
-                radical=d["radical"], strokes=d["strokes"], cangjie=d["cangjie"]
-            )
+        for _kcb in char["kCheungBauer"]:
+            assert isinstance(_kcb, dict)
+            assert isinstance(_kcb["readings"], list)
+            k_readings: t.List[UnhnReading] = []
 
-            for reading in d["readings"]:
-                k.readings.append(UnhnReading(reading=reading))
-            c.kCheungBauer.append(k)
+            for reading in _kcb["readings"]:
+                k_readings.append(UnhnReading(reading=reading))
+
+            c.kCheungBauer.append(
+                kCheungBauer(
+                    radical=_kcb["radical"],
+                    strokes=_kcb["strokes"],
+                    cangjie=_kcb["cangjie"],
+                    readings=k_readings,
+                )
+            )
 
     if "kRSAdobe_Japan1_6" in char:
-        for d in char["kRSAdobe_Japan1_6"]:
+        for _kaj in char["kRSAdobe_Japan1_6"]:
+            assert isinstance(_kaj, dict)
             c.kRSAdobe_Japan1_6.append(
                 kRSAdobe_Japan1_6(
-                    type=d["type"],
-                    cid=d["cid"],
-                    radical=d["radical"],
-                    strokes=d["strokes"],
-                    strokes_residue=d["strokes-residue"],
+                    type=_kaj["type"],
+                    cid=_kaj["cid"],
+                    radical=_kaj["radical"],
+                    strokes=_kaj["strokes"],
+                    strokes_residue=_kaj["strokes-residue"],
                 )
             )
 
     if "kCihaiT" in char:
-        for d in char["kCihaiT"]:
+        for _kct in char["kCihaiT"]:
+            assert isinstance(_kct, dict)
             c.kCihaiT.append(
-                kCihaiT(page=d["page"], row=d["row"], character=d["character"])
+                kCihaiT(page=_kct["page"], row=_kct["row"], character=_kct["character"])
             )
 
     if "kIICore" in char:
-        for d in char["kIICore"]:
-            k = kIICore(priority=d["priority"])
-            for s in d["sources"]:
-                k.sources.append(kIICoreSource(source=s))
-            c.kIICore.append(k)
+        for _kiic in char["kIICore"]:
+            k_sources: t.List[kIICoreSource] = []
+            assert isinstance(_kiic, dict)
+            assert isinstance(_kiic["sources"], list)
+            for _kiic_source in _kiic["sources"]:
+                k_sources.append(kIICoreSource(source=_kiic_source))
+            c.kIICore.append(kIICore(priority=_kiic["priority"], sources=k_sources))
 
     if "kDaeJaweon" in char:
-        k = kDaeJaweon()
-        d = char["kDaeJaweon"]
-        k.locations.append(
-            UnhnLocation(page=d["page"], character=d["character"], virtual=d["virtual"])
+        _kdj = char["kDaeJaweon"]
+        assert isinstance(_kdj, dict)
+        c.kDaeJaweon.append(
+            kDaeJaweon(
+                locations=[
+                    UnhnLocation(
+                        page=_kdj["page"],
+                        character=_kdj["character"],
+                        virtual=_kdj["virtual"],
+                    )
+                ]
+            )
         )
-        c.kDaeJaweon.append(k)
 
     if "kIRGKangXi" in char:
-        k = kIRGKangXi()
-        for d in char["kIRGKangXi"]:
-            k.locations.append(
+        _kikx_locations: t.List[UnhnLocation] = []
+        for _kikx in char["kIRGKangXi"]:
+            assert isinstance(_kikx, dict)
+            _kikx_locations.append(
                 UnhnLocation(
-                    page=d["page"], character=d["character"], virtual=d["virtual"]
+                    page=_kikx["page"],
+                    character=_kikx["character"],
+                    virtual=_kikx["virtual"],
                 )
             )
-        c.kIRGKangXi.append(k)
+        c.kIRGKangXi.append(kIRGKangXi(locations=_kikx_locations))
 
     if "kIRGDaeJaweon" in char:
-        k = kIRGDaeJaweon()
-        for d in char["kIRGDaeJaweon"]:
-            k.locations.append(
+        _kidj_locations: t.List[UnhnLocation] = []
+        for _kidj in char["kIRGDaeJaweon"]:
+            assert isinstance(_kidj, dict)
+            _kidj_locations.append(
                 UnhnLocation(
-                    page=d["page"], character=d["character"], virtual=d["virtual"]
+                    page=_kidj["page"],
+                    character=_kidj["character"],
+                    virtual=_kidj["virtual"],
                 )
             )
-        c.kIRGDaeJaweon.append(k)
+        c.kIRGDaeJaweon.append(kIRGDaeJaweon(locations=_kidj_locations))
 
     if "kFenn" in char:
-        for d in char["kFenn"]:
-            c.kFenn.append(kFenn(phonetic=d["phonetic"], frequency=d["frequency"]))
+        for _kf in char["kFenn"]:
+            assert isinstance(_kf, dict)
+            c.kFenn.append(kFenn(phonetic=_kf["phonetic"], frequency=_kf["frequency"]))
 
     if "kHanyuPinlu" in char:
-        for d in char["kHanyuPinlu"]:
+        for _khp in char["kHanyuPinlu"]:
+            assert isinstance(_khp, dict)
+            assert isinstance(_khp["location"], dict)
             c.kHanyuPinlu.append(
-                kHanyuPinlu(phonetic=d["phonetic"], frequency=d["frequency"])
+                kHanyuPinlu(phonetic=_khp["phonetic"], frequency=_khp["frequency"])
             )
 
     if "kHDZRadBreak" in char:
-        d = char["kHDZRadBreak"]
-        k = kHDZRadBreak(radical=d["radical"], ucn=d["ucn"])
-        k.locations.append(
-            UnhnLocation(
-                volume=d["location"]["volume"],
-                page=d["location"]["page"],
-                character=d["location"]["character"],
-                virtual=d["location"]["virtual"],
+        _khrb = char["kHDZRadBreak"]
+        assert isinstance(_khrb, dict)
+        assert isinstance(_khrb["location"], dict)
+        c.kHDZRadBreak.append(
+            kHDZRadBreak(
+                radical=_khrb["radical"],
+                ucn=_khrb["ucn"],
+                locations=[
+                    UnhnLocation(
+                        volume=_khrb["location"]["volume"],
+                        page=_khrb["location"]["page"],
+                        character=_khrb["location"]["character"],
+                        virtual=_khrb["location"]["virtual"],
+                    )
+                ],
             )
         )
-        c.kHDZRadBreak.append(k)
 
     if "kSBGY" in char:
-        for d in char["kSBGY"]:
-            k = kSBGY()
-            k.locations.append(UnhnLocation(page=d["page"], character=d["character"]))
-            c.kSBGY.append(k)
+        _ksbgy_locations: t.List[UnhnLocation] = []
+        for _ksbgy in char["kSBGY"]:
+            assert isinstance(_ksbgy, dict)
+            _ksbgy_locations.append(
+                UnhnLocation(page=_ksbgy["page"], character=_ksbgy["character"])
+            )
+        c.kSBGY.append(kSBGY(locations=_ksbgy_locations))
 
     rs_fields = (  # radical-stroke fields, since they're the same structure
         ("kRSUnicode", kRSUnicode, c.kRSUnicode),
@@ -218,15 +288,17 @@ def import_char(c, char):  # NOQA: C901
         ("kRSKorean", kRSKorean, c.kRSKorean),
     )
 
-    for f, model, column in rs_fields:
-        if f in char:
-            for d in char[f]:
-                k = model(
-                    radical=d["radical"],
-                    strokes=d["strokes"],
-                    simplified=d["simplified"],
+    for f_rs, RSModel, rs_column in rs_fields:
+        if f_rs in char:
+            for _md in char[f_rs]:
+                assert isinstance(_md, dict)
+                rs_column.append(
+                    RSModel(
+                        radical=_md["radical"],
+                        strokes=_md["strokes"],
+                        simplified=_md["simplified"],
+                    )
                 )
-                column.append(k)
 
     irg_fields = (  # IRG, since they're the same structure
         ("kIRG_GSource", kIRG_GSource, c.kIRG_GSource),
@@ -240,33 +312,47 @@ def import_char(c, char):  # NOQA: C901
         ("kIRG_VSource", kIRG_VSource, c.kIRG_VSource),
     )
 
-    for f, model, column in irg_fields:
-        if f in char:
-            d = char[f]
-            k = model(source=d["source"], location=d["location"])
-            column.append(k)
+    for f_irg, IRGModel, column in irg_fields:
+        if f_irg in char:
+            _irg = char[f_irg]
+            assert isinstance(_irg, dict)
+            column.append(IRGModel(source=_irg["source"], location=_irg["location"]))
 
     if "kGSR" in char:
-        for d in char["kGSR"]:
-            k = kGSR(set=d["set"], letter=d["letter"], apostrophe=d["apostrophe"])
-            c.kGSR.append(k)
+        for _kgsr in char["kGSR"]:
+            assert isinstance(_kgsr, dict)
+            c.kGSR.append(
+                kGSR(
+                    set=_kgsr["set"],
+                    letter=_kgsr["letter"],
+                    apostrophe=_kgsr["apostrophe"],
+                )
+            )
 
     if "kCheungBauerIndex" in char:
-        d = char["kCheungBauerIndex"]
-        k = kCheungBauerIndex()
-        k.locations.append(
-            UnhnLocation(
-                page=d["location"]["page"], character=d["location"]["character"]
+        kcbi = char["kCheungBauerIndex"]
+        assert isinstance(kcbi, dict)
+        assert isinstance(kcbi["location"], dict)
+        c.kCheungBauerIndex.append(
+            kCheungBauerIndex(
+                locations=[
+                    UnhnLocation(
+                        page=kcbi["location"]["page"],
+                        character=kcbi["location"]["character"],
+                    )
+                ]
             )
         )
-        c.kCheungBauerIndex.append(k)
 
     if "kFennIndex" in char:
-        d = char["kFennIndex"]
-        k = kFennIndex()
-        k.locations.append(
-            UnhnLocation(
-                page=d["location"]["page"], character=d["location"]["character"]
+        _kFennIndex = char["kFennIndex"]
+        assert isinstance(_kFennIndex, dict)
+        assert isinstance(_kFennIndex["location"], dict)
+        c.kFennIndex.append(
+            kFennIndex(
+                locations=UnhnLocation(
+                    page=_kFennIndex["location"]["page"],
+                    character=_kFennIndex["location"]["character"],
+                )
             )
         )
-        c.kFennIndex.append(k)
diff --git a/src/unihan_db/tables.py b/src/unihan_db/tables.py
index d93cd17e..f7d4bca5 100644
--- a/src/unihan_db/tables.py
+++ b/src/unihan_db/tables.py
@@ -23,10 +23,11 @@
 
 """
 from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import relationship
+from sqlalchemy.orm import DeclarativeBase, relationship
 
-Base = declarative_base()
+
+class Base(DeclarativeBase):
+    pass
 
 
 class Unhn(Base):


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 5abfb87f..19f2b996 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,80 +1,92 @@
 import os
 import pathlib
+import typing as t
 import zipfile
+import sqlalchemy
 
 import pytest
+from sqlalchemy.orm import sessionmaker, scoped_session
+from sqlalchemy.orm.scoping import ScopedSession
+from unihan_db.bootstrap import UNIHAN_FILES
 
-from sqlalchemy import MetaData, create_engine
-from sqlalchemy.orm import scoped_session, sessionmaker
 
-from unihan_db.bootstrap import UNIHAN_FILES
+class UnihanOptions(t.TypedDict):
+    source: pathlib.Path
+    work_dir: pathlib.Path
+    zip_path: pathlib.Path
+
+
+@pytest.fixture
+def tests_path() -> pathlib.Path:
+    return pathlib.Path(__file__).parent
 
 
 @pytest.fixture
-def fixture_path():
-    return os.path.abspath(os.path.join(os.path.dirname(__file__), "fixtures"))
+def fixture_path(tests_path: pathlib.Path) -> pathlib.Path:
+    return tests_path / "fixtures"
 
 
 @pytest.fixture
-def test_config_file(fixture_path):
+def test_config_file(fixture_path: str) -> str:
     return os.path.join(fixture_path, "test_config.yml")
 
 
 @pytest.fixture
-def zip_path(tmpdir: pathlib.Path):
-    return tmpdir / "Unihan.zip"
+def zip_path(tmp_path: pathlib.Path) -> pathlib.Path:
+    return tmp_path / "Unihan.zip"
 
 
 @pytest.fixture
-def zip_file(zip_path, fixture_path):
+def zip_file(zip_path: pathlib.Path, fixture_path: pathlib.Path) -> zipfile.ZipFile:
     _files = []
     for f in UNIHAN_FILES:
-        _files += [os.path.join(fixture_path, f)]
-    zf = zipfile.ZipFile(str(zip_path), "a")
-    for f in _files:
-        zf.write(f, os.path.basename(f))
+        _files += [fixture_path / f]
+    zf = zipfile.ZipFile(zip_path, "a")
+    for _f in _files:
+        zf.write(_f, _f.name)
     zf.close()
     return zf
 
 
 @pytest.fixture
-def unihan_options(zip_file, zip_path, tmp_path: pathlib.Path):
+def unihan_options(
+    zip_file: zipfile.ZipFile, zip_path: pathlib.Path, tmp_path: pathlib.Path
+) -> "UnihanOptions":
     return {
-        "source": str(zip_path),
-        "work_dir": str(tmp_path),
-        "zip_path": str(tmp_path / "downloads" / "Moo.zip"),
-        "expand": True,
+        "source": zip_path,
+        "work_dir": tmp_path,
+        "zip_path": tmp_path / "downloads" / "Moo.zip",
     }
 
 
 @pytest.fixture(scope="function")
-def tmpdb_file(tmp_path: pathlib.Path):
-    return tmp_path / "test.db"
+def tmpdb_file(tmpdir: pathlib.Path) -> pathlib.Path:
+    return tmpdir / "test.db"
 
 
-@pytest.fixture
-def engine():
-    return create_engine("sqlite:///:memory:")
+@pytest.fixture(scope="session")
+def engine() -> sqlalchemy.Engine:
+    return sqlalchemy.create_engine("sqlite:///")
+
+
+@pytest.fixture(scope="session")
+def metadata() -> sqlalchemy.MetaData:
+    return sqlalchemy.MetaData()
 
 
 @pytest.fixture(scope="function")
-def session(engine, request):
+def session(
+    engine: sqlalchemy.Engine, request: pytest.FixtureRequest
+) -> ScopedSession[t.Any]:
     connection = engine.connect()
     transaction = connection.begin()
     session_factory = sessionmaker(bind=engine)
     session = scoped_session(session_factory)
 
-    def teardown():
+    def teardown() -> None:
         transaction.rollback()
         connection.close()
         session.remove()
 
     request.addfinalizer(teardown)
     return session
-
-
-@pytest.fixture
-def metadata(engine):
-    metadata = MetaData()
-    metadata.engine = engine
-    return metadata
diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py
index 539662ad..54b6d51e 100644
--- a/tests/test_bootstrap.py
+++ b/tests/test_bootstrap.py
@@ -1,12 +1,27 @@
+import pathlib
+from sqlalchemy.orm import Session
 from unihan_db import bootstrap
 from unihan_db.tables import Base, Unhn
+import typing as t
 
+import sqlalchemy
 
-def test_reflect_db(tmpdb_file, unihan_options, metadata):
+
+class UnihanOptions(t.TypedDict):
+    source: pathlib.Path
+    work_dir: pathlib.Path
+    zip_path: pathlib.Path
+
+
+def test_reflect_db(
+    tmpdb_file: pathlib.Path,
+    unihan_options: UnihanOptions,
+    metadata: sqlalchemy.MetaData,
+) -> None:
     assert not bootstrap.is_bootstrapped(metadata)
 
 
-def test_import_object(session, engine):
+def test_import_object(session: Session, engine: sqlalchemy.Engine) -> None:
     Base.metadata.create_all(engine)
     session.add(Unhn(char="好", ucn="U+4E09"))
     session.commit()
@@ -15,15 +30,22 @@ def test_import_object(session, engine):
     assert session.query(Unhn).count() == 1
 
 
-def test_import_unihan(zip_file, session, engine, unihan_options):
-    Base.metadata.bind = engine
-    Base.metadata.create_all()
-    # bootstrap.bootstrap_unihan(Base.metadata, unihan_options)
+def test_import_unihan(
+    zip_file: pathlib.Path,
+    session: Session,
+    engine: sqlalchemy.Engine,
+    unihan_options: UnihanOptions,
+) -> None:
+    Base.metadata.create_all(bind=engine)
 
 
-def test_import_unihan_raw(zip_file, session, engine, unihan_options):
-    Base.metadata.bind = engine
-    Base.metadata.create_all()
+def test_import_unihan_raw(
+    zip_file: pathlib.Path,
+    session: Session,
+    engine: sqlalchemy.Engine,
+    unihan_options: UnihanOptions,
+) -> None:
+    Base.metadata.create_all(bind=engine)
 
     bootstrap.bootstrap_unihan(session, unihan_options)