Skip to content

Commit

Permalink
Support MotherDuck (#119)
Browse files Browse the repository at this point in the history
* feat: support motherduck; fix: roll back textual_textarea

* chore: add ruff rules

* chore: add motherduck token to gh workflow

* chore: update readme for multi db support

* fix: restore compatibility with py38, py39
  • Loading branch information
tconbeer authored Jun 23, 2023
1 parent 6f12692 commit 4288e56
Show file tree
Hide file tree
Showing 13 changed files with 229 additions and 97 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:
test-windows:
name: Windows - 3.10
runs-on: Windows-latest
env:
motherduck_token: ${{ secrets.HARLEQUIN_MOTHERDUCK_TOKEN }}
steps:
- name: Check out Repo
uses: actions/checkout@v3
Expand All @@ -39,6 +41,8 @@ jobs:
test:
name: ${{ matrix.os }} - ${{ matrix.py }}
runs-on: ${{ matrix.os }}-latest
env:
motherduck_token: ${{ secrets.HARLEQUIN_MOTHERDUCK_TOKEN }}
strategy:
fail-fast: false
matrix:
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Features
- Supports MotherDuck! `harlequin md:` connects to your MotherDuck instance. Optionally pass token with `--md_token <token>` and set SaaS mode with `--md_saas`.

### Bug Fixes

- Fixes issues with mouse input and focus by rolling back textual_textarea to v0.2.2

## [0.0.16] - 2023-06-20

- Press <kbd>F10</kbd> with either the Query Editor or Results Viewer in focus to enter "full-screen" mode for those widgets (and hide the other widgets). ([#100](https://github.com/tconbeer/harlequin/issues/100))
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ You can also open a database in read-only mode:
harlequin -r "path/to/duck.db"
```

### Using Harlequin with MotherDuck

You can use Harlequin with MotherDuck, just as you would use the DuckDB CLI:

```bash
harlequin "md:"
```

You can attach local databases as additional arguments (`md:` has to be first:)

```bash
harlequin "md:" "local_duck.db"
```

#### Authentication Options

1. Web browser: Run `harlequin "md:"`, and Harlequin will attempt to open a web browser where you can log in.
2. Use an environment variable: Set the `motherduck_token` variable before running `harlequin "md:"`, and Harlequin will authenticate with MotherDuck using your service token.
3. Use the CLI option: You can pass a service token to Harlequin with `harlequin "md:" --md_token <my token>`

#### SaaS Mode

You can run Harlequin in ["SaaS Mode"](https://motherduck.com/docs/authenticating-to-motherduck#authentication-using-saas-mode) by passing the `md_saas` option: `harlequin "md:" --md_saas`.

### Viewing the Schema of your Database

When Harlequin is open, you can view the schema of your DuckDB database in the left sidebar. You can use your mouse or the arrow keys + enter to navigate the tree. The tree shows schemas, tables/views and their types, and columns and their types.
Expand Down
181 changes: 118 additions & 63 deletions poetry.lock

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry.dependencies]
python = "^3.8"
textual = ">=0.27.0,<1.0.0"
textual-textarea = "==0.3.0"
textual = "==0.27.0"
textual-textarea = "==0.2.2"
click = "^8.1.3"
duckdb = "==0.8.0"
duckdb = ">=0.8.0"
shandy-sqlfmt = ">=0.19.0"

[tool.poetry.group.dev.dependencies]
Expand All @@ -40,10 +40,11 @@ pytest-asyncio = "^0.21.0"
harlequin = "harlequin.cli:harlequin"

[tool.ruff]
select = ["E", "F", "I"]
select = ["A", "B", "E", "F", "I"]
target-version = "py38"

[tool.mypy]
python_version = "3.11"
python_version = "3.8"
files = [
"src/harlequin/**/*.py",
"tests/**/*.py",
Expand Down
35 changes: 29 additions & 6 deletions src/harlequin/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from pathlib import Path
from typing import List
from typing import List, Union

import click

Expand All @@ -26,13 +25,37 @@
"monokai."
),
)
@click.option(
"--md_token",
help=(
"MotherDuck Token. Pass your MotherDuck service token in this option, or "
"set the `motherduck_token` environment variable."
),
)
@click.option(
"--md_saas",
is_flag=True,
help="Run MotherDuck in SaaS mode (no local privileges).",
)
@click.argument(
"db_path",
nargs=-1,
type=click.Path(path_type=Path),
type=click.Path(path_type=str),
)
def harlequin(db_path: List[Path], read_only: bool, theme: str) -> None:
def harlequin(
db_path: List[str],
read_only: bool,
theme: str,
md_token: Union[str, None],
md_saas: bool,
) -> None:
if not db_path:
db_path = [Path(":memory:")]
tui = Harlequin(db_path=db_path, read_only=read_only, theme=theme)
db_path = [":memory:"]
tui = Harlequin(
db_path=db_path,
read_only=read_only,
theme=theme,
md_token=md_token,
md_saas=md_saas,
)
tui.run()
23 changes: 16 additions & 7 deletions src/harlequin/duck_ops.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import List, Tuple
from typing import List, Sequence, Tuple, Union

import duckdb

Expand All @@ -11,12 +11,21 @@
Catalog = List[Tuple[str, SCHEMAS]]


def connect(db_path: List[Path], read_only: bool = False) -> duckdb.DuckDBPyConnection:
def connect(
db_path: Sequence[Union[str, Path]],
read_only: bool = False,
md_token: Union[str, None] = None,
md_saas: bool = False,
) -> duckdb.DuckDBPyConnection:
if not db_path:
db_path = [Path(":memory:")]
db_path = [":memory:"]
primary_db, *other_dbs = db_path
token = f"?token={md_token}" if md_token else ""
saas = "?saas_mode=true" if md_saas else ""
try:
connection = duckdb.connect(database=str(primary_db), read_only=read_only)
connection = duckdb.connect(
database=f"{primary_db}{token}{saas}", read_only=read_only
)
for db in other_dbs:
connection.execute(f"attach '{db}'{' (READ_ONLY)' if read_only else ''}")
except (duckdb.CatalogException, duckdb.IOException) as e:
Expand All @@ -34,7 +43,7 @@ def connect(db_path: List[Path], read_only: bool = False) -> duckdb.DuckDBPyConn
)
)

raise HarlequinExit()
raise HarlequinExit() from None
else:
return connection

Expand Down Expand Up @@ -96,9 +105,9 @@ def get_catalog(conn: duckdb.DuckDBPyConnection) -> Catalog:
for (schema,) in schemas:
tables = get_tables(conn, database, schema)
tables_data: TABLES = []
for table, type in tables:
for table, kind in tables:
columns = get_columns(conn, database, schema, table)
tables_data.append((table, type, columns))
tables_data.append((table, kind, columns))
schemas_data.append((schema, tables_data))
data.append((database, schemas_data))
return data
14 changes: 8 additions & 6 deletions src/harlequin/tui/app.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from pathlib import Path
from typing import Iterator, List, Optional, Tuple, Type, Union
from typing import Iterator, List, Optional, Sequence, Tuple, Type, Union

import duckdb
from textual import log, work
from textual.app import App, ComposeResult, CSSPathType # type: ignore
from textual.app import App, ComposeResult, CSSPathType
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.dom import DOMNode
Expand Down Expand Up @@ -49,9 +49,11 @@ class Harlequin(App, inherit_bindings=False):

def __init__(
self,
db_path: List[Path],
db_path: Sequence[Union[str, Path]],
read_only: bool = False,
theme: str = "monokai",
md_token: Union[str, None] = None,
md_saas: bool = False,
driver_class: Union[Type[Driver], None] = None,
css_path: Union[CSSPathType, None] = None,
watch_css: bool = False,
Expand Down Expand Up @@ -236,8 +238,8 @@ async def watch_relation(
short_types = [short_type(t) for t in relation.dtypes]
table.add_columns(
*[
f"{name} [#888888]{type}[/]"
for name, type in zip(relation.columns, short_types)
f"{name} [#888888]{data_type}[/]"
for name, data_type in zip(relation.columns, short_types)
]
)
try:
Expand Down Expand Up @@ -310,5 +312,5 @@ def update_schema_data(self) -> None:


if __name__ == "__main__":
app = Harlequin([Path("f1.db")])
app = Harlequin(["f1.db"])
app.run()
8 changes: 4 additions & 4 deletions src/harlequin/tui/components/code_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ async def action_submit(self) -> None:
self.post_message(self.Submitted(self.text))

def action_format(self) -> None:
input = self.query_one(TextInput)
old_cursor = input.cursor
text_input = self.query_one(TextInput)
old_cursor = text_input.cursor

try:
self.text = format_string(self.text, Mode())
Expand All @@ -61,5 +61,5 @@ def action_format(self) -> None:
)
)
else:
input.move_cursor(old_cursor.pos, old_cursor.lno)
input.update(input._content)
text_input.move_cursor(old_cursor.pos, old_cursor.lno)
text_input.update(text_input._content)
2 changes: 1 addition & 1 deletion src/harlequin/tui/components/error_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def __init__(
header: str,
error: BaseException,
name: Union[str, None] = None,
id: Union[str, None] = None,
id: Union[str, None] = None, # noqa: A002
classes: Union[str, None] = None,
) -> None:
self.title = title
Expand Down
2 changes: 1 addition & 1 deletion src/harlequin/tui/components/schema_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def __init__(
connection: DuckDBPyConnection,
data: Union[str, None] = None,
name: Union[str, None] = None,
id: Union[str, None] = None,
id: Union[str, None] = None, # noqa: A002
classes: Union[str, None] = None,
disabled: bool = False,
) -> None:
Expand Down
6 changes: 3 additions & 3 deletions tests/functional_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ def small_db(tmp_path: Path, data_dir: Path) -> Path:

@pytest.fixture
def app() -> Harlequin:
return Harlequin([Path(":memory:")])
return Harlequin([":memory:"])


@pytest.fixture
def app_small_db(small_db: Path) -> Harlequin:
return Harlequin([small_db])
return Harlequin([str(small_db)])


@pytest.fixture
def app_multi_db(tiny_db: Path, small_db: Path) -> Harlequin:
return Harlequin([tiny_db, small_db])
return Harlequin([str(tiny_db), str(small_db)])
9 changes: 8 additions & 1 deletion tests/functional_tests/test_duck_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@

def test_connect(tiny_db: Path, small_db: Path, tmp_path: Path) -> None:
assert connect([])
assert connect([Path(":memory:")])
assert connect([":memory:"])
assert connect([tiny_db], read_only=False)
assert connect([tiny_db], read_only=True)
assert connect([tiny_db, Path(":memory:"), small_db], read_only=False)
assert connect([tiny_db, small_db], read_only=True)
assert connect([tmp_path / "new.db"])


def test_connect_motherduck(tiny_db: Path) -> None:
# note: set environment variable motherduck_token
assert connect(["md:"])
assert connect(["md:cloudf1"], md_saas=True)
assert connect(["md:", tiny_db])


def test_cannot_connect(tiny_db: Path) -> None:
with pytest.raises(HarlequinExit):
connect([Path(":memory:")], read_only=True)
Expand Down

0 comments on commit 4288e56

Please sign in to comment.