diff --git a/README.md b/README.md index 29abb35..27c6751 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,11 @@ Databases we support: - DuckDB >=0.6 - SQLite (coming soon) + +### Documentation + +[Read the docs!](https://sqeleton.readthedocs.io) + ### Basic usage ```python diff --git a/docs/intro.md b/docs/intro.md index a1c10c1..8543ceb 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -505,6 +505,72 @@ ddb: AbstractDatabase[NewAbstractDialect] = connect("duckdb://:memory:") # ddb.dialect is now known to implement NewAbstractDialect. ``` +### Query interpreter + +In addition to query expressions, `Database.query()` can accept a generator, which will behave as an "interpreter". + +The generator executes queries by yielding them. + +Using a query interpreter also guarantees that subsequent calls to `.query()` will run in the same session. That can be useful for using temporary tables, or session variables. + +Example: + +```python +def sample_using_temp_table(db: Database, source_table: ITable, sample_size: int): + "This function creates a temporary table from a query and then samples rows from it" + + results = [] + + def _sample_using_temp_table(): + nonlocal results + + yield code("CREATE TEMPORARY TABLE tmp1 AS {source_table}", source_table=source_table) + + tbl = table('tmp1') + try: + results += yield sample(tbl, sample_size) + finally: + yield tbl.drop() + + db.query(_sample_using_temp_table()) + return results +``` + ### Query params -### Query interpreter \ No newline at end of file +TODO + +## Other features + +### SQL client + +Sqeleton comes with a simple built-in SQL client, in the form of a REPL, which accepts SQL commands, and a few special commands. + +It accepts any database URL that is supported by Sqeleton. That can be useful for querying databases that don't have established clients. + +You can call it using `sqeleton repl `. + +Example: + +```bash +# Start a REPL session +$ sqeleton repl duckdb:///pii_test.ddb + +# Run SQL +DuckDB> select (22::float / 7) as almost_pi +┏━━━━━━━━━━━━━━━━━━━┓ +┃ almost_pi ┃ +┡━━━━━━━━━━━━━━━━━━━┩ +│ 3.142857074737549 │ +└───────────────────┘ + 1 rows + +# Display help +DuckDB> ? + +Commands: + ?mytable - shows schema of table 'mytable' + * - shows list of all tables + *pattern - shows list of all tables with name like pattern +Otherwise, runs regular SQL query +``` \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 0f98ab4..5d29ab1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -97,7 +97,7 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] [[package]] name = "coverage" -version = "6.5.0" +version = "7.0.3" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -146,15 +146,15 @@ numpy = ">=1.14" [[package]] name = "filelock" -version = "3.8.2" +version = "3.9.0" description = "A platform independent file lock." category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2022.9.29)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] -testing = ["covdefaults (>=2.2.2)", "coverage (>=6.5)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] +testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] [[package]] name = "idna" @@ -166,7 +166,7 @@ python-versions = ">=3.5" [[package]] name = "importlib-metadata" -version = "5.1.0" +version = "4.13.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -197,6 +197,14 @@ compression = ["lz4 (>=2.1.6)", "zstandard (>=0.12.0)"] dns-srv = ["dnspython (>=1.16.0)"] gssapi = ["gssapi (>=1.6.9)"] +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +category = "main" +optional = true +python-versions = "*" + [[package]] name = "numpy" version = "1.21.1" @@ -280,7 +288,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "Pygments" -version = "2.13.0" +version = "2.14.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false @@ -331,7 +339,7 @@ six = ">=1.5" [[package]] name = "pytz" -version = "2022.6" +version = "2022.7" description = "World timezone definitions, modern and historical" category = "main" optional = false @@ -369,11 +377,11 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "12.6.0" +version = "13.0.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false -python-versions = ">=3.6.3,<4.0.0" +python-versions = ">=3.7.0" [package.dependencies] commonmark = ">=0.9.0,<0.10.0" @@ -443,6 +451,34 @@ development = ["Cython", "coverage", "more-itertools", "numpy (<1.24.0)", "pendu pandas = ["pandas (>=1.0.0,<1.6.0)", "pyarrow (>=8.0.0,<8.1.0)"] secure-local-storage = ["keyring (!=16.1.0,<24.0.0)"] +[[package]] +name = "textual" +version = "0.9.1" +description = "Modern Text User Interface framework" +category = "main" +optional = true +python-versions = ">=3.7,<4.0" + +[package.dependencies] +importlib-metadata = ">=4.11.3,<5.0.0" +nanoid = ">=2.0.0" +rich = ">12.6.0" +typing-extensions = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +dev = ["aiohttp (>=3.8.1)", "click (>=8.1.2)", "msgpack (>=1.0.3)"] + +[[package]] +name = "textual-select" +version = "0.2.1" +description = "A select widget (aka dropdown) for Textual." +category = "main" +optional = true +python-versions = ">=3.7,<4.0" + +[package.dependencies] +textual = ">=0.6.0" + [[package]] name = "toml" version = "0.10.2" @@ -529,7 +565,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "vertica-python" -version = "1.1.1" +version = "1.2.0" description = "Official native Python client for the Vertica database." category = "dev" optional = false @@ -560,12 +596,13 @@ postgresql = ["psycopg2"] presto = ["presto-python-client"] snowflake = ["snowflake-connector-python", "cryptography"] trino = ["trino"] +tui = ["textual", "textual-select"] vertica = [] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "93d803ff31252129545c881c7ef4e65cbcba1668f7513c776273b5fe719bf774" +content-hash = "5918d5df773cc17a2461fc1e89f549c9b09af69ff3cb6ccd9ae0a6fff7058f30" [metadata.files] asn1crypto = [ @@ -773,56 +810,57 @@ commonmark = [ {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, ] coverage = [ - {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, - {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, - {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, - {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, - {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, - {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, - {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, - {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, - {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, - {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, - {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, - {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, - {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, - {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, - {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, - {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, - {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, - {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, - {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, - {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, - {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, - {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, - {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, - {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, - {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, - {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, - {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, - {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, - {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, - {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, - {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, - {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, - {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, - {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, - {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, - {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, - {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, - {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, - {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, - {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, - {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, - {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, - {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, - {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, - {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, - {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, - {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, - {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, - {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, - {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, + {file = "coverage-7.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f7c51b6074a8a3063c341953dffe48fd6674f8e4b1d3c8aa8a91f58d6e716a8"}, + {file = "coverage-7.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:628f47eaf66727fc986d3b190d6fa32f5e6b7754a243919d28bc0fd7974c449f"}, + {file = "coverage-7.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e89d5abf86c104de808108a25d171ad646c07eda96ca76c8b237b94b9c71e518"}, + {file = "coverage-7.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75e43c6f4ea4d122dac389aabdf9d4f0e160770a75e63372f88005d90f5bcc80"}, + {file = "coverage-7.0.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49da0ff241827ebb52d5d6d5a36d33b455fa5e721d44689c95df99fd8db82437"}, + {file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0bce4ad5bdd0b02e177a085d28d2cea5fc57bb4ba2cead395e763e34cf934eb1"}, + {file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f79691335257d60951638dd43576b9bcd6f52baa5c1c2cd07a509bb003238372"}, + {file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5722269ed05fbdb94eef431787c66b66260ff3125d1a9afcc00facff8c45adf9"}, + {file = "coverage-7.0.3-cp310-cp310-win32.whl", hash = "sha256:bdbda870e0fda7dd0fe7db7135ca226ec4c1ade8aa76e96614829b56ca491012"}, + {file = "coverage-7.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:e56fae4292e216b8deeee38ace84557b9fa85b52db005368a275427cdabb8192"}, + {file = "coverage-7.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b82343a5bc51627b9d606f0b6b6b9551db7b6311a5dd920fa52a94beae2e8959"}, + {file = "coverage-7.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd0a8aa431f9b7ad9eb8264f55ef83cbb254962af3775092fb6e93890dea9ca2"}, + {file = "coverage-7.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:112cfead1bd22eada8a8db9ed387bd3e8be5528debc42b5d3c1f7da4ffaf9fb5"}, + {file = "coverage-7.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af87e906355fa42447be5c08c5d44e6e1c005bf142f303f726ddf5ed6e0c8a4d"}, + {file = "coverage-7.0.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f30090e22a301952c5abd0e493a1c8358b4f0b368b49fa3e4568ed3ed68b8d1f"}, + {file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae871d09901911eedda1981ea6fd0f62a999107293cdc4c4fd612321c5b34745"}, + {file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ed7c9debf7bfc63c9b9f8b595409237774ff4b061bf29fba6f53b287a2fdeab9"}, + {file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:13121fa22dcd2c7b19c5161e3fd725692448f05377b788da4502a383573227b3"}, + {file = "coverage-7.0.3-cp311-cp311-win32.whl", hash = "sha256:037b51ee86bc600f99b3b957c20a172431c35c2ef9c1ca34bc813ab5b51fd9f5"}, + {file = "coverage-7.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:25fde928306034e8deecd5fc91a07432dcc282c8acb76749581a28963c9f4f3f"}, + {file = "coverage-7.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7e8b0642c38b3d3b3c01417643ccc645345b03c32a2e84ef93cdd6844d6fe530"}, + {file = "coverage-7.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18b09811f849cc958d23f733a350a66b54a8de3fed1e6128ba55a5c97ffb6f65"}, + {file = "coverage-7.0.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:349d0b545520e8516f7b4f12373afc705d17d901e1de6a37a20e4ec9332b61f7"}, + {file = "coverage-7.0.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b38813eee5b4739f505d94247604c72eae626d5088a16dd77b08b8b1724ab3"}, + {file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ba9af1218fa01b1f11c72271bc7290b701d11ad4dbc2ae97c445ecacf6858dba"}, + {file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c5648c7eec5cf1ba5db1cf2d6c10036a582d7f09e172990474a122e30c841361"}, + {file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d0df04495b76a885bfef009f45eebe8fe2fbf815ad7a83dabcf5aced62f33162"}, + {file = "coverage-7.0.3-cp37-cp37m-win32.whl", hash = "sha256:af6cef3796b8068713a48dd67d258dc9a6e2ebc3bd4645bfac03a09672fa5d20"}, + {file = "coverage-7.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:62ef3800c4058844e2e3fa35faa9dd0ccde8a8aba6c763aae50342e00d4479d4"}, + {file = "coverage-7.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:acef7f3a3825a2d218a03dd02f5f3cc7f27aa31d882dd780191d1ad101120d74"}, + {file = "coverage-7.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a530663a361eb27375cec28aea5cd282089b5e4b022ae451c4c3493b026a68a5"}, + {file = "coverage-7.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c58cd6bb46dcb922e0d5792850aab5964433d511b3a020867650f8d930dde4f4"}, + {file = "coverage-7.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f918e9ef4c98f477a5458238dde2a1643aed956c7213873ab6b6b82e32b8ef61"}, + {file = "coverage-7.0.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b865aa679bee7fbd1c55960940dbd3252621dd81468268786c67122bbd15343"}, + {file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c5d9b480ebae60fc2cbc8d6865194136bc690538fa542ba58726433bed6e04cc"}, + {file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:985ad2af5ec3dbb4fd75d5b0735752c527ad183455520055a08cf8d6794cabfc"}, + {file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ca15308ef722f120967af7474ba6a453e0f5b6f331251e20b8145497cf1bc14a"}, + {file = "coverage-7.0.3-cp38-cp38-win32.whl", hash = "sha256:c1cee10662c25c94415bbb987f2ec0e6ba9e8fce786334b10be7e6a7ab958f69"}, + {file = "coverage-7.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:44d6a556de4418f1f3bfd57094b8c49f0408df5a433cf0d253eeb3075261c762"}, + {file = "coverage-7.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e6dcc70a25cb95df0ae33dfc701de9b09c37f7dd9f00394d684a5b57257f8246"}, + {file = "coverage-7.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bf76d79dfaea802f0f28f50153ffbc1a74ae1ee73e480baeda410b4f3e7ab25f"}, + {file = "coverage-7.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88834e5d56d01c141c29deedacba5773fe0bed900b1edc957595a8a6c0da1c3c"}, + {file = "coverage-7.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef001a60e888f8741e42e5aa79ae55c91be73761e4df5e806efca1ddd62fd400"}, + {file = "coverage-7.0.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4959dc506be74e4963bd2c42f7b87d8e4b289891201e19ec551e64c6aa5441f8"}, + {file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b791beb17b32ac019a78cfbe6184f992b6273fdca31145b928ad2099435e2fcb"}, + {file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b07651e3b9af8f1a092861d88b4c74d913634a7f1f2280fca0ad041ad84e9e96"}, + {file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:55e46fa4168ccb7497c9be78627fcb147e06f474f846a10d55feeb5108a24ef0"}, + {file = "coverage-7.0.3-cp39-cp39-win32.whl", hash = "sha256:e3f1cd1cd65695b1540b3cf7828d05b3515974a9d7c7530f762ac40f58a18161"}, + {file = "coverage-7.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d8249666c23683f74f8f93aeaa8794ac87cc61c40ff70374a825f3352a4371dc"}, + {file = "coverage-7.0.3-pp37.pp38.pp39-none-any.whl", hash = "sha256:b1ffc8f58b81baed3f8962e28c30d99442079b82ce1ec836a1f67c0accad91c1"}, + {file = "coverage-7.0.3.tar.gz", hash = "sha256:d5be4e93acce64f516bf4fd239c0e6118fc913c93fa1a3f52d15bdcc60d97b2d"}, ] cryptography = [ {file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:2fa36a7b2cc0998a3a4d5af26ccb6273f3df133d61da2ba13b3286261e7efb70"}, @@ -905,16 +943,16 @@ duckdb = [ {file = "duckdb-0.6.1.tar.gz", hash = "sha256:6d26e9f1afcb924a6057785e506810d48332d4764ddc4a5b414d0f2bf0cacfb4"}, ] filelock = [ - {file = "filelock-3.8.2-py3-none-any.whl", hash = "sha256:8df285554452285f79c035efb0c861eb33a4bcfa5b7a137016e32e6a90f9792c"}, - {file = "filelock-3.8.2.tar.gz", hash = "sha256:7565f628ea56bfcd8e54e42bdc55da899c85c1abfe1b5bcfd147e9188cebb3b2"}, + {file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"}, + {file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"}, ] idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] importlib-metadata = [ - {file = "importlib_metadata-5.1.0-py3-none-any.whl", hash = "sha256:d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313"}, - {file = "importlib_metadata-5.1.0.tar.gz", hash = "sha256:d5059f9f1e8e41f80e9c56c2ee58811450c31984dfa625329ffd7c0dad88a73b"}, + {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, + {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, ] mysql-connector-python = [ {file = "mysql-connector-python-8.0.29.tar.gz", hash = "sha256:29ec05ded856b4da4e47239f38489c03b31673ae0f46a090d0e4e29c670e6181"}, @@ -936,6 +974,10 @@ mysql-connector-python = [ {file = "mysql_connector_python-8.0.29-cp39-cp39-win_amd64.whl", hash = "sha256:1bef2a4a2b529c6e9c46414100ab7032c252244e8a9e017d2b6a41bb9cea9312"}, {file = "mysql_connector_python-8.0.29-py2.py3-none-any.whl", hash = "sha256:047420715bbb51d3cba78de446c8a6db4666459cd23e168568009c620a3f5b90"}, ] +nanoid = [ + {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, + {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, +] numpy = [ {file = "numpy-1.21.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:38e8648f9449a549a7dfe8d8755a5979b45b3538520d1e735637ef28e8c2dc50"}, {file = "numpy-1.21.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fd7d7409fa643a91d0a05c7554dd68aa9c9bb16e186f6ccfe40d6e003156e33a"}, @@ -1040,8 +1082,8 @@ pycryptodomex = [ {file = "pycryptodomex-3.16.0.tar.gz", hash = "sha256:e9ba9d8ed638733c9e95664470b71d624a6def149e2db6cc52c1aca5a6a2df1d"}, ] Pygments = [ - {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, - {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, + {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, + {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, ] PyJWT = [ {file = "PyJWT-2.6.0-py3-none-any.whl", hash = "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14"}, @@ -1056,8 +1098,8 @@ python-dateutil = [ {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] pytz = [ - {file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"}, - {file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"}, + {file = "pytz-2022.7-py2.py3-none-any.whl", hash = "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd"}, + {file = "pytz-2022.7.tar.gz", hash = "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a"}, ] pytz-deprecation-shim = [ {file = "pytz_deprecation_shim-0.1.0.post0-py2.py3-none-any.whl", hash = "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6"}, @@ -1068,8 +1110,8 @@ requests = [ {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, ] rich = [ - {file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"}, - {file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"}, + {file = "rich-13.0.1-py3-none-any.whl", hash = "sha256:41fe1d05f433b0f4724cda8345219213d2bfa472ef56b2f64f415b5b94d51b04"}, + {file = "rich-13.0.1.tar.gz", hash = "sha256:25f83363f636995627a99f6e4abc52ed0970ebbd544960cc63cbb43aaac3d6f0"}, ] runtype = [ {file = "runtype-0.2.7-py3-none-any.whl", hash = "sha256:a2bdeb1a1dece5b753ac0cc602d373239166887fe652fd9fec41683dc38bff10"}, @@ -1105,6 +1147,14 @@ snowflake-connector-python = [ {file = "snowflake_connector_python-2.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23401da4ac80867f1c65c7275938fadd4a796b500748b39b26f7d3e36a10405e"}, {file = "snowflake_connector_python-2.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:6994dca106e4fb2c26135e7d3d05fdbd927cee4e63f8d3f8167b43d5b3d2c9d3"}, ] +textual = [ + {file = "textual-0.9.1-py3-none-any.whl", hash = "sha256:97613f6855a10f82b1384346480c550324efed73f6d123b9064c277477eaa634"}, + {file = "textual-0.9.1.tar.gz", hash = "sha256:117816bbea6ca4009317ca079caf9af7653d577c3f849b58da0f2580bed239d0"}, +] +textual-select = [ + {file = "textual_select-0.2.1-py3-none-any.whl", hash = "sha256:91f8cb2bd300964eb481624a2fc444030e13f99f1dd7645ea1afb266e382abc0"}, + {file = "textual_select-0.2.1.tar.gz", hash = "sha256:55c591b32d7ef8820fad707d61f2622444420cb92ee8882b266087fa02330829"}, +] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -1134,8 +1184,8 @@ urllib3 = [ {file = "urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"}, ] vertica-python = [ - {file = "vertica-python-1.1.1.tar.gz", hash = "sha256:dedf56d76b67673b4d57a13f7f96ebdc57b39ea650b93ebf0c05eb6d1d2c0c05"}, - {file = "vertica_python-1.1.1-py2.py3-none-any.whl", hash = "sha256:63d300832d6fe471987880f06a9590eafc46a1f896860881270f6b6645f3bec6"}, + {file = "vertica-python-1.2.0.tar.gz", hash = "sha256:cdf7972492f9a56ceff72dfd3161d866080144100f18922d50a29ac69f0c9513"}, + {file = "vertica_python-1.2.0-py2.py3-none-any.whl", hash = "sha256:4c6bc271e871e4935874cc4556832ff9a2330894e2e9516eddd6a7344b9f514b"}, ] zipp = [ {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, diff --git a/pyproject.toml b/pyproject.toml index 787d080..7e91c2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ trino = {version="^0.314.0", optional=true} presto-python-client = {version="*", optional=true} clickhouse-driver = {version="*", optional=true} duckdb = {version="^0.6.0", optional=true} +textual = {version="^0.9.1", optional=true} +textual-select = {version="*", optional=true} [tool.poetry.dev-dependencies] parameterized = "*" @@ -63,6 +65,7 @@ trino = ["trino"] clickhouse = ["clickhouse-driver"] vertica = ["vertica-python"] duckdb = ["duckdb"] +tui = ["textual", "textual-select"] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/sqeleton/__main__.py b/sqeleton/__main__.py index 7bcb069..a159d8e 100644 --- a/sqeleton/__main__.py +++ b/sqeleton/__main__.py @@ -13,5 +13,16 @@ def repl(database): return repl_main(database) +CONN_EDITOR_HELP = """CONFIG_PATH - Path to a TOML config file of db connections, new or existing.""" + + +@main.command(no_args_is_help=True, help=CONN_EDITOR_HELP) +@click.argument("config_path", required=True) +def conn_editor(config_path): + from .conn_editor import main + + return main(config_path) + + if __name__ == "__main__": main() diff --git a/sqeleton/conn_editor.py b/sqeleton/conn_editor.py new file mode 100644 index 0000000..e1d72c1 --- /dev/null +++ b/sqeleton/conn_editor.py @@ -0,0 +1,295 @@ +import sys +import re +from concurrent.futures import ThreadPoolExecutor +import toml + +from runtype import dataclass + +try: + import textual +except ModuleNotFoundError: + raise ModuleNotFoundError( + "Error: Cannot find the TUI library 'textual'. Please install it using `pip install sqeleton[tui]`" + ) + +from textual.app import App, ComposeResult +from textual.containers import Vertical, Container, Horizontal +from textual.widgets import Header, Footer, Input, Label, Button, Static, ListView, ListItem + +from textual_select import Select + +from .databases._connect import DATABASE_BY_SCHEME +from . import connect + + +class ContentSwapper(Container): + def __init__(self, *initial_widgets, **kw): + self.container = Container(*initial_widgets) + super().__init__(**kw) + + def compose(self): + yield self.container + + def new_content(self, *new_widgets): + for c in self.container.children: + c.remove() + + self.container.mount(*new_widgets) + + +def test_connect(connect_dict): + conn = connect(connect_dict) + assert conn.query("select 1+1", int) == 2 + + +@dataclass +class Config: + config: dict + + @property + def databases(self): + if "database" not in self.config: + self.config["database"] = {} + assert isinstance(self.config["database"], dict) + return self.config["database"] + + def add_database(self, name: str, db: dict): + assert isinstance(db, dict) + self.config["database"][name] = db + + def remove_database(self, name): + del self.config["database"][name] + + +class EditConnection(Vertical): + def __init__(self, db_name: str, config: Config) -> None: + self._db_name = db_name + self._config = config + super().__init__() + + def compose(self): + self.params_container = ContentSwapper(id="params") + self.driver_select = Select( + placeholder="Select a database driver", + search=True, + items=[{"value": k, "text": k} for k in DATABASE_BY_SCHEME], + list_mount="#driver_container", + value=self._config.databases.get(self._db_name, {}).get("driver"), + id="driver", + ) + + yield Vertical( + Label("Connection name:"), + Input(id="conn_name", value=self._db_name, classes="margin-bottom-1"), + Label("Database Driver:"), + self.driver_select, + self.params_container, + id="driver_container", + ) + + self.create_params() + + def create_params(self): + driver = self.driver_select.value + if not driver: + return + db_config = self._config.databases.get(self._db_name, {}) + + db_cls = DATABASE_BY_SCHEME[driver] + # Filter out repetitions, but keep order + base_params = re.findall("<(.*?)>", db_cls.CONNECT_URI_HELP) + params = dict.fromkeys([p.lower() for p in base_params] + [k for k in db_config if k != "driver"]) + + widgets = [] + for p in params: + label = p + if p in ("user", "pass", "port", "dbname"): + label += " (optional)" + widgets.append(Label(f"{label}:")) + p = p.lower() + widgets.append(Input(id="input_" + p, name=p, classes="param", value=db_config.get(p))) + + self.params_container.new_content( + Vertical( + *widgets, + Horizontal( + Button("Test & Save Connection", id="test_and_save"), + Button("Save Connection Without Testing", id="save_without_test"), + ), + Vertical(id="result"), + ) + ) + + def on_select_changed(self, event: Select.Changed) -> None: + driver = str(event.value) + self.driver_select.text = driver + self.driver_select.value = driver + self.create_params() + + def _get_connect_dict(self): + connect_dict = {"driver": self.driver_select.value} + for p in self.query(".param"): + if p.value: + connect_dict[p.name] = p.value + + return connect_dict + + async def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "test_and_save": + connect_dict = self._get_connect_dict() + + result_container = self.query_one("#result") + result_container.mount(Label(f"Trying to connect to {connect_dict}")) + + try: + test_connect(connect_dict) + except Exception as e: + error = str(e) + result_container.mount(Label(f"[red]Error: {error}[/red]")) + else: + result_container.mount(Label(f"[green]Success![green]")) + self.save(connect_dict) + + elif button_id == "save_without_test": + connect_dict = self._get_connect_dict() + self.save(connect_dict) + + def save(self, connect_dict): + assert isinstance(connect_dict, dict) + result_container = self.query_one("#result") + result_container.mount(Label(f"Database saved")) + + name = self.query_one("#conn_name").value + self._config.add_database(name, connect_dict) + self.app.config_changed() + + +class ListOfConnections(Vertical): + def __init__(self, config: Config, **kw): + self.config = config + super().__init__(**kw) + + def compose(self) -> ComposeResult: + list_items = [ + ListItem(Label(name, id="list_label_" + name), name=name) for name in self.config.databases.keys() + ] + + self.lv = lv = ListView(*list_items, id="connection_list") + yield lv + + lv.focus() + + def on_list_view_highlighted(self, h: ListView.Highlighted): + name = h.item.name + self.app.query_one("#edit_container").new_content(EditConnection(name, self.config)) + + +class ConnectionEditor(App): + CSS = """ + #conn_list { + display: block; + dock: left; + height: 100%; + max-width: 30%; + margin: 1 + } + + #edit { + margin: 1 + } + + #test_and_save { + margin: 1 + } + #save_without_test { + margin: 1 + } + + .margin-bottom-1 { + margin-bottom: 1 + } + """ + + BINDINGS = [ + ("q", "quit", "Quit"), + ("d", "toggle_dark", "Toggle dark mode"), + ("insert", "add_conn", "Add new connection"), + ("delete", "del_conn", "Delete selected connection"), + ("t", "test_conn", "Test selected connection"), + ("a", "test_all_conns", "Test all connections"), + ] + + def run(self, toml_path, **kw): + self.toml_path = toml_path + try: + with open(self.toml_path) as f: + self.config = Config(toml.load(f)) + except FileNotFoundError: + self.config = Config({}) + + return super().run(**kw) + + def config_changed(self): + self.list_swapper.new_content(ListOfConnections(self.config)) + with open(self.toml_path, "w") as f: + toml.dump(self.config.config, f) + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + self.list_swapper = ContentSwapper(ListOfConnections(self.config), id="conn_list") + self.edit_swapper = ContentSwapper(id="edit_container") + self.edit_swapper.new_content(EditConnection("New", self.config)) + + yield Header() + yield Container(self.list_swapper, self.edit_swapper) + yield Footer() + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + def action_test_all_conns(self): + return self.action_quit() + + def action_add_conn(self): + self.edit_swapper.new_content(EditConnection("New", self.config)) + + def _selected_connection(self): + connection_list: ListView = self.query_one("#connection_list") + return connection_list.children[connection_list.index] + + def action_del_conn(self): + name = self._selected_connection().name + self.config.remove_database(name) + self.config_changed() + + def _test_existing_db(self, name): + label: Label = self.query_one("#list_label_" + name) + label.update(f"{name}🔃") + connect_dict = self.config.databases[name] + try: + test_connect(connect_dict) + except Exception as e: + label.update(f"{name}❌ {str(e)[:16]}") + else: + label.update(f"{name}✅") + + def action_test_conn(self): + name = self._selected_connection().name + t = ThreadPoolExecutor() + t.submit(self._test_existing_db, name) + + def action_test_all_conns(self): + t = ThreadPoolExecutor() + for name in self.config.databases: + t.submit(self._test_existing_db, name) + + +def main(toml_path: str): + app = ConnectionEditor() + app.run(toml_path) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/sqeleton/databases/_connect.py b/sqeleton/databases/_connect.py index 70b8116..a1cb366 100644 --- a/sqeleton/databases/_connect.py +++ b/sqeleton/databases/_connect.py @@ -2,6 +2,7 @@ from itertools import zip_longest from contextlib import suppress import dsnparse +import toml from runtype import dataclass @@ -137,6 +138,19 @@ def connect_to_uri(self, db_uri: str, thread_count: Optional[int] = 1) -> Databa raise NotImplementedError("No support for multiple schemes") (scheme,) = dsn.schemes + if scheme == "toml": + toml_path = dsn.path or dsn.host + database = dsn.fragment + if not database: + raise ValueError("Must specify a database name, e.g. 'toml://path#database'. ") + with open(toml_path) as f: + config = toml.load(f) + try: + conn_dict = config['database'][database] + except KeyError: + raise ValueError(f"Cannot find database config named '{database}'.") + return self.connect_with_dict(conn_dict, thread_count) + try: matcher = self.match_uri_path[scheme] except KeyError: diff --git a/sqeleton/databases/base.py b/sqeleton/databases/base.py index 7dbe912..294e228 100644 --- a/sqeleton/databases/base.py +++ b/sqeleton/databases/base.py @@ -10,6 +10,8 @@ from uuid import UUID import decimal +from runtype import dataclass + from ..utils import is_uuid, safezip, Self from ..queries import Expr, Compiler, table, Select, SKIP, Explain, Code, this from ..queries.ast_classes import Random @@ -265,6 +267,21 @@ class _DialectWithMixins(cls, *mixins, *abstract_mixins): T = TypeVar("T", bound=BaseDialect) +@dataclass +class QueryResult: + rows: list + columns: list = None + + def __iter__(self): + return iter(self.rows) + + def __len__(self): + return len(self.rows) + + def __getitem__(self, i): + return self.rows[i] + + class Database(AbstractDatabase[T]): """Base abstract class for databases. @@ -473,7 +490,8 @@ def _query_cursor(self, c, sql_code: str): try: c.execute(sql_code) if sql_code.lower().startswith(("select", "explain", "show")): - return c.fetchall() + columns = [col[0] for col in c.description] + return QueryResult(c.fetchall(), columns) except Exception as _e: # logger.exception(e) # logger.error(f'Caused by SQL: {sql_code}') @@ -519,7 +537,7 @@ def set_conn(self): assert not hasattr(self.thread_local, "conn") try: self.thread_local.conn = self.create_connection() - except ModuleNotFoundError as e: + except Exception as e: self._init_error = e def _query(self, sql_code: Union[str, ThreadLocalInterpreter]): diff --git a/sqeleton/databases/clickhouse.py b/sqeleton/databases/clickhouse.py index 8ff292c..5e9326b 100644 --- a/sqeleton/databases/clickhouse.py +++ b/sqeleton/databases/clickhouse.py @@ -163,7 +163,7 @@ def current_timestamp(self) -> str: class Clickhouse(ThreadedDatabase): dialect = Dialect() - CONNECT_URI_HELP = "clickhouse://:@/" + CONNECT_URI_HELP = "clickhouse://:@/" CONNECT_URI_PARAMS = ["database?"] def __init__(self, *, thread_count: int, **kw): diff --git a/sqeleton/databases/duckdb.py b/sqeleton/databases/duckdb.py index f6c6b77..07ae4f8 100644 --- a/sqeleton/databases/duckdb.py +++ b/sqeleton/databases/duckdb.py @@ -141,7 +141,7 @@ class DuckDB(Database): dialect = Dialect() SUPPORTS_UNIQUE_CONSTAINT = False # Temporary, until we implement it default_schema = "main" - CONNECT_URI_HELP = "duckdb://@" + CONNECT_URI_HELP = "duckdb://@" CONNECT_URI_PARAMS = ["database", "dbpath"] def __init__(self, **kw): diff --git a/sqeleton/databases/mysql.py b/sqeleton/databases/mysql.py index ab39778..522d599 100644 --- a/sqeleton/databases/mysql.py +++ b/sqeleton/databases/mysql.py @@ -109,7 +109,7 @@ class MySQL(ThreadedDatabase): dialect = Dialect() SUPPORTS_ALPHANUMS = False SUPPORTS_UNIQUE_CONSTAINT = True - CONNECT_URI_HELP = "mysql://:@/" + CONNECT_URI_HELP = "mysql://:@/" CONNECT_URI_PARAMS = ["database?"] def __init__(self, *, thread_count, **kw): diff --git a/sqeleton/databases/oracle.py b/sqeleton/databases/oracle.py index b95366b..b3da12a 100644 --- a/sqeleton/databases/oracle.py +++ b/sqeleton/databases/oracle.py @@ -160,7 +160,7 @@ def current_timestamp(self) -> str: class Oracle(ThreadedDatabase): dialect = Dialect() - CONNECT_URI_HELP = "oracle://:@/" + CONNECT_URI_HELP = "oracle://:@/" CONNECT_URI_PARAMS = ["database?"] def __init__(self, *, host, database, thread_count, **kw): diff --git a/sqeleton/databases/postgresql.py b/sqeleton/databases/postgresql.py index ecf07d0..8d1be75 100644 --- a/sqeleton/databases/postgresql.py +++ b/sqeleton/databases/postgresql.py @@ -98,12 +98,13 @@ def current_timestamp(self) -> str: class PostgreSQL(ThreadedDatabase): dialect = PostgresqlDialect() SUPPORTS_UNIQUE_CONSTAINT = True - CONNECT_URI_HELP = "postgresql://:@/" + CONNECT_URI_HELP = "postgresql://:@/" CONNECT_URI_PARAMS = ["database?"] default_schema = "public" def __init__(self, *, thread_count, **kw): + print("###", kw) self._args = kw super().__init__(thread_count=thread_count) diff --git a/sqeleton/databases/redshift.py b/sqeleton/databases/redshift.py index e44847c..eb74d36 100644 --- a/sqeleton/databases/redshift.py +++ b/sqeleton/databases/redshift.py @@ -60,7 +60,7 @@ def is_distinct_from(self, a: str, b: str) -> str: class Redshift(PostgreSQL): dialect = Dialect() - CONNECT_URI_HELP = "redshift://:@/" + CONNECT_URI_HELP = "redshift://:@/" CONNECT_URI_PARAMS = ["database?"] def select_table_schema(self, path: DbPath) -> str: diff --git a/sqeleton/databases/snowflake.py b/sqeleton/databases/snowflake.py index 1643cdf..ebc9bb8 100644 --- a/sqeleton/databases/snowflake.py +++ b/sqeleton/databases/snowflake.py @@ -139,7 +139,7 @@ def set_timezone_to_utc(self) -> str: class Snowflake(Database): dialect = Dialect() - CONNECT_URI_HELP = "snowflake://:@//?warehouse=" + CONNECT_URI_HELP = "snowflake://:@//?warehouse=" CONNECT_URI_PARAMS = ["database", "schema"] CONNECT_URI_KWPARAMS = ["warehouse"] diff --git a/sqeleton/databases/vertica.py b/sqeleton/databases/vertica.py index c80d845..3f853ea 100644 --- a/sqeleton/databases/vertica.py +++ b/sqeleton/databases/vertica.py @@ -152,7 +152,7 @@ def current_timestamp(self) -> str: class Vertica(ThreadedDatabase): dialect = Dialect() - CONNECT_URI_HELP = "vertica://:@/" + CONNECT_URI_HELP = "vertica://:@/" CONNECT_URI_PARAMS = ["database?"] default_schema = "public" diff --git a/sqeleton/repl.py b/sqeleton/repl.py index 8a7b75a..cea1bbd 100644 --- a/sqeleton/repl.py +++ b/sqeleton/repl.py @@ -4,7 +4,6 @@ # logging.basicConfig(level=logging.DEBUG) from . import connect -from .queries import table import sys @@ -54,13 +53,14 @@ def repl(uri): else: print_table([(k, v[1]) for k, v in schema.items()], ["name", "type"], f"Table '{table_name}'") else: + # Normal SQL query try: res = db.query(q) except Exception as e: logging.error(e) else: if res: - print_table(res, [str(i) for i in range(len(res[0]))], q) + print_table(res.rows, res.columns, None) def main():