diff --git a/doc/connecting.md b/doc/connecting.md index 76e223daa..b0cc6a078 100644 --- a/doc/connecting.md +++ b/doc/connecting.md @@ -51,6 +51,53 @@ a flag with (-a|--connection_arguments)the connection string as a JSON string. S %sql -a '{"timeout":10, "mode":"ro"}' sqlite:// SELECT * from work; ``` ++++ + +## Connecting securely + +**It is highly recommended** that you do not pass plain credentials. + +```{code-cell} ipython3 +%load_ext sql +``` + +### Building connection string + +One option is to use `getpass`, type your password, build your connection string and pass it to `%sql`: + ++++ + +```python +from getpass import getpass + +password = getpass() +connection_string = f"postgresql://user:{password}@localhost/database" +%sql $connection_string +``` + ++++ + +### Using `DATABASE_URL` + ++++ + +You may also set the `DATABASE_URL` environment variable, and `%sql` will automatically load it from there. You can do it either by setting the environment variable from your terminal or in your notebook: + +```python +from getpass import getpass +from os import environ + +password = getpass() +environ["DATABASE_URL"] = f"postgresql://user:{password}@localhost/database" +``` + +```python +# without any args, %sql reads from DATABASE_URL +%sql +``` + ++++ + ## DSN connections Alternately, you can store connection info in a configuration file, under a section name chosen to refer to your database. diff --git a/doc/howto.md b/doc/howto.md index de34d723a..4828eb9a2 100644 --- a/doc/howto.md +++ b/doc/howto.md @@ -110,3 +110,42 @@ Pass existing engines to `%sql` %%sql SELECT MYSUM(1, 2) ``` + +## Connect to a SQLite database with spaces + +Currently, due to a limitation in the argument parser, it's not possible to directly connect to SQLite databases whose path contains spaces; however, you can do it by creating the engine first. + +### Setup + +```{code-cell} ipython3 +%pip install jupysql --quiet +``` + +```{code-cell} ipython3 +%load_ext sql +``` + +## Connect to db + +```{code-cell} ipython3 +from sqlalchemy import create_engine + +engine = create_engine("sqlite:///my database.db") +``` + +Add some sample data: + +```{code-cell} ipython3 +import pandas as pd + +_ = pd.DataFrame({"x": range(5)}).to_sql("numbers", engine) +``` + +```{code-cell} ipython3 +%sql engine +``` + +```{code-cell} ipython3 +%%sql +SELECT * FROM numbers +``` diff --git a/src/sql/connection.py b/src/sql/connection.py index d64e27edc..b7b83b3e4 100644 --- a/src/sql/connection.py +++ b/src/sql/connection.py @@ -140,7 +140,7 @@ def connection_list(cls): template = " * {}" else: template = " {}" - result.append(template.format(engine_url.__repr__())) + result.append(template.format(repr(engine_url))) return "\n".join(result) @classmethod diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 18480da47..a743eabdb 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -55,6 +55,7 @@ def ip(): "CREATE TABLE author (first_name, last_name, year_of_death)", "INSERT INTO author VALUES ('William', 'Shakespeare', 1616)", "INSERT INTO author VALUES ('Bertold', 'Brecht', 1956)", + "CREATE TABLE empty_table (column INT, another INT)", ], ) yield ip_session diff --git a/src/tests/test_command.py b/src/tests/test_command.py index 487725dc5..808f5c9f1 100644 --- a/src/tests/test_command.py +++ b/src/tests/test_command.py @@ -18,6 +18,7 @@ def sql_magic(ip): ("sqlite://", "", "", "sqlite://", None), ("SELECT * FROM TABLE", "", "SELECT * FROM TABLE\n", "", None), ("SELECT * FROM", "TABLE", "SELECT * FROM\nTABLE", "", None), + ("my_var << SELECT * FROM table", "", "SELECT * FROM table\n", "", "my_var"), ("my_var << SELECT *", "FROM table", "SELECT *\nFROM table", "", "my_var"), ("[db]", "", "", "sqlite://", None), ], @@ -26,7 +27,8 @@ def sql_magic(ip): "connection-string", "sql-query", "sql-query-in-line-and-cell", - "parsed-var", + "parsed-var-single-line", + "parsed-var-multi-line", "config", ], ) diff --git a/src/tests/test_connection.py b/src/tests/test_connection.py new file mode 100644 index 000000000..22bd009f8 --- /dev/null +++ b/src/tests/test_connection.py @@ -0,0 +1,15 @@ +import sys +from unittest.mock import Mock + +from sqlalchemy.engine import Engine + +from sql.connection import Connection + + +def test_password_isnt_displayed(monkeypatch): + monkeypatch.setitem(sys.modules, "psycopg2", Mock()) + monkeypatch.setattr(Engine, "connect", Mock()) + + Connection.from_connect_str("postgresql://user:topsecret@somedomain.com/db") + + assert "topsecret" not in Connection.connection_list() diff --git a/src/tests/test_magic.py b/src/tests/test_magic.py index c41246504..fe5850a2a 100644 --- a/src/tests/test_magic.py +++ b/src/tests/test_magic.py @@ -1,3 +1,4 @@ +from pathlib import Path import os.path import re import tempfile @@ -6,6 +7,7 @@ import pytest from sqlalchemy import create_engine +from sql.connection import Connection from conftest import runsql @@ -179,25 +181,25 @@ def test_connection_args_double_quotes(ip): # assert 'Shakespeare' in str(persisted) +@pytest.mark.parametrize("value", ["None", "0"]) +def test_displaylimit_disabled(ip, value): + ip.run_line_magic("config", "SqlMagic.autolimit = None") + + ip.run_line_magic("config", f"SqlMagic.displaylimit = {value}") + result = runsql(ip, "SELECT * FROM author;") + + assert "Brecht" in result._repr_html_() + assert "Shakespeare" in result._repr_html_() + + def test_displaylimit(ip): ip.run_line_magic("config", "SqlMagic.autolimit = None") - ip.run_line_magic("config", "SqlMagic.displaylimit = None") - result = runsql( - ip, - "SELECT * FROM (VALUES ('apple'), ('banana'), ('cherry')) " - "AS Result ORDER BY 1;", - ) - assert "apple" in result._repr_html_() - assert "banana" in result._repr_html_() - assert "cherry" in result._repr_html_() + ip.run_line_magic("config", "SqlMagic.displaylimit = 1") - result = runsql( - ip, - "SELECT * FROM (VALUES ('apple'), ('banana'), ('cherry')) " - "AS Result ORDER BY 1;", - ) - assert "apple" in result._repr_html_() - assert "cherry" not in result._repr_html_() + result = runsql(ip, "SELECT * FROM author ORDER BY first_name;") + + assert "Brecht" in result._repr_html_() + assert "Shakespeare" not in result._repr_html_() def test_column_local_vars(ip): @@ -368,7 +370,7 @@ def test_json_in_select(ip): assert result == [('{"greeting": "Farewell sweet {person}"}',)] -def test_close_connection(ip): +def test_closed_connections_are_no_longer_listed(ip): connections = runsql(ip, "%sql -l") connection_name = list(connections)[0] runsql(ip, f"%sql -x {connection_name}") @@ -376,6 +378,33 @@ def test_close_connection(ip): assert connection_name not in connections_afterward +def test_close_connection(ip, tmp_empty): + # open two connections + ip.run_cell("%sql sqlite:///one.db") + ip.run_cell("%sql sqlite:///two.db") + + # close them + ip.run_cell("%sql -x sqlite:///one.db") + ip.run_cell("%sql --close sqlite:///two.db") + + assert "sqlite:///one.db" not in Connection.connections + assert "sqlite:///two.db" not in Connection.connections + + +def test_column_names_visible(ip, tmp_empty): + res = ip.run_line_magic("sql", "SELECT * FROM empty_table") + + assert "