Task ID: task_e_682d3e15079c8323900cefc80202d67c
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
pyee ------------------------------ 15.36 KiB/15.36 KiB
itsdangerous ------------------------------ 15.85 KiB/15.85 KiB
execnet ------------------------------ 30.88 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 32.00 KiB/63.41 KiB
packaging ------------------------------ 48.00 KiB/64.91 KiB
click ------------------------------ 64.00 KiB/99.76 KiB
flask ------------------------------ 64.00 KiB/100.88 KiB
urllib3 ------------------------------ 75.47 KiB/125.66 KiB
jinja2 ------------------------------ 62.88 KiB/131.74 KiB
charset-normalizer ------------------------------ 141.91 KiB/145.08 KiB
certifi ------------------------------ 110.87 KiB/155.88 KiB
werkzeug ------------------------------ 91.96 KiB/219.24 KiB
python-dateutil ------------------------------ 46.86 KiB/224.50 KiB
pytest ------------------------------ 62.88 KiB/335.58 KiB
greenlet ------------------------------ 32.00 KiB/589.71 KiB
pyright ------------------------------ 30.06 KiB/5.31 MiB
ruff ------------------------------ 268.82 KiB/11.02 MiB
duckdb ------------------------------ 269.61 KiB/19.27 MiB
playwright ------------------------------ 14.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
pyee ------------------------------ 15.36 KiB/15.36 KiB
execnet ------------------------------ 30.88 KiB/39.66 KiB
pytest-xdist ------------------------------ 30.90 KiB/45.03 KiB
requests ------------------------------ 48.00 KiB/63.41 KiB
packaging ------------------------------ 48.00 KiB/64.91 KiB
click ------------------------------ 80.00 KiB/99.76 KiB
flask ------------------------------ 64.00 KiB/100.88 KiB
urllib3 ------------------------------ 75.47 KiB/125.66 KiB
jinja2 ------------------------------ 91.02 KiB/131.74 KiB
charset-normalizer ------------------------------ 141.91 KiB/145.08 KiB
certifi ------------------------------ 155.88 KiB/155.88 KiB
werkzeug ------------------------------ 107.96 KiB/219.24 KiB
python-dateutil ------------------------------ 46.86 KiB/224.50 KiB
pytest ------------------------------ 76.53 KiB/335.58 KiB
greenlet ------------------------------ 48.00 KiB/589.71 KiB
pyright ------------------------------ 123.20 KiB/5.31 MiB
ruff ------------------------------ 364.82 KiB/11.02 MiB
duckdb ------------------------------ 365.61 KiB/19.27 MiB
playwright ------------------------------ 30.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
requests ------------------------------ 63.41 KiB/63.41 KiB
packaging ------------------------------ 64.91 KiB/64.91 KiB
click ------------------------------ 96.00 KiB/99.76 KiB
flask ------------------------------ 64.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 91.02 KiB/131.74 KiB
charset-normalizer ------------------------------ 145.08 KiB/145.08 KiB
certifi ------------------------------ 155.88 KiB/155.88 KiB
werkzeug ------------------------------ 107.96 KiB/219.24 KiB
python-dateutil ------------------------------ 76.12 KiB/224.50 KiB
pytest ------------------------------ 92.53 KiB/335.58 KiB
greenlet ------------------------------ 48.00 KiB/589.71 KiB
pyright ------------------------------ 203.20 KiB/5.31 MiB
ruff ------------------------------ 444.82 KiB/11.02 MiB
duckdb ------------------------------ 445.61 KiB/19.27 MiB
playwright ------------------------------ 30.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
requests ------------------------------ 63.41 KiB/63.41 KiB
packaging ------------------------------ 64.91 KiB/64.91 KiB
click ------------------------------ 96.00 KiB/99.76 KiB
flask ------------------------------ 64.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 107.02 KiB/131.74 KiB
charset-normalizer ------------------------------ 145.08 KiB/145.08 KiB
werkzeug ------------------------------ 107.96 KiB/219.24 KiB
python-dateutil ------------------------------ 76.12 KiB/224.50 KiB
pytest ------------------------------ 92.53 KiB/335.58 KiB
greenlet ------------------------------ 48.00 KiB/589.71 KiB
pyright ------------------------------ 219.20 KiB/5.31 MiB
ruff ------------------------------ 460.82 KiB/11.02 MiB
duckdb ------------------------------ 461.61 KiB/19.27 MiB
playwright ------------------------------ 30.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
requests ------------------------------ 63.41 KiB/63.41 KiB
packaging ------------------------------ 64.91 KiB/64.91 KiB
click ------------------------------ 99.76 KiB/99.76 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 107.02 KiB/131.74 KiB
charset-normalizer ------------------------------ 145.08 KiB/145.08 KiB
werkzeug ------------------------------ 123.96 KiB/219.24 KiB
python-dateutil ------------------------------ 108.12 KiB/224.50 KiB
pytest ------------------------------ 108.53 KiB/335.58 KiB
greenlet ------------------------------ 60.07 KiB/589.71 KiB
pyright ------------------------------ 251.20 KiB/5.31 MiB
ruff ------------------------------ 492.82 KiB/11.02 MiB
duckdb ------------------------------ 509.61 KiB/19.27 MiB
playwright ------------------------------ 30.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
requests ------------------------------ 63.41 KiB/63.41 KiB
packaging ------------------------------ 64.91 KiB/64.91 KiB
click ------------------------------ 99.76 KiB/99.76 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 123.02 KiB/131.74 KiB
werkzeug ------------------------------ 123.96 KiB/219.24 KiB
python-dateutil ------------------------------ 156.12 KiB/224.50 KiB
pytest ------------------------------ 124.53 KiB/335.58 KiB
greenlet ------------------------------ 76.07 KiB/589.71 KiB
pyright ------------------------------ 299.20 KiB/5.31 MiB
ruff ------------------------------ 556.82 KiB/11.02 MiB
duckdb ------------------------------ 557.61 KiB/19.27 MiB
playwright ------------------------------ 46.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
packaging ------------------------------ 64.91 KiB/64.91 KiB
click ------------------------------ 99.76 KiB/99.76 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 123.02 KiB/131.74 KiB
werkzeug ------------------------------ 123.96 KiB/219.24 KiB
python-dateutil ------------------------------ 156.12 KiB/224.50 KiB
pytest ------------------------------ 124.53 KiB/335.58 KiB
greenlet ------------------------------ 76.07 KiB/589.71 KiB
pyright ------------------------------ 315.20 KiB/5.31 MiB
ruff ------------------------------ 556.82 KiB/11.02 MiB
duckdb ------------------------------ 573.61 KiB/19.27 MiB
playwright ------------------------------ 46.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
packaging ------------------------------ 64.91 KiB/64.91 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 123.02 KiB/131.74 KiB
werkzeug ------------------------------ 123.96 KiB/219.24 KiB
python-dateutil ------------------------------ 188.12 KiB/224.50 KiB
pytest ------------------------------ 124.53 KiB/335.58 KiB
greenlet ------------------------------ 108.07 KiB/589.71 KiB
pyright ------------------------------ 347.20 KiB/5.31 MiB
ruff ------------------------------ 588.82 KiB/11.02 MiB
duckdb ------------------------------ 589.61 KiB/19.27 MiB
playwright ------------------------------ 46.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
execnet ------------------------------ 39.66 KiB/39.66 KiB
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
flask ------------------------------ 80.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 123.02 KiB/131.74 KiB
werkzeug ------------------------------ 123.96 KiB/219.24 KiB
python-dateutil ------------------------------ 188.12 KiB/224.50 KiB
pytest ------------------------------ 124.53 KiB/335.58 KiB
greenlet ------------------------------ 108.07 KiB/589.71 KiB
pyright ------------------------------ 347.20 KiB/5.31 MiB
ruff ------------------------------ 588.82 KiB/11.02 MiB
duckdb ------------------------------ 589.61 KiB/19.27 MiB
playwright ------------------------------ 46.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
pytest-xdist ------------------------------ 45.03 KiB/45.03 KiB
flask ------------------------------ 96.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 131.74 KiB/131.74 KiB
werkzeug ------------------------------ 139.96 KiB/219.24 KiB
python-dateutil ------------------------------ 220.12 KiB/224.50 KiB
pytest ------------------------------ 140.53 KiB/335.58 KiB
greenlet ------------------------------ 188.07 KiB/589.71 KiB
pyright ------------------------------ 411.20 KiB/5.31 MiB
ruff ------------------------------ 668.82 KiB/11.02 MiB
duckdb ------------------------------ 669.61 KiB/19.27 MiB
playwright ------------------------------ 46.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
flask ------------------------------ 96.00 KiB/100.88 KiB
urllib3 ------------------------------ 91.47 KiB/125.66 KiB
jinja2 ------------------------------ 131.74 KiB/131.74 KiB
werkzeug ------------------------------ 139.96 KiB/219.24 KiB
python-dateutil ------------------------------ 220.12 KiB/224.50 KiB
pytest ------------------------------ 140.53 KiB/335.58 KiB
greenlet ------------------------------ 204.07 KiB/589.71 KiB
pyright ------------------------------ 443.20 KiB/5.31 MiB
ruff ------------------------------ 684.82 KiB/11.02 MiB
duckdb ------------------------------ 685.61 KiB/19.27 MiB
playwright ------------------------------ 46.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
flask ------------------------------ 100.88 KiB/100.88 KiB
urllib3 ------------------------------ 123.47 KiB/125.66 KiB
werkzeug ------------------------------ 139.96 KiB/219.24 KiB
python-dateutil ------------------------------ 224.50 KiB/224.50 KiB
pytest ------------------------------ 188.53 KiB/335.58 KiB
greenlet ------------------------------ 332.07 KiB/589.71 KiB
pyright ------------------------------ 571.20 KiB/5.31 MiB
ruff ------------------------------ 828.82 KiB/11.02 MiB
duckdb ------------------------------ 829.61 KiB/19.27 MiB
playwright ------------------------------ 62.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
flask ------------------------------ 100.88 KiB/100.88 KiB
urllib3 ------------------------------ 123.47 KiB/125.66 KiB
werkzeug ------------------------------ 139.96 KiB/219.24 KiB
pytest ------------------------------ 188.53 KiB/335.58 KiB
greenlet ------------------------------ 364.07 KiB/589.71 KiB
pyright ------------------------------ 603.20 KiB/5.31 MiB
ruff ------------------------------ 844.82 KiB/11.02 MiB
duckdb ------------------------------ 845.61 KiB/19.27 MiB
playwright ------------------------------ 62.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠹ Preparing packages... (0/33)
urllib3 ------------------------------ 123.47 KiB/125.66 KiB
werkzeug ------------------------------ 139.96 KiB/219.24 KiB
pytest ------------------------------ 188.53 KiB/335.58 KiB
greenlet ------------------------------ 396.07 KiB/589.71 KiB
pyright ------------------------------ 619.20 KiB/5.31 MiB
ruff ------------------------------ 860.82 KiB/11.02 MiB
duckdb ------------------------------ 861.61 KiB/19.27 MiB
playwright ------------------------------ 62.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (22/33)
urllib3 ------------------------------ 123.47 KiB/125.66 KiB
werkzeug ------------------------------ 139.96 KiB/219.24 KiB
pytest ------------------------------ 188.53 KiB/335.58 KiB
greenlet ------------------------------ 396.07 KiB/589.71 KiB
pyright ------------------------------ 619.20 KiB/5.31 MiB
ruff ------------------------------ 860.82 KiB/11.02 MiB
duckdb ------------------------------ 877.61 KiB/19.27 MiB
playwright ------------------------------ 62.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (22/33)
werkzeug ------------------------------ 155.96 KiB/219.24 KiB
pytest ------------------------------ 220.53 KiB/335.58 KiB
greenlet ------------------------------ 460.07 KiB/589.71 KiB
pyright ------------------------------ 811.20 KiB/5.31 MiB
ruff ------------------------------ 1.04 MiB/11.02 MiB
duckdb ------------------------------ 1.04 MiB/19.27 MiB
playwright ------------------------------ 78.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (22/33)
werkzeug ------------------------------ 187.96 KiB/219.24 KiB
pytest ------------------------------ 220.53 KiB/335.58 KiB
greenlet ------------------------------ 476.07 KiB/589.71 KiB
pyright ------------------------------ 955.20 KiB/5.31 MiB
ruff ------------------------------ 1.20 MiB/11.02 MiB
duckdb ------------------------------ 1.19 MiB/19.27 MiB
playwright ------------------------------ 94.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (22/33)
pytest ------------------------------ 252.53 KiB/335.58 KiB
greenlet ------------------------------ 492.07 KiB/589.71 KiB
pyright ------------------------------ 1.23 MiB/5.31 MiB
ruff ------------------------------ 1.48 MiB/11.02 MiB
duckdb ------------------------------ 1.48 MiB/19.27 MiB
playwright ------------------------------ 110.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (22/33)
pytest ------------------------------ 284.53 KiB/335.58 KiB
greenlet ------------------------------ 492.07 KiB/589.71 KiB
pyright ------------------------------ 1.32 MiB/5.31 MiB
ruff ------------------------------ 1.64 MiB/11.02 MiB
duckdb ------------------------------ 1.64 MiB/19.27 MiB
playwright ------------------------------ 190.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (22/33)
pytest ------------------------------ 332.53 KiB/335.58 KiB
greenlet ------------------------------ 540.07 KiB/589.71 KiB
pyright ------------------------------ 1.77 MiB/5.31 MiB
ruff ------------------------------ 2.23 MiB/11.02 MiB
duckdb ------------------------------ 2.23 MiB/19.27 MiB
playwright ------------------------------ 718.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (22/33)
greenlet ------------------------------ 540.07 KiB/589.71 KiB
pyright ------------------------------ 1.79 MiB/5.31 MiB
ruff ------------------------------ 2.27 MiB/11.02 MiB
duckdb ------------------------------ 2.27 MiB/19.27 MiB
playwright ------------------------------ 750.91 KiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠸ Preparing packages... (22/33)
greenlet ------------------------------ 589.71 KiB/589.71 KiB
pyright ------------------------------ 1.80 MiB/5.31 MiB
ruff ------------------------------ 3.00 MiB/11.02 MiB
duckdb ------------------------------ 3.00 MiB/19.27 MiB
playwright ------------------------------ 1.47 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠼ Preparing packages... (27/33)
pyright ------------------------------ 1.82 MiB/5.31 MiB
ruff ------------------------------ 3.29 MiB/11.02 MiB
duckdb ------------------------------ 3.30 MiB/19.27 MiB
playwright ------------------------------ 1.75 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠼ Preparing packages... (27/33)
pyright ------------------------------ 1.82 MiB/5.31 MiB
ruff ------------------------------ 3.68 MiB/11.02 MiB
duckdb ------------------------------ 3.67 MiB/19.27 MiB
playwright ------------------------------ 2.12 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠼ Preparing packages... (27/33)
pyright ------------------------------ 1.86 MiB/5.31 MiB
ruff ------------------------------ 4.39 MiB/11.02 MiB
duckdb ------------------------------ 4.39 MiB/19.27 MiB
playwright ------------------------------ 2.84 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠼ Preparing packages... (27/33)
pyright ------------------------------ 1.91 MiB/5.31 MiB
ruff ------------------------------ 5.08 MiB/11.02 MiB
duckdb ------------------------------ 5.06 MiB/19.27 MiB
playwright ------------------------------ 3.54 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠼ Preparing packages... (27/33)
pyright ------------------------------ 1.94 MiB/5.31 MiB
ruff ------------------------------ 5.86 MiB/11.02 MiB
duckdb ------------------------------ 5.83 MiB/19.27 MiB
playwright ------------------------------ 4.33 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠴ Preparing packages... (28/33)
pyright ------------------------------ 1.97 MiB/5.31 MiB
ruff ------------------------------ 6.69 MiB/11.02 MiB
duckdb ------------------------------ 6.64 MiB/19.27 MiB
playwright ------------------------------ 5.15 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠴ Preparing packages... (28/33)
pyright ------------------------------ 2.01 MiB/5.31 MiB
ruff ------------------------------ 7.50 MiB/11.02 MiB
duckdb ------------------------------ 7.47 MiB/19.27 MiB
playwright ------------------------------ 5.98 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠴ Preparing packages... (28/33)
pyright ------------------------------ 2.06 MiB/5.31 MiB
ruff ------------------------------ 8.29 MiB/11.02 MiB
duckdb ------------------------------ 8.25 MiB/19.27 MiB
playwright ------------------------------ 6.78 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠴ Preparing packages... (28/33)
pyright ------------------------------ 2.09 MiB/5.31 MiB
ruff ------------------------------ 9.06 MiB/11.02 MiB
duckdb ------------------------------ 9.01 MiB/19.27 MiB
playwright ------------------------------ 7.56 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠦ Preparing packages... (28/33)
pyright ------------------------------ 2.12 MiB/5.31 MiB
ruff ------------------------------ 9.81 MiB/11.02 MiB
duckdb ------------------------------ 9.75 MiB/19.27 MiB
playwright ------------------------------ 8.30 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠦ Preparing packages... (28/33)
pyright ------------------------------ 2.14 MiB/5.31 MiB
ruff ------------------------------ 10.64 MiB/11.02 MiB
duckdb ------------------------------ 10.59 MiB/19.27 MiB
playwright ------------------------------ 9.10 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠦ Preparing packages... (28/33)
pyright ------------------------------ 2.14 MiB/5.31 MiB
duckdb ------------------------------ 11.01 MiB/19.27 MiB
playwright ------------------------------ 9.54 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠦ Preparing packages... (28/33)
pyright ------------------------------ 2.15 MiB/5.31 MiB
duckdb ------------------------------ 11.52 MiB/19.27 MiB
playwright ------------------------------ 10.02 MiB/43.05 MiB
Building scubaduck @ file:///workspace/scubaduck
⠦ Preparing packages... (28/33)
pyright ------------------------------ 2.19 MiB/5.31 MiB
duckdb ------------------------------ 12.70 MiB/19.27 MiB
playwright ------------------------------ 11.20 MiB/43.05 MiB
Built scubaduck @ file:///workspace/scubaduck
⠧ Preparing packages... (29/33)
pyright ------------------------------ 2.19 MiB/5.31 MiB
duckdb ------------------------------ 12.75 MiB/19.27 MiB
playwright ------------------------------ 11.26 MiB/43.05 MiB
⠧ Preparing packages... (29/33)
pyright ------------------------------ 2.19 MiB/5.31 MiB
duckdb ------------------------------ 13.44 MiB/19.27 MiB
playwright ------------------------------ 11.97 MiB/43.05 MiB
⠧ Preparing packages... (29/33)
pyright ------------------------------ 2.20 MiB/5.31 MiB
duckdb ------------------------------ 14.62 MiB/19.27 MiB
playwright ------------------------------ 13.15 MiB/43.05 MiB
⠧ Preparing packages... (29/33)
pyright ------------------------------ 2.22 MiB/5.31 MiB
duckdb ------------------------------ 15.84 MiB/19.27 MiB
playwright ------------------------------ 14.33 MiB/43.05 MiB
⠧ Preparing packages... (29/33)
pyright ------------------------------ 2.25 MiB/5.31 MiB
duckdb ------------------------------ 17.08 MiB/19.27 MiB
playwright ------------------------------ 15.56 MiB/43.05 MiB
⠇ Preparing packages... (30/33)
pyright ------------------------------ 2.25 MiB/5.31 MiB
duckdb ------------------------------ 18.09 MiB/19.27 MiB
playwright ------------------------------ 16.59 MiB/43.05 MiB
⠇ Preparing packages... (30/33)
pyright ------------------------------ 2.28 MiB/5.31 MiB
duckdb ------------------------------ 19.17 MiB/19.27 MiB
playwright ------------------------------ 17.70 MiB/43.05 MiB
⠇ Preparing packages... (30/33)
pyright ------------------------------ 2.34 MiB/5.31 MiB
duckdb ------------------------------ 19.27 MiB/19.27 MiB
playwright ------------------------------ 19.44 MiB/43.05 MiB
⠇ Preparing packages... (30/33)
pyright ------------------------------ 2.36 MiB/5.31 MiB
playwright ------------------------------ 19.51 MiB/43.05 MiB
⠋ Preparing packages... (31/33)
pyright ------------------------------ 2.44 MiB/5.31 MiB
playwright ------------------------------ 20.39 MiB/43.05 MiB
⠋ Preparing packages... (31/33)
pyright ------------------------------ 2.52 MiB/5.31 MiB
playwright ------------------------------ 20.65 MiB/43.05 MiB
⠋ Preparing packages... (31/33)
pyright ------------------------------ 2.60 MiB/5.31 MiB
playwright ------------------------------ 21.33 MiB/43.05 MiB
⠋ Preparing packages... (31/33)
pyright ------------------------------ 2.63 MiB/5.31 MiB
playwright ------------------------------ 23.09 MiB/43.05 MiB
⠋ Preparing packages... (31/33)
pyright ------------------------------ 2.65 MiB/5.31 MiB
playwright ------------------------------ 24.65 MiB/43.05 MiB
⠙ Preparing packages... (31/33)
pyright ------------------------------ 2.67 MiB/5.31 MiB
playwright ------------------------------ 26.40 MiB/43.05 MiB
⠙ Preparing packages... (31/33)
pyright ------------------------------ 2.70 MiB/5.31 MiB
playwright ------------------------------ 27.72 MiB/43.05 MiB
⠙ Preparing packages... (31/33)
pyright ------------------------------ 2.70 MiB/5.31 MiB
playwright ------------------------------ 29.90 MiB/43.05 MiB
⠙ Preparing packages... (31/33)
pyright ------------------------------ 2.73 MiB/5.31 MiB
playwright ------------------------------ 31.82 MiB/43.05 MiB
⠹ Preparing packages... (31/33)
pyright ------------------------------ 2.78 MiB/5.31 MiB
playwright ------------------------------ 33.42 MiB/43.05 MiB
⠹ Preparing packages... (31/33)
pyright ------------------------------ 2.79 MiB/5.31 MiB
playwright ------------------------------ 35.11 MiB/43.05 MiB
⠹ Preparing packages... (31/33)
pyright ------------------------------ 2.81 MiB/5.31 MiB
playwright ------------------------------ 36.96 MiB/43.05 MiB
⠹ Preparing packages... (31/33)
pyright ------------------------------ 2.86 MiB/5.31 MiB
playwright ------------------------------ 38.55 MiB/43.05 MiB
⠸ Preparing packages... (31/33)
pyright ------------------------------ 2.89 MiB/5.31 MiB
playwright ------------------------------ 40.45 MiB/43.05 MiB
⠸ Preparing packages... (31/33)
pyright ------------------------------ 2.95 MiB/5.31 MiB
playwright ------------------------------ 40.98 MiB/43.05 MiB
⠸ Preparing packages... (31/33)
pyright ------------------------------ 3.04 MiB/5.31 MiB
playwright ------------------------------ 41.41 MiB/43.05 MiB
⠸ Preparing packages... (31/33)
pyright ------------------------------ 3.10 MiB/5.31 MiB
playwright ------------------------------ 42.19 MiB/43.05 MiB
⠼ Preparing packages... (31/33)
pyright ------------------------------ 3.14 MiB/5.31 MiB
⠼ Preparing packages... (31/33)
pyright ------------------------------ 3.17 MiB/5.31 MiB
⠼ Preparing packages... (31/33)
pyright ------------------------------ 3.25 MiB/5.31 MiB
⠼ Preparing packages... (31/33)
pyright ------------------------------ 3.34 MiB/5.31 MiB
⠼ Preparing packages... (31/33)
pyright ------------------------------ 3.50 MiB/5.31 MiB
⠴ Preparing packages... (32/33)
pyright ------------------------------ 3.68 MiB/5.31 MiB
⠴ Preparing packages... (32/33)
pyright ------------------------------ 3.84 MiB/5.31 MiB
⠴ Preparing packages... (32/33)
pyright ------------------------------ 4.14 MiB/5.31 MiB
⠴ Preparing packages... (32/33)
pyright ------------------------------ 4.25 MiB/5.31 MiB
⠦ Preparing packages... (32/33)
pyright ------------------------------ 4.37 MiB/5.31 MiB
⠦ Preparing packages... (32/33)
pyright ------------------------------ 4.48 MiB/5.31 MiB
⠦ Preparing packages... (32/33)
pyright ------------------------------ 4.57 MiB/5.31 MiB
⠦ Preparing packages... (32/33)
Prepared 33 packages in 2.97s
░░░░░░░░░░░░░░░░░░░░ [0/0] Installing wheels...
░░░░░░░░░░░░░░░░░░░░ [0/33] Installing wheels...
░░░░░░░░░░░░░░░░░░░░ [0/33] blinker==1.9.0
░░░░░░░░░░░░░░░░░░░░ [1/33] blinker==1.9.0
░░░░░░░░░░░░░░░░░░░░ [1/33] iniconfig==2.1.0
█░░░░░░░░░░░░░░░░░░░ [2/33] iniconfig==2.1.0
█░░░░░░░░░░░░░░░░░░░ [2/33] itsdangerous==2.2.0
█░░░░░░░░░░░░░░░░░░░ [3/33] itsdangerous==2.2.0
█░░░░░░░░░░░░░░░░░░░ [3/33] typing-extensions==4.13.2
██░░░░░░░░░░░░░░░░░░ [4/33] typing-extensions==4.13.2
██░░░░░░░░░░░░░░░░░░ [4/33] text-unidecode==1.3
███░░░░░░░░░░░░░░░░░ [5/33] text-unidecode==1.3
███░░░░░░░░░░░░░░░░░ [5/33] requests==2.32.3
███░░░░░░░░░░░░░░░░░ [6/33] requests==2.32.3
███░░░░░░░░░░░░░░░░░ [6/33] pytest-base-url==2.1.0
████░░░░░░░░░░░░░░░░ [7/33] pytest-base-url==2.1.0
████░░░░░░░░░░░░░░░░ [7/33] pytest-playwright==0.7.0
████░░░░░░░░░░░░░░░░ [8/33] pytest-playwright==0.7.0
████░░░░░░░░░░░░░░░░ [8/33] pyee==13.0.0
█████░░░░░░░░░░░░░░░ [9/33] pyee==13.0.0
███████████████████░ [32/33] pyright==1.1.400
████████████████████ [33/33] pyright==1.1.400
Installed 33 packages in 114ms
+ blinker==1.9.0
+ certifi==2025.4.26
+ charset-normalizer==3.4.2
+ click==8.2.0
+ duckdb==1.2.2
+ execnet==2.1.1
+ flask==3.1.1
+ greenlet==3.2.2
+ idna==3.10
+ iniconfig==2.1.0
+ itsdangerous==2.2.0
+ jinja2==3.1.6
+ markupsafe==3.0.2
+ nodeenv==1.9.1
+ packaging==25.0
+ playwright==1.52.0
+ pluggy==1.6.0
+ pyee==13.0.0
+ pyright==1.1.400
+ pytest==8.3.5
+ pytest-base-url==2.1.0
+ pytest-playwright==0.7.0
+ pytest-xdist==3.6.1
+ python-dateutil==2.9.0.post0
+ python-slugify==8.0.4
+ requests==2.32.3
+ ruff==0.11.10
+ scubaduck==0.1.0 (from file:///workspace/scubaduck)
+ six==1.17.0
+ text-unidecode==1.3
+ typing-extensions==4.13.2
+ urllib3==2.4.0
+ werkzeug==3.1.3
++ source .venv/bin/activate
+++ '[' -z '' ']'
+++ '[' -n x ']'
+++ SCRIPT_PATH=.venv/bin/activate
+++ '[' .venv/bin/activate = /tmp/9xjiG0-setup_script.sh ']'
+++ deactivate nondestructive
+++ unset -f pydoc
+++ '[' -z '' ']'
+++ '[' -z '' ']'
+++ hash -r
+++ '[' -z '' ']'
+++ unset VIRTUAL_ENV
+++ unset VIRTUAL_ENV_PROMPT
+++ '[' '!' nondestructive = nondestructive ']'
+++ VIRTUAL_ENV=/workspace/scubaduck/.venv
+++ '[' linux-gnu = cygwin ']'
+++ '[' linux-gnu = msys ']'
+++ export VIRTUAL_ENV
+++ '[' -z '' ']'
+++ unset SCRIPT_PATH
+++ _OLD_VIRTUAL_PATH=/root/.cargo/bin:/root/.rbenv/shims:/root/.rbenv/bin:/root/.rbenv/shims:/root/.local/share/swiftly/bin:/root/.bun/bin:/root/.nvm/versions/node/v22.15.1/bin:/root/.pyenv/shims:3441PYENV_ROOT/shims:/root/.pyenv/bin:/usr/local/go/bin:/root/go/bin:/root/.rbenv/bin:/root/.rbenv/shims:/root/.bun/bin:/root/.local/bin:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+++ PATH=/workspace/scubaduck/.venv/bin:/root/.cargo/bin:/root/.rbenv/shims:/root/.rbenv/bin:/root/.rbenv/shims:/root/.local/share/swiftly/bin:/root/.bun/bin:/root/.nvm/versions/node/v22.15.1/bin:/root/.pyenv/shims:3441PYENV_ROOT/shims:/root/.pyenv/bin:/usr/local/go/bin:/root/go/bin:/root/.rbenv/bin:/root/.rbenv/shims:/root/.bun/bin:/root/.local/bin:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+++ export PATH
+++ '[' xscubaduck '!=' x ']'
+++ VIRTUAL_ENV_PROMPT='(scubaduck) '
+++ export VIRTUAL_ENV_PROMPT
+++ '[' -z '' ']'
+++ '[' -z '' ']'
+++ _OLD_VIRTUAL_PS1=
+++ PS1='(scubaduck) '
+++ export PS1
+++ alias pydoc
+++ true
+++ hash -r
++ playwright install chromium
Downloading Chromium 136.0.7103.25 (playwright build v1169) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1169/chromium-linux.zip
167.7 MiB [] 0% 0.0s167.7 MiB [] 0% 30.9s167.7 MiB [] 0% 28.5s167.7 MiB [] 0% 17.6s167.7 MiB [] 0% 11.3s167.7 MiB [] 1% 8.1s167.7 MiB [] 1% 6.3s167.7 MiB [] 2% 5.5s167.7 MiB [] 3% 4.2s167.7 MiB [] 4% 3.3s167.7 MiB [] 5% 3.4s167.7 MiB [] 5% 3.6s167.7 MiB [] 5% 3.8s167.7 MiB [] 5% 3.9s167.7 MiB [] 5% 4.1s167.7 MiB [] 6% 4.3s167.7 MiB [] 7% 4.1s167.7 MiB [] 8% 3.7s167.7 MiB [] 9% 3.5s167.7 MiB [] 10% 3.4s167.7 MiB [] 10% 3.5s167.7 MiB [] 11% 3.2s167.7 MiB [] 13% 2.9s167.7 MiB [] 14% 2.7s167.7 MiB [] 15% 2.7s167.7 MiB [] 16% 2.6s167.7 MiB [] 17% 2.5s167.7 MiB [] 19% 2.4s167.7 MiB [] 20% 2.3s167.7 MiB [] 21% 2.2s167.7 MiB [] 22% 2.1s167.7 MiB [] 23% 2.1s167.7 MiB [] 24% 2.0s167.7 MiB [] 26% 1.9s167.7 MiB [] 27% 1.8s167.7 MiB [] 27% 1.9s167.7 MiB [] 29% 1.8s167.7 MiB [] 30% 1.7s167.7 MiB [] 31% 1.7s167.7 MiB [] 33% 1.6s167.7 MiB [] 34% 1.6s167.7 MiB [] 36% 1.5s167.7 MiB [] 37% 1.5s167.7 MiB [] 38% 1.4s167.7 MiB [] 40% 1.4s167.7 MiB [] 41% 1.3s167.7 MiB [] 42% 1.3s167.7 MiB [] 43% 1.2s167.7 MiB [] 44% 1.2s167.7 MiB [] 45% 1.2s167.7 MiB [] 46% 1.2s167.7 MiB [] 47% 1.2s167.7 MiB [] 48% 1.1s167.7 MiB [] 49% 1.1s167.7 MiB [] 50% 1.0s167.7 MiB [] 52% 1.0s167.7 MiB [] 54% 1.0s167.7 MiB [] 54% 0.9s167.7 MiB [] 55% 0.9s167.7 MiB [] 56% 0.9s167.7 MiB [] 57% 0.9s167.7 MiB [] 59% 0.8s167.7 MiB [] 60% 0.8s167.7 MiB [] 61% 0.8s167.7 MiB [] 62% 0.8s167.7 MiB [] 63% 0.7s167.7 MiB [] 64% 0.7s167.7 MiB [] 65% 0.7s167.7 MiB [] 66% 0.7s167.7 MiB [] 67% 0.7s167.7 MiB [] 68% 0.6s167.7 MiB [] 69% 0.6s167.7 MiB [] 71% 0.6s167.7 MiB [] 72% 0.5s167.7 MiB [] 73% 0.5s167.7 MiB [] 74% 0.5s167.7 MiB [] 76% 0.5s167.7 MiB [] 77% 0.4s167.7 MiB [] 78% 0.4s167.7 MiB [] 79% 0.4s167.7 MiB [] 81% 0.4s167.7 MiB [] 82% 0.3s167.7 MiB [] 83% 0.3s167.7 MiB [] 84% 0.3s167.7 MiB [] 86% 0.3s167.7 MiB [] 87% 0.2s167.7 MiB [] 88% 0.2s167.7 MiB [] 89% 0.2s167.7 MiB [] 91% 0.2s167.7 MiB [] 92% 0.1s167.7 MiB [] 93% 0.1s167.7 MiB [] 94% 0.1s167.7 MiB [] 95% 0.1s167.7 MiB [] 96% 0.1s167.7 MiB [] 97% 0.0s167.7 MiB [] 98% 0.0s167.7 MiB [] 99% 0.0s167.7 MiB [] 100% 0.0s
Chromium 136.0.7103.25 (playwright build v1169) downloaded to /root/.cache/ms-playwright/chromium-1169
Downloading FFMPEG playwright build v1011 from https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux.zip
2.3 MiB [] 0% 0.0s2.3 MiB [] 3% 0.5s2.3 MiB [] 8% 0.4s2.3 MiB [] 17% 0.2s2.3 MiB [] 26% 0.2s2.3 MiB [] 77% 0.0s2.3 MiB [] 100% 0.0s
FFMPEG playwright build v1011 downloaded to /root/.cache/ms-playwright/ffmpeg-1011
Downloading Chromium Headless Shell 136.0.7103.25 (playwright build v1169) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1169/chromium-headless-shell-linux.zip
101.4 MiB [] 0% 0.0s101.4 MiB [] 0% 18.0s101.4 MiB [] 0% 19.3s101.4 MiB [] 0% 13.1s101.4 MiB [] 0% 8.1s101.4 MiB [] 1% 8.2s101.4 MiB [] 2% 4.0s101.4 MiB [] 3% 2.8s101.4 MiB [] 5% 2.2s101.4 MiB [] 7% 1.8s101.4 MiB [] 9% 1.6s101.4 MiB [] 10% 1.6s101.4 MiB [] 11% 1.5s101.4 MiB [] 13% 1.4s101.4 MiB [] 15% 1.3s101.4 MiB [] 16% 1.2s101.4 MiB [] 18% 1.1s101.4 MiB [] 21% 1.0s101.4 MiB [] 22% 1.0s101.4 MiB [] 25% 0.9s101.4 MiB [] 27% 0.8s101.4 MiB [] 30% 0.8s101.4 MiB [] 33% 0.7s101.4 MiB [] 36% 0.7s101.4 MiB [] 38% 0.6s101.4 MiB [] 41% 0.6s101.4 MiB [] 44% 0.5s101.4 MiB [] 45% 0.5s101.4 MiB [] 46% 0.5s101.4 MiB [] 48% 0.5s101.4 MiB [] 51% 0.5s101.4 MiB [] 54% 0.4s101.4 MiB [] 55% 0.4s101.4 MiB [] 56% 0.4s101.4 MiB [] 58% 0.4s101.4 MiB [] 60% 0.4s101.4 MiB [] 62% 0.4s101.4 MiB [] 63% 0.4s101.4 MiB [] 64% 0.3s101.4 MiB [] 67% 0.3s101.4 MiB [] 68% 0.3s101.4 MiB [] 69% 0.3s101.4 MiB [] 72% 0.3s101.4 MiB [] 75% 0.2s101.4 MiB [] 77% 0.2s101.4 MiB [] 79% 0.2s101.4 MiB [] 81% 0.2s101.4 MiB [] 83% 0.2s101.4 MiB [] 84% 0.1s101.4 MiB [] 85% 0.1s101.4 MiB [] 87% 0.1s101.4 MiB [] 89% 0.1s101.4 MiB [] 90% 0.1s101.4 MiB [] 91% 0.1s101.4 MiB [] 93% 0.1s101.4 MiB [] 94% 0.1s101.4 MiB [] 97% 0.0s101.4 MiB [] 99% 0.0s101.4 MiB [] 100% 0.0s
Chromium Headless Shell 136.0.7103.25 (playwright build v1169) downloaded to /root/.cache/ms-playwright/chromium_headless_shell-1169
++ echo 'source .venv/bin/activate'
I'll read the AGENTS.md file in the repository root.
bash: .venv/bin/activate: No such file or directory
root@82cf664bb242:/workspace# ls
scubaduck
root@82cf664bb242:/workspace# cd scubaduck
root@82cf664bb242:/workspace/scubaduck# ls
AGENTS.md codex_setup.sh scubaduck tests
README.md pyproject.toml stubs uv.lock
root@82cf664bb242:/workspace/scubaduck# cat AGENTS.md
- Use "source .venv/bin/activate" to activate the virtual environment before
running any commands
- ONLY use non-interactive commands like cat, sed, apply_patch to do edits.
Do NOT use interactive editors.
- Do NOT attempt to install packages. Only the packages specified in
pyproject.toml are available. You cannot add new packages. If you
desperately want another package, make a note of it in the final PR
description.
- Use conventional commits to format PR title
- There are no nested AGENTS.md files, this is the only agents file
- When using Playwright, ONLY use chromium browser. This browser is already
installed.
- Use "ruff check" to check lint, "ruff format" to autoformat files and
"pyright" to typecheck.
- When writing the PR description, include the original prompt supplied by the
user.
- When you add functionality to the server, add server tests to
tests/test_server.py. When you add features to the frontend, add frontend
tests to tests/test_web.py
I'll follow guidance to use a virtual environment, apply patches, and run tests as needed.
root@82cf664bb242:/workspace/scubaduck# ls -R | head
.:
AGENTS.md
README.md
codex_setup.sh
pyproject.toml
scubaduck
stubs
tests
uv.lock
I'm checking the contents of the scubaduck directory now.
root@82cf664bb242:/workspace/scubaduck# ls scubaduck
__init__.py sample.csv server.py static
I'll check the contents of the tests folder now.
root@82cf664bb242:/workspace/scubaduck# ls tests
conftest.py test_server.py test_web.py
root@82cf664bb242:/workspace/scubaduck# sed -n '1,160p' scubaduck/server.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Tuple
import re
from datetime import datetime, timedelta, timezone
import time
from pathlib import Path
import sqlite3
import traceback
import duckdb
from dateutil import parser as dtparser
from flask import Flask, jsonify, request, send_from_directory
@dataclass
class Filter:
column: str
op: str
value: str | int | float | list[str] | None
@dataclass
class QueryParams:
start: str | None = None
end: str | None = None
order_by: str | None = None
order_dir: str = "ASC"
limit: int | None = None
columns: list[str] = field(default_factory=lambda: [])
filters: list[Filter] = field(default_factory=lambda: [])
derived_columns: dict[str, str] = field(default_factory=lambda: {})
graph_type: str = "samples"
group_by: list[str] = field(default_factory=lambda: [])
aggregate: str | None = None
show_hits: bool = False
def _load_database(path: Path) -> duckdb.DuckDBPyConnection:
ext = path.suffix.lower()
if ext == ".csv":
con = duckdb.connect()
con.execute(
f"CREATE TABLE events AS SELECT * FROM read_csv_auto('{path.as_posix
()}')"
)
elif ext in {".db", ".sqlite"}:
con = duckdb.connect()
sconn = sqlite3.connect(path)
info = sconn.execute("PRAGMA table_info(events)").fetchall()
col_defs = ", ".join(f"{r[1]} {r[2]}" for r in info)
con.execute(f"CREATE TABLE events ({col_defs})")
placeholders = ",".join("?" for _ in info)
for row in sconn.execute("SELECT * FROM events"):
con.execute(f"INSERT INTO events VALUES ({placeholders})", row)
sconn.close()
else:
con = duckdb.connect(path)
return con
_REL_RE = re.compile(
r"([+-]?\d+(?:\.\d*)?)\s*(hour|hours|day|days|week|weeks|fortnight|fortnight
s)",
re.IGNORECASE,
)
def parse_time(val: str | None) -> str | None:
"""Parse an absolute or relative time string into ``YYYY-MM-DD HH:MM:SS``.""
"
if val is None or val == "":
return None
s = val.strip()
if s.lower() == "now":
dt = datetime.now(timezone.utc)
return dt.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
m = _REL_RE.fullmatch(s)
if m:
qty = float(m.group(1))
unit = m.group(2).lower()
delta: timedelta
if unit.startswith("hour"):
delta = timedelta(hours=qty)
elif unit.startswith("day"):
delta = timedelta(days=qty)
elif unit.startswith("week"):
delta = timedelta(weeks=qty)
elif unit.startswith("fortnight"):
delta = timedelta(weeks=2 * qty)
else: # pragma: no cover - defensive
raise ValueError(f"Unsupported unit: {unit}")
dt = datetime.now(timezone.utc) + delta
return dt.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
dt = dtparser.parse(s)
return dt.replace(microsecond=0, tzinfo=None).strftime("%Y-%m-%d %H:%M:%S")
def build_query(params: QueryParams, column_types: Dict[str, str] | None = None)
-> str:
select_parts: list[str] = []
has_agg = bool(params.group_by) or params.aggregate is not None
if has_agg:
select_parts.extend(params.group_by)
agg = (params.aggregate or "avg").lower()
def agg_expr(col: str) -> str:
if agg.startswith("p"):
quant = float(agg[1:]) / 100
return f"quantile({col}, {quant})"
if agg == "count distinct":
return f"count(DISTINCT {col})"
if agg == "avg" and column_types is not None:
ctype = column_types.get(col, "").upper()
if "TIMESTAMP" in ctype or "DATE" in ctype or "TIME" in ctype:
return (
"TIMESTAMP 'epoch' + INTERVAL '1 second' * "
f"CAST(avg(epoch({col})) AS BIGINT)"
)
return f"{agg}({col})"
for col in params.columns:
if col in params.group_by:
continue
select_parts.append(f"{agg_expr(col)} AS {col}")
if params.show_hits:
select_parts.insert(len(params.group_by), "count(*) AS Hits")
else:
select_parts.extend(params.columns)
for name, expr in params.derived_columns.items():
select_parts.append(f"{expr} AS {name}")
select_clause = ", ".join(select_parts) if select_parts else "*"
query = f"SELECT {select_clause} FROM events"
where_parts: list[str] = []
if params.start:
where_parts.append(f"timestamp >= '{params.start}'")
if params.end:
where_parts.append(f"timestamp <= '{params.end}'")
for f in params.filters:
op = f.op
if op in {"empty", "!empty"}:
val = "''"
else:
if f.value is None:
continue
if isinstance(f.value, list):
if not f.value:
continue
if op == "=":
vals = " OR ".join(f"{f.column} = '{v}'" for v in f.value)
where_parts.append(f"({vals})")
continue
val = f"'{f.value}'" if isinstance(f.value, str) else str(f.value)
if op == "contains":
where_parts.append(f"{f.column} ILIKE '%' || {val} || '%'")
elif op == "!contains":
where_parts.append(f"{f.column} NOT ILIKE '%' || {val} || '%'")
elif op == "empty":
root@82cf664bb242:/workspace/scubaduck# sed -n '160,320p' scubaduck/server.py
elif op == "empty":
where_parts.append(f"{f.column} = {val}")
elif op == "!empty":
where_parts.append(f"{f.column} != {val}")
else:
where_parts.append(f"{f.column} {op} {val}")
if where_parts:
query += " WHERE " + " AND ".join(where_parts)
if params.group_by:
query += " GROUP BY " + ", ".join(params.group_by)
if params.order_by:
query += f" ORDER BY {params.order_by} {params.order_dir}"
if params.limit is not None:
query += f" LIMIT {params.limit}"
return query
def create_app(db_file: str | Path | None = None) -> Flask:
app = Flask(__name__, static_folder="static")
db_path = Path(db_file or Path(__file__).with_name("sample.csv")).resolve()
con = _load_database(db_path)
column_types: Dict[str, str] = {
r[1]: r[2] for r in con.execute("PRAGMA table_info(events)").fetchall()
}
sample_cache: Dict[Tuple[str, str], Tuple[List[str], float]] = {}
CACHE_TTL = 60.0
CACHE_LIMIT = 200
@app.route("/")
def index() -> Any: # pyright: ignore[reportUnusedFunction]
assert app.static_folder is not None
return send_from_directory(app.static_folder, "index.html")
@app.route("/api/columns")
def columns() -> Any: # pyright: ignore[reportUnusedFunction]
rows = con.execute("PRAGMA table_info(events)").fetchall()
return jsonify([{"name": r[1], "type": r[2]} for r in rows])
def _cache_get(key: Tuple[str, str]) -> List[str] | None:
item = sample_cache.get(key)
if item is None:
return None
vals, ts = item
if time.time() - ts > CACHE_TTL:
del sample_cache[key]
return None
sample_cache[key] = (vals, time.time())
return vals
def _cache_set(key: Tuple[str, str], vals: List[str]) -> None:
sample_cache[key] = (vals, time.time())
if len(sample_cache) > CACHE_LIMIT:
oldest = min(sample_cache.items(), key=lambda kv: kv[1][1])[0]
del sample_cache[oldest]
@app.route("/api/samples")
def sample_values() -> Any: # pyright: ignore[reportUnusedFunction]
column = request.args.get("column")
substr = request.args.get("q", "")
if not column or column not in column_types:
return jsonify([])
ctype = column_types[column].upper()
if "CHAR" not in ctype and "STRING" not in ctype and "VARCHAR" not in ct
ype:
return jsonify([])
key = (column, substr)
cached = _cache_get(key)
if cached is not None:
return jsonify(cached)
rows = con.execute(
f"SELECT DISTINCT {column} FROM events WHERE CAST({column} AS VARCHA
R) ILIKE '%' || ? || '%' LIMIT 20",
[substr],
).fetchall()
values = [r[0] for r in rows]
_cache_set(key, values)
return jsonify(values)
@app.route("/api/query", methods=["POST"])
def query() -> Any: # pyright: ignore[reportUnusedFunction]
payload = request.get_json(force=True)
try:
start = parse_time(payload.get("start"))
end = parse_time(payload.get("end"))
except Exception as exc:
return jsonify({"error": str(exc)}), 400
params = QueryParams(
start=start,
end=end,
order_by=payload.get("order_by"),
order_dir=payload.get("order_dir", "ASC"),
limit=payload.get("limit"),
columns=payload.get("columns", []),
derived_columns=payload.get("derived_columns", {}),
graph_type=payload.get("graph_type", "samples"),
group_by=payload.get("group_by", []),
aggregate=payload.get("aggregate"),
show_hits=payload.get("show_hits", False),
)
for f in payload.get("filters", []):
params.filters.append(Filter(f["column"], f["op"], f.get("value")))
if params.graph_type != "table" and (
params.group_by or params.aggregate or params.show_hits
):
return (
jsonify(
{
"error": "group_by, aggregate and show_hits are only val
id for table view"
}
),
400,
)
valid_cols = set(column_types.keys())
for col in params.columns:
if col not in valid_cols:
return jsonify({"error": f"Unknown column: {col}"}), 400
for col in params.group_by:
if col not in valid_cols:
return jsonify({"error": f"Unknown column: {col}"}), 400
if params.order_by and params.order_by not in valid_cols:
return jsonify({"error": f"Unknown column: {params.order_by}"}), 400
if params.group_by:
agg = (params.aggregate or "avg").lower()
if agg.startswith("p") or agg in {"avg", "sum"}:
need_numeric = True
allow_time = False
elif agg in {"min", "max"}:
need_numeric = False
allow_time = True
else:
need_numeric = False
allow_time = False
if need_numeric or allow_time:
for c in params.columns:
if c in params.group_by:
continue
ctype = column_types.get(c, "").upper()
is_numeric = any(
t in ctype
for t in [
"INT",
"DECIMAL",
"REAL",
"DOUBLE",
"FLOAT",
"NUMERIC",
"HUGEINT",
]
)
is_time = "TIMESTAMP" in ctype or "DATE" in ctype or "TIME"
in ctype
if need_numeric and not is_numeric:
return (
jsonify(
{
"error": f"Aggregate {agg} cannot be applied
to column {c}",
}
),
400,
I need to locate the remaining lines to complete my task.
root@82cf664bb242:/workspace/scubaduck# sed -n '320,640p' scubaduck/server.py
400,
)
if allow_time and not (is_numeric or is_time):
return (
jsonify(
{
"error": f"Aggregate {agg} cannot be applied
to column {c}",
}
),
400,
)
sql = build_query(params, column_types)
try:
rows = con.execute(sql).fetchall()
except Exception as exc:
tb = traceback.format_exc()
print(f"Query failed:\n{sql}\n{tb}")
return (
jsonify({"sql": sql, "error": str(exc), "traceback": tb}),
400,
)
return jsonify({"sql": sql, "rows": rows})
return app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)
Looks like build_query
averages timestamps by converting to epoch, then casts it back. Now, let's check tests for coverage or style.
root@82cf664bb242:/workspace/scubaduck# sed -n '1,200p' tests/test_server.py
from __future__ import annotations
import json
from pathlib import Path
import duckdb
from scubaduck import server
import pytest
def test_basic_query() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-02 00:00:00",
"order_by": "timestamp",
"order_dir": "ASC",
"limit": 10,
"columns": ["timestamp", "event", "value", "user"],
"filters": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert data
rows = data["rows"]
# We expect first three rows (until 2024-01-02 00:00:00)
assert len(rows) == 3
assert rows[0][1] == "login"
assert rows[1][1] == "logout"
def test_filter_multi_token() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-02 03:00:00",
"order_by": "timestamp",
"limit": 10,
"columns": ["timestamp", "event", "value", "user"],
"filters": [{"column": "user", "op": "=", "value": ["alice", "charlie"]}
],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert data
rows = data["rows"]
# Should only return rows for alice and charlie
assert len(rows) == 3
assert rows[0][3] == "alice"
assert rows[-1][3] == "charlie"
def test_empty_filter_is_noop() -> None:
app = server.app
client = app.test_client()
base_payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"order_by": "timestamp",
"limit": 100,
"columns": ["timestamp", "event", "value", "user"],
}
no_filter = {**base_payload, "filters": []}
empty_filter = {
**base_payload,
"filters": [{"column": "user", "op": "=", "value": None}],
}
rv1 = client.post(
"/api/query", data=json.dumps(no_filter), content_type="application/json
"
)
rv2 = client.post(
"/api/query", data=json.dumps(empty_filter), content_type="application/j
son"
)
rows1 = rv1.get_json()["rows"]
rows2 = rv2.get_json()["rows"]
assert rows1 == rows2
def test_select_columns() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"order_by": "timestamp",
"limit": 10,
"columns": ["timestamp", "user"],
"filters": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert data
rows = data["rows"]
assert len(rows[0]) == 2
assert rows[0][1] == "alice"
def test_string_filter_ops() -> None:
app = server.app
client = app.test_client()
base = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"order_by": "timestamp",
"limit": 100,
"columns": ["timestamp", "event", "value", "user"],
}
contains = {
**base,
"filters": [{"column": "user", "op": "contains", "value": "ali"}],
}
rv = client.post(
"/api/query", data=json.dumps(contains), content_type="application/json"
)
rows = rv.get_json()["rows"]
assert all("ali" in r[3] for r in rows)
regex = {
**base,
"filters": [{"column": "user", "op": "~", "value": "^a.*"}],
}
rv = client.post(
"/api/query", data=json.dumps(regex), content_type="application/json"
)
rows = rv.get_json()["rows"]
assert all(r[3].startswith("a") for r in rows)
assert len(rows) == 2
not_empty = {**base, "filters": [{"column": "user", "op": "!empty"}]}
rv = client.post(
"/api/query", data=json.dumps(not_empty), content_type="application/json
"
)
assert len(rv.get_json()["rows"]) == 4
def _make_payload() -> dict[str, object]:
return {
"start": "2024-01-01 00:00:00",
"end": "2024-01-02 00:00:00",
"order_by": "timestamp",
"order_dir": "ASC",
"limit": 10,
"columns": ["timestamp", "event", "value", "user"],
"filters": [],
}
def test_database_types(tmp_path: Path) -> None:
csv_file = tmp_path / "events.csv"
csv_file.write_text(Path("scubaduck/sample.csv").read_text())
sqlite_file = tmp_path / "events.sqlite"
import sqlite3
conn = sqlite3.connect(sqlite_file)
conn.execute(
"CREATE TABLE events (timestamp TEXT, event TEXT, value INTEGER, user TE
XT)"
)
with open(csv_file) as f:
next(f)
for line in f:
ts, ev, val, user = line.strip().split(",")
conn.execute(
"INSERT INTO events VALUES (?, ?, ?, ?)", (ts, ev, int(val), use
r)
)
conn.commit()
conn.close() # pyright: ignore[reportUnknownMemberType, reportAttributeAcce
ssIssue]
duckdb_file = tmp_path / "events.duckdb"
con = duckdb.connect(duckdb_file)
con.execute(
f"CREATE TABLE events AS SELECT * FROM read_csv_auto('{csv_file.as_posix
()}')"
)
con.close() # pyright: ignore[reportUnknownMemberType, reportAttributeAcces
sIssue]
for db in (csv_file, sqlite_file, duckdb_file):
app = server.create_app(db)
client = app.test_client()
payload = _make_payload()
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/js
on"
)
rows = rv.get_json()["rows"]
assert len(rows) == 3
def test_group_by_table() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
root@82cf664bb242:/workspace/scubaduck# sed -n '200,400p' tests/test_server.py
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "user",
"limit": 10,
"columns": ["value"],
"group_by": ["user"],
"aggregate": "Sum",
"show_hits": True,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
rows = rv.get_json()["rows"]
assert rows[0][0] == "alice"
assert rows[0][1] == 2
assert rows[0][2] == 40
def test_relative_time_query(monkeypatch: pytest.MonkeyPatch) -> None:
app = server.app
client = app.test_client()
from datetime import datetime
fixed_now = datetime(2024, 1, 2, 4, 0, 0)
class FixedDateTime(datetime):
@classmethod
def now(cls, tz=None): # type: ignore[override]
return fixed_now if tz is None else fixed_now.astimezone(tz)
monkeypatch.setattr(server, "datetime", FixedDateTime)
payload = {
"start": "-1 hour",
"end": "now",
"order_by": "timestamp",
"limit": 100,
"columns": ["timestamp", "event", "value", "user"],
"filters": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 1
assert data["rows"][0][3] == "charlie"
def test_invalid_time_error() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "nonsense",
"end": "now",
"order_by": "timestamp",
"limit": 10,
"columns": ["timestamp"],
"filters": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "error" in data
def test_query_error_returns_sql_and_traceback() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"columns": ["event"],
"group_by": ["user"],
"aggregate": "avg",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "error" in data
def test_table_unknown_column_error() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "timestamp",
"limit": 100,
"columns": ["user", "Hits", "value"],
"group_by": ["user"],
"aggregate": "Count",
"show_hits": True,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "Unknown column" in data["error"]
def test_samples_view_rejects_group_by() -> None:
app = server.app
client = app.test_client()
payload = {
"graph_type": "samples",
"group_by": ["user"],
"columns": ["timestamp"],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "only valid" in data["error"]
I need to add a test to fix the new bug in the server.
root@82cf664bb242:/workspace/scubaduck# sed -n '1,160p' tests/test_web.py
from __future__ import annotations
from typing import Any
def select_value(page: Any, selector: str, value: str) -> None:
page.evaluate(
"arg => setSelectValue(arg.sel, arg.val)",
{"sel": selector, "val": value},
)
def run_query(
page: Any,
url: str,
*,
start: str | None = None,
end: str | None = None,
order_by: str | None = None,
order_dir: str | None = "ASC",
limit: int | None = None,
group_by: list[str] | None = None,
aggregate: str | None = None,
) -> dict[str, Any]:
page.goto(url)
page.wait_for_selector("#order_by option", state="attached")
page.wait_for_selector("#order_dir", state="attached")
page.wait_for_function("window.lastResults !== undefined")
if start is not None:
page.fill("#start", start)
if end is not None:
page.fill("#end", end)
if order_by is not None:
select_value(page, "#order_by", order_by)
if order_dir is not None and order_dir == "DESC":
page.click("#order_dir")
if limit is not None:
page.fill("#limit", str(limit))
if group_by is not None:
select_value(page, "#graph_type", "table")
page.evaluate(
"g => { groupBy.chips = g; groupBy.renderChips(); }",
group_by,
)
if aggregate is not None:
select_value(page, "#graph_type", "table")
select_value(page, "#aggregate", aggregate)
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
return page.evaluate("window.lastResults")
def test_range_filters(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-02 00:00:00",
end="2024-01-02 04:00:00",
order_by="timestamp",
limit=100,
)
assert len(data["rows"]) == 2
from dateutil import parser
timestamps = [parser.parse(row[0]).replace(tzinfo=None) for row in data["row
s"]]
assert timestamps == [
parser.parse("2024-01-02 00:00:00"),
parser.parse("2024-01-02 03:00:00"),
]
def test_order_by(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="value",
order_dir="DESC",
limit=100,
)
values = [row[2] for row in data["rows"]]
assert values == sorted(values, reverse=True)
def test_limit(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
limit=2,
)
assert len(data["rows"]) == 2
def test_simple_filter(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
filter_el = page.query_selector("#filters .filter:last-child")
assert filter_el
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": filter_el, "val": "user"},
)
val_input = filter_el.query_selector(".f-val")
val_input.click()
page.keyboard.type("alice")
page.keyboard.press("Enter")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
data = page.evaluate("window.lastResults")
assert len(data["rows"]) == 2
assert all(row[3] == "alice" for row in data["rows"])
def test_default_filter_and_layout(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
count = page.evaluate("document.querySelectorAll('#filters .filter').length"
)
assert count == 1
last_is_button = page.evaluate(
"document.querySelector('#filters').lastElementChild.id === 'add_filter'
"
)
assert last_is_button
position = page.evaluate(
"getComputedStyle(document.querySelector('#filters .filter button.remove
')).position"
)
assert position == "static"
def test_header_and_tabs(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
header = page.text_content("#header")
assert "sample.csv" in header
assert "events" in header
assert page.is_visible("#settings")
assert page.is_hidden("#columns")
page.click("text=Columns")
assert page.is_visible("#columns")
cols = [c.strip() for c in page.locator("#column_groups li").all_inner_texts
()]
assert "timestamp" in cols
assert "event" in cols
page.click("text=View Settings")
assert page.is_visible("#settings")
btn_color = page.evaluate(
"getComputedStyle(document.querySelector('#dive')).backgroundColor"
)
assert "rgb(0, 128, 0)" == btn_color
sidebar_overflow = page.evaluate(
"getComputedStyle(document.querySelector('#sidebar')).overflowY"
)
)
assert sidebar_overflow == "auto"
assert view_overflow == "auto"
def test_graph_type_table_fields(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#graph_type", state="attached")
select_value(page, "#graph_type", "table")
assert page.is_visible("#group_by_field")
assert page.is_visible("#aggregate_field")
assert page.is_visible("#show_hits_field")
page.click("text=Columns")
assert not page.is_visible("text=Strings:")
def test_help_and_alignment(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
titles = page.evaluate(
"Array.from(document.querySelectorAll('#settings .help')).map(e => e.tit
le)"
)
assert any("start/end of the time range" in t for t in titles)
text_align = page.evaluate(
"getComputedStyle(document.querySelector('#settings label')).textAlign"
)
assert text_align == "right"
def test_table_sorting(page: Any, server_url: str) -> None:
run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
order_dir="ASC",
limit=100,
)
# header alignment
align = page.evaluate(
"getComputedStyle(document.querySelector('#results th')).textAlign"
)
assert align == "right"
header = page.locator("#results th").nth(3)
def values() -> list[str]:
return page.locator("#results td:nth-child(4)").all_inner_texts()
orig_rows = values()
assert orig_rows == ["alice", "bob", "alice", "charlie"]
first_sql = page.evaluate("window.lastResults.sql")
header.click()
assert values() == sorted(orig_rows)
assert header.inner_text().endswith("▲")
color = page.evaluate(
"getComputedStyle(document.querySelector('#results th:nth-child(4)')).co
lor"
)
assert "0, 0, 255" in color
assert page.evaluate("window.lastResults.sql") == first_sql
header.click()
assert values() == sorted(orig_rows, reverse=True)
assert header.inner_text().endswith("▼")
header.click()
assert values() == orig_rows
assert header.inner_text() == "user"
color = page.evaluate(
"getComputedStyle(document.querySelector('#results th:nth-child(4)')).co
lor"
)
assert "0, 0, 255" not in color
def test_relative_dropdown(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
btn = page.query_selector('[data-target="start-select"]')
assert btn
btn.click()
page.click("#start-select div:text('-3 hours')")
assert page.input_value("#start") == "-3 hours"
def test_end_dropdown_now(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click('[data-target="end-select"]')
page.click("#end-select div:text('now')")
assert page.input_value("#end") == "now"
def test_invalid_time_error_shown(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="nonsense",
end="now",
order_by="timestamp",
)
assert "error" in data
msg = page.text_content("#view")
assert "nonsense" in msg
def test_query_error_shown(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
group_by=["user"],
aggregate="Avg",
)
assert "error" in data
msg = page.text_content("#view")
assert "Aggregate avg" in msg
def test_column_toggle_and_selection(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
page.wait_for_selector("#column_groups input", state="attached")
count = page.evaluate(
"document.querySelectorAll('#column_groups input:checked').length"
)
assert count == 4
page.click("#columns_none")
count = page.evaluate(
"document.querySelectorAll('#column_groups input:checked').length"
)
assert count == 0
page.click("#columns_all")
count = page.evaluate(
"document.querySelectorAll('#column_groups input:checked').length"
)
assert count == 4
page.uncheck("#column_groups input[value='value']")
page.click("text=View Settings")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 00:00:00")
select_value(page, "#order_by", "timestamp")
page.fill("#limit", "10")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
data = page.evaluate("window.lastResults")
assert len(data["rows"][0]) == 3
headers = page.locator("#results th").all_inner_texts()
def test_column_group_links(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
page.wait_for_selector("#column_groups a", state="attached")
tag = page.evaluate("document.querySelector('#column_groups .col-group a').t
agName")
assert tag == "A"
def test_column_group_links_float_right(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
page.wait_for_selector("#column_groups .col-group .links", state="attached")
float_val = page.evaluate(
"getComputedStyle(document.querySelector('#column_groups .col-group .lin
ks')).float"
)
assert float_val == "right"
def test_chip_dropdown_navigation(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
f = page.query_selector("#filters .filter:last-child")
assert f
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": f, "val": "user"},
)
inp = f.query_selector(".f-val")
inp.click()
page.wait_for_selector("#filters .filter:last-child .chip-dropdown div")
page.keyboard.type("ali")
page.wait_for_selector("text=alice")
page.keyboard.press("ArrowDown")
page.keyboard.press("Enter")
chips = page.evaluate(
"Array.from(document.querySelectorAll('#filters .filter:last-child .chip
')).map(c => c.firstChild.textContent)"
)
assert chips == ["ali"]
page.click("#filters .filter:last-child .chip .x")
page.wait_for_selector(".chip", state="detached")
def test_chip_copy_and_paste(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.evaluate(
"Object.defineProperty(navigator, 'clipboard', {value:{ _data: '', write
Text(t){ this._data = t; }, readText(){ return Promise.resolve(this._data); } }}
)"
)
page.click("text=Add Filter")
f = page.query_selector("#filters .filter:last-child")
assert f
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": f, "val": "user"},
)
inp = f.query_selector(".f-val")
inp.click()
page.keyboard.type("alice")
page.keyboard.press("Enter")
page.keyboard.type("bob")
page.keyboard.press("Enter")
f.query_selector(".chip-copy").click()
assert page.evaluate("navigator.clipboard._data") == "alice,bob"
page.evaluate(
"var f=document.querySelector('#filters .filter:last-child'); f.chips=[]
; f.querySelectorAll('.chip').forEach(c=>c.remove())"
)
page.wait_for_selector("#filters .chip", state="detached")
inp.click()
page.evaluate(
"var dt=new DataTransfer(); dt.setData('text/plain','alice,bob'); var e=
new ClipboardEvent('paste',{clipboardData:dt}); document.querySelector('#filters
.filter:last-child .f-val').dispatchEvent(e);"
)
chips = page.evaluate(
"Array.from(document.querySelectorAll('#filters .filter:last-child .chip
')).map(c => c.firstChild.textContent)"
)
assert chips[:2] == ["alice", "bob"]
page.evaluate(
"var f=document.querySelector('#filters .filter:last-child'); f.chips=[]
; f.querySelectorAll('.chip').forEach(c=>c.remove())"
)
page.wait_for_selector("#filters .chip", state="detached")
inp.click()
page.evaluate(
"var dt=new DataTransfer(); dt.setData('text/plain','alice,bob'); var e=
new ClipboardEvent('paste',{clipboardData:dt}); Object.defineProperty(e,'shiftKe
y',{value:true}); document.querySelector('#filters .filter:last-child .f-val').d
ispatchEvent(e);"
)
chips = page.evaluate(
"Array.from(document.querySelectorAll('#filters .filter:last-child .chip
')).map(c => c.firstChild.textContent)"
)
assert chips[-1] == "alice,bob"
def test_chip_dropdown_hides_on_outside_click(page: Any, server_url: str) -> Non
e:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Add Filter")
f = page.query_selector("#filters .filter:last-child")
assert f
page.evaluate(
"arg => setSelectValue(arg.el.querySelector('.f-col'), arg.val)",
{"el": f, "val": "user"},
)
inp = f.query_selector(".f-val")
inp.click()
page.wait_for_selector("#filters .filter:last-child .chip-dropdown div")
page.click("#header")
page.wait_for_selector("#filters .filter:last-child .chip-dropdown", state="
hidden")
...
page.wait_for_selector("#order_by option", state="attached")
page.wait_for_function("window.lastResults !== undefined")
page.fill("#start", "2024-01-01 00:00:00")
page.fill("#end", "2024-01-02 00:00:00")
page.fill("#limit", "1")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
first_url = page.url
first_rows = page.evaluate("window.lastResults.rows.length")
page.fill("#limit", "2")
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
second_url = page.url
second_rows = page.evaluate("window.lastResults.rows.length")
assert second_rows != first_rows
assert first_url != second_url
page.go_back()
page.wait_for_function("window.lastResults !== undefined")
assert page.url == first_url
assert page.evaluate("window.lastResults.rows.length") == first_rows
def test_load_from_url(page: Any, server_url: str) -> None:
url = (
f"{server_url}?start=2024-01-01%2000:00:00&end=2024-01-02%2000:00:00"
"&order_by=timestamp&limit=2"
)
page.goto(url)
page.wait_for_selector("#order_by option", state="attached")
page.wait_for_function("window.lastResults !== undefined")
assert page.input_value("#start") == "2024-01-01 00:00:00"
assert page.input_value("#end") == "2024-01-02 00:00:00"
assert page.input_value("#limit") == "2"
assert page.evaluate("window.lastResults.rows.length") == 2
def test_empty_data_message(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2025-01-01 00:00:00",
end="2025-01-02 00:00:00",
order_by="timestamp",
limit=100,
)
assert data["rows"] == []
msg = page.text_content("#view")
assert "Empty data provided to table" in msg
def test_group_by_chip_from_url(page: Any, server_url: str) -> None:
url = f"{server_url}?graph_type=table&group_by=user&order_by=user&limit=10"
page.goto(url)
page.wait_for_selector("#group_by_field .chip", state="attached")
chips = page.evaluate(
"Array.from(document.querySelectorAll('#group_by_field .chip')).map(c =>
c.firstChild.textContent)"
)
assert chips == ["user"]
def test_group_by_autocomplete(page: Any, server_url: str) -> None:
page.goto(f"{server_url}?graph_type=table")
page.wait_for_selector("#group_by_field", state="visible")
inp = page.query_selector("#group_by_field .f-val")
assert inp
inp.click()
page.keyboard.type("us")
page.wait_for_selector("#group_by_field .chip-dropdown div")
options = page.locator("#group_by_field .chip-dropdown div").all_inner_texts
()
assert "user" in options
def test_group_by_copy_icon(page: Any, server_url: str) -> None:
page.goto(f"{server_url}?graph_type=table")
page.wait_for_selector("#group_by_field", state="visible")
icon = page.text_content("#group_by_field .chip-copy")
assert icon == "⎘"
def test_group_by_input_no_border(page: Any, server_url: str) -> None:
page.goto(f"{server_url}?graph_type=table")
page.wait_for_selector("#group_by_field", state="visible")
border = page.evaluate(
"getComputedStyle(document.querySelector('#group_by_field .f-val')).bord
erStyle"
)
assert border == "none"
def test_table_group_by_query(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
limit=100,
group_by=["user"],
aggregate="Count",
)
assert "error" not in data
assert len(data["rows"]) == 3
def test_table_avg_no_group_by(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
aggregate="Avg",
)
assert len(data["rows"]) == 1
row = data["rows"][0]
assert row[0] == 4
from dateutil import parser
ts = parser.parse(row[1]).replace(tzinfo=None)
assert ts == parser.parse("2024-01-01 13:00:00")
assert row[2] == 25
root@82cf664bb242:/workspace/scubaduck# sed -n '640,1280p' tests/test_web.py
I think the file might have ended sooner than expected. I'll check the tail.
root@82cf664bb242:/workspace/scubaduck# wc -l tests/test_web.py
636 tests/test_web.py
The bug occurs because average aggregation doesn't allow timestamps; converting to epoch should fix it.
To handle timestamp aggregation, I'll modify validation so 'avg' allows time columns.
For 'avg', I'll allow time columns, but for 'sum' and quantiles, only numeric is allowed.
I'll ensure min/max allows numeric or time, but exclude string types for now.
root@82cf664bb242:/workspace/scubaduck# grep -n "Avg" -n tests/test_server.py
I'll search the codebase for references to "avg". Let's see what turns up.
root@82cf664bb242:/workspace/scubaduck# grep -n "avg" -n tests/test_server.py
278: "aggregate": "avg",
I’ll check around line 270 to see what’s going on in the code.
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "error" in data
def test_query_error_returns_sql_and_traceback() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"columns": ["event"],
"group_by": ["user"],
"aggregate": "avg",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "error" in data
def test_table_unknown_column_error() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "timestamp",
"limit": 100,
"columns": ["user", "Hits", "value"],
"group_by": ["user"],
"aggregate": "Count",
"show_hits": True,
I'll create a server test with group_by ["user"]
, graph_type "table"
, and default avg
aggregate.
I'll set graph_type "table"
, and include only timestamp
in columns for replication.
root@82cf664bb242:/workspace/scubaduck# head scubaduck/sample.csv
timestamp,event,value,user
2024-01-01 00:00:00,login,10,alice
2024-01-01 01:00:00,logout,20,bob
2024-01-02 00:00:00,login,30,alice
2024-01-02 03:00:00,login,40,charlie
I'm confirming how the aggregation works by grouping by user and computing the averages.
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
rows = rv.get_json()["rows"]
assert rows[0][0] == "alice"
assert rows[0][1] == 2
assert rows[0][2] == 40
def test_relative_time_query(monkeypatch: pytest.MonkeyPatch) -> None:
app = server.app
client = app.test_client()
from datetime import datetime
fixed_now = datetime(2024, 1, 2, 4, 0, 0)
class FixedDateTime(datetime):
@classmethod
def now(cls, tz=None): # type: ignore[override]
return fixed_now if tz is None else fixed_now.astimezone(tz)
monkeypatch.setattr(server, "datetime", FixedDateTime)
payload = {
"start": "-1 hour",
"end": "now",
"order_by": "timestamp",
"limit": 100,
"columns": ["timestamp", "event", "value", "user"],
"filters": [],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 1
assert data["rows"][0][3] == "charlie"
def test_invalid_time_error() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "nonsense",
"end": "now",
"order_by": "timestamp",
"limit": 10,
"columns": ["timestamp"],
"filters": [],
I'll check if there's a test for group_by with the default avg in test_web.py.
root@82cf664bb242:/workspace/scubaduck# grep -n "avg" tests/test_web.py
284: assert "Aggregate avg" in msg
623:def test_table_avg_no_group_by(page: Any, server_url: str) -> None:
I'll check lines 272-292 to verify the error message for the group_by and Avg query.
msg = page.text_content("#view")
assert "nonsense" in msg
def test_query_error_shown(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
group_by=["user"],
aggregate="Avg",
)
assert "error" in data
msg = page.text_content("#view")
assert "Aggregate avg" in msg
def test_column_toggle_and_selection(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
Seems like query defaults to columns, but I’ll double-check the run_query function to confirm.
def select_value(page: Any, selector: str, value: str) -> None:
page.evaluate(
"arg => setSelectValue(arg.sel, arg.val)",
{"sel": selector, "val": value},
)
def run_query(
page: Any,
url: str,
*,
start: str | None = None,
end: str | None = None,
order_by: str | None = None,
order_dir: str | None = "ASC",
limit: int | None = None,
group_by: list[str] | None = None,
aggregate: str | None = None,
) -> dict[str, Any]:
page.goto(url)
page.wait_for_selector("#order_by option", state="attached")
page.wait_for_selector("#order_dir", state="attached")
page.wait_for_function("window.lastResults !== undefined")
if start is not None:
page.fill("#start", start)
if end is not None:
page.fill("#end", end)
if order_by is not None:
select_value(page, "#order_by", order_by)
if order_dir is not None and order_dir == "DESC":
page.click("#order_dir")
if limit is not None:
page.fill("#limit", str(limit))
if group_by is not None:
select_value(page, "#graph_type", "table")
page.evaluate(
"g => { groupBy.chips = g; groupBy.renderChips(); }",
group_by,
)
if aggregate is not None:
select_value(page, "#graph_type", "table")
select_value(page, "#aggregate", aggregate)
page.evaluate("window.lastResults = undefined")
page.click("text=Dive")
page.wait_for_function("window.lastResults !== undefined")
return page.evaluate("window.lastResults")
def test_range_filters(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-02 00:00:00",
end="2024-01-02 04:00:00",
order_by="timestamp",
I'll check the static main JS for "columns" to confirm the defaults.
root@82cf664bb242:/workspace/scubaduck# ls scubaduck/static
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ScubaDuck</title>
<style>
body { margin: 0; font-family: sans-serif; height: 100vh; display: flex; fle
x-direction: column; }
#header { padding: 10px; font-weight: bold; border-bottom: 1px solid #ccc; }
#content { flex: 1; display: flex; height: calc(100vh - 42px); overflow: hid
den; }
#sidebar { width: 450px; padding: 10px; border-right: 3px solid #ccc; overfl
ow-y: auto; display: flex; flex-direction: column; box-sizing: border-box; }
#sidebar-resizer { width: 5px; cursor: col-resize; background: #ccc; }
#view { flex: 1; padding: 10px; overflow-y: auto; overflow-x: auto; }
.field { display: flex; align-items: center; margin-bottom: 10px; }
.field label { width: 80px; text-align: right; margin-right: 5px; }
.help { margin-left: 4px; cursor: help; }
.rel-btn { margin-left: 4px; }
#tabs { display: flex; align-items: center; margin-bottom: 10px; }
#tabs .tab { margin-right: 5px; background: none; border: 1px solid #ccc; pa
dding: 4px 8px; cursor: pointer; width: 120px; text-align: center; box-sizing: b
order-box; }
#tabs .tab.active { background: #eee; font-weight: bold; }
#dive { margin-left: auto; background: green; color: white; border: none; pa
dding: 5px 10px; cursor: pointer; }
.tab-content { display: none; }
.tab-content.active { display: block; }
#filter_list { display: flex; flex-direction: column; }
#filters .filter {
border: 1px solid #ccc;
padding: 5px;
margin-bottom: 5px;
position: relative;
display: flex;
flex-direction: column;
}
#filters .filter-row { display: flex; margin-bottom: 5px; }
#filters .filter-row .f-col { flex: 1; }
#filters .filter-row .f-op {
margin-left: 5px;
width: fit-content;
flex: 0 0 auto;
}
.chip-input input {
border: none;
I’m searching for the default columns within the project.
root@82cf664bb242:/workspace/scubaduck# grep -n "group_by" -n scubaduck/static/i
ndex.html
169: <div id="group_by_field" class="field" style="display:none;">
173: <input id="group_by" class="f-val" type="text">
352: document.getElementById('group_by_field').style.display = show ? 'flex' :
'none';
449: groupBy = document.getElementById('group_by').closest('.field');
792: payload.group_by = groupBy.chips || [];
810: if (params.group_by && params.group_by.length) sp.set('group_by', params
.group_by.join(','));
831: if (params.group_by) {
832: groupBy.chips.splice(0, groupBy.chips.length, ...params.group_by);
875: if (sp.has('group_by')) params.group_by = sp.get('group_by').split(',').fi
lter(c => c);
I'm looking for the default setting related to 'columns'.
root@82cf664bb242:/workspace/scubaduck# grep -n "columns" -n scubaduck/static/in
dex.html | head
119: <button class="tab" data-tab="columns">Columns</button>
211: <div id="columns" class="tab-content">
213: <a id="columns_all" href="#">All</a>
214: <a id="columns_none" href="#">None</a>
368:fetch('/api/columns').then(r => r.json()).then(cols => {
438: document.getElementById('columns_all').addEventListener('click', e => {
443: document.getElementById('columns_none').addEventListener('click', e => {
779: columns: selectedColumns.filter(c => c !== 'Hits'),
806: if (params.columns && params.columns.length) sp.set('columns', params.colu
mns.join(','));
838: cb.checked = !params.columns || params.columns.includes(cb.value);
I'll check around line 368 for any relevant data or entries.
root@82cf664bb242:/workspace/scubaduck# sed -n '360,420p' scubaduck/static/index
.html
displayType = graphTypeSel.value;
}
orderDirBtn.addEventListener('click', () => {
orderDir = orderDir === 'ASC' ? 'DESC' : 'ASC';
updateOrderDirButton();
});
updateOrderDirButton();
graphTypeSel.addEventListener('change', updateDisplayTypeUI);
fetch('/api/columns').then(r => r.json()).then(cols => {
const orderSelect = document.getElementById('order_by');
const groupsEl = document.getElementById('column_groups');
const groups = {
time: {name: 'Time', cols: [], ul: null},
integer: {name: 'Integers', cols: [], ul: null},
string: {name: 'Strings', cols: [], ul: null}
};
cols.forEach(c => {
const t = c.type.toUpperCase();
columnTypes[c.name] = c.type;
allColumns.push(c.name);
let g = 'string';
if (t.includes('INT')) g = 'integer';
if (t.includes('TIMESTAMP')) g = 'time';
groups[g].cols.push(c.name);
if (g !== 'string') {
const o = document.createElement('option');
o.value = c.name;
o.textContent = c.name;
orderSelect.appendChild(o);
}
});
Object.keys(groups).forEach(key => {
const g = groups[key];
const div = document.createElement('div');
div.className = 'col-group';
const header = document.createElement('div');
header.className = 'col-group-header';
header.appendChild(document.createTextNode(g.name + ': '));
const links = document.createElement('span');
links.className = 'links';
const allBtn = document.createElement('a');
allBtn.href = '#';
allBtn.textContent = 'All';
const noneBtn = document.createElement('a');
noneBtn.href = '#';
noneBtn.textContent = 'None';
links.appendChild(allBtn);
links.appendChild(noneBtn);
header.appendChild(links);
div.appendChild(header);
const ul = document.createElement('ul');
g.ul = ul;
g.cols.forEach(name => {
const li = document.createElement('li');
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = name;
cb.checked = true;
cb.addEventListener('change', updateSelectedColumns);
label.appendChild(cb);
I should keep going to check the remaining default columns.
root@82cf664bb242:/workspace/scubaduck# sed -n '420,500p' scubaduck/static/index
.html
label.appendChild(cb);
label.appendChild(document.createTextNode(' ' + name));
li.appendChild(label);
ul.appendChild(li);
});
allBtn.addEventListener('click', e => {
e.preventDefault();
ul.querySelectorAll('input').forEach(cb => (cb.checked = true));
updateSelectedColumns();
});
noneBtn.addEventListener('click', e => {
e.preventDefault();
ul.querySelectorAll('input').forEach(cb => (cb.checked = false));
updateSelectedColumns();
});
div.appendChild(ul);
groupsEl.appendChild(div);
});
document.getElementById('columns_all').addEventListener('click', e => {
e.preventDefault();
groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = true));
updateSelectedColumns();
});
document.getElementById('columns_none').addEventListener('click', e => {
e.preventDefault();
groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = false));
updateSelectedColumns();
});
updateSelectedColumns();
groupBy = document.getElementById('group_by').closest('.field');
initChipInput(groupBy, typed =>
allColumns.filter(c => c.toLowerCase().includes(typed.toLowerCase()))
);
initDropdown(orderSelect);
initDropdown(document.getElementById('aggregate'));
updateDisplayTypeUI();
addFilter();
initFromUrl();
});
document.querySelectorAll('#tabs .tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#tabs .tab').forEach(t => t.classList.remove('act
ive'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('a
ctive'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active');
});
});
document.querySelectorAll('.rel-btn').forEach(btn => {
btn.addEventListener('click', () => {
const dd = document.getElementById(btn.dataset.target);
const show = dd.style.display === 'none' || dd.style.display === '';
document.querySelectorAll('.rel-dropdown').forEach(d => (d.style.display = '
none'));
dd.style.display = show ? 'block' : 'none';
});
});
document.querySelectorAll('.rel-dropdown div').forEach(opt => {
opt.addEventListener('click', () => {
const box = opt.closest('.rel-box');
const input = box.querySelector('input');
input.value = opt.dataset.value || opt.textContent;
opt.parentElement.style.display = 'none';
});
});
document.addEventListener('click', e => {
document.querySelectorAll('.rel-dropdown').forEach(dd => {
if (!dd.parentElement.contains(e.target)) dd.style.display = 'none';
});
});
function updateSelectedColumns() {
const base = allColumns.filter(name => {
const cb = document.querySelector(`#column_groups input[value="${name}"]`);
if (!cb || !cb.checked) return false;
if (graphTypeSel.value === 'table' && isStringColumn(name)) return false;
return true;
});
if (graphTypeSel.value === 'table') {
selectedColumns = groupBy.chips.slice();
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
I'll scroll through to check the default columns in the updateSelectedColumns function.
root@82cf664bb242:/workspace/scubaduck# sed -n '500,560p' scubaduck/static/index
.html
if (document.getElementById('show_hits').checked) selectedColumns.push('Hits
');
base.forEach(c => {
if (!selectedColumns.includes(c)) selectedColumns.push(c);
});
} else {
selectedColumns = base;
}
}
function isStringColumn(name) {
const t = (columnTypes[name] || '').toUpperCase();
return t.includes('CHAR') || t.includes('STRING') || t.includes('VARCHAR');
}
function isIntegerColumn(name) {
const t = (columnTypes[name] || '').toUpperCase();
return t.includes('INT');
}
function isTimeColumn(name) {
const t = (columnTypes[name] || '').toUpperCase();
return t.includes('TIMESTAMP');
}
function initChipInput(filter, fetchOptions) {
const input = filter.querySelector('.f-val');
const chipsEl = filter.querySelector('.chip-input');
const dropdown = filter.querySelector('.chip-dropdown');
const copyBtn = filter.querySelector('.chip-copy');
const chips = [];
filter.chips = chips;
filter.renderChips = renderChips;
filter.addChip = addChip;
let highlight = 0;
chipsEl.addEventListener('click', () => {
input.focus();
});
function renderChips() {
chipsEl.querySelectorAll('.chip').forEach(c => c.remove());
chips.forEach((v, i) => {
const span = document.createElement('span');
span.className = 'chip';
span.textContent = v;
const x = document.createElement('span');
x.className = 'x';
x.textContent = '✖';
x.addEventListener('click', () => {
chips.splice(i, 1);
renderChips();
});
span.appendChild(x);
chipsEl.insertBefore(span, input);
});
}
function hideDropdown() {
dropdown.style.display = 'none';
}
I’ll check for where the queries are being built.
root@82cf664bb242:/workspace/scubaduck# grep -n "query" -n scubaduck/static/inde
x.html | head -n 20
124: <label>Start<span class="help" title="Sets the start/end of the ti
me range to query. Can be any kind of datetime string. For example: 'April 23, 2
014' or 'yesterday'.">[?]</span></label>
142: <label>End<span class="help" title="Sets the start/end of the time
range to query. Can be any kind of datetime string. For example: 'April 23, 201
4' or 'yesterday'.">[?]</span></label>
209: <div id="query_info" style="margin-top:10px;"></div>
355: document.querySelectorAll('#column_groups .col-group').forEach(g => {
356: if (g.querySelector('.col-group-header').textContent.startsWith('Strings
')) {
427: ul.querySelectorAll('input').forEach(cb => (cb.checked = true));
432: ul.querySelectorAll('input').forEach(cb => (cb.checked = false));
440: groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = true));
445: groupsEl.querySelectorAll('input').forEach(cb => (cb.checked = false));
460:document.querySelectorAll('#tabs .tab').forEach(btn => {
462: document.querySelectorAll('#tabs .tab').forEach(t => t.classList.remove(
'active'));
463: document.querySelectorAll('.tab-content').forEach(c => c.classList.remov
e('active'));
469:document.querySelectorAll('.rel-btn').forEach(btn => {
473: document.querySelectorAll('.rel-dropdown').forEach(d => (d.style.display
= 'none'));
477:document.querySelectorAll('.rel-dropdown div').forEach(opt => {
480: const input = box.querySelector('input');
486: document.querySelectorAll('.rel-dropdown').forEach(dd => {
493: const cb = document.querySelector(`#column_groups input[value="${name}"]
`);
525: const input = filter.querySelector('.f-val');
526: const chipsEl = filter.querySelector('.chip-input');
I'll look for selectedColumns
and loadResults
around that section.
root@82cf664bb242:/workspace/scubaduck# grep -n "selectedColumns" -n scubaduck/s
tatic/index.html
230:let selectedColumns = [];
499: selectedColumns = groupBy.chips.slice();
500: if (document.getElementById('show_hits').checked) selectedColumns.push('
Hits');
502: if (!selectedColumns.includes(c)) selectedColumns.push(c);
505: selectedColumns = base;
779: columns: selectedColumns.filter(c => c !== 'Hits'),
901: let hitsIndex = selectedColumns.indexOf('Hits');
907: selectedColumns.forEach((col, i) => {
926: const col = selectedColumns[i];
I'll check the code around line 770 to understand how the payload is built.
root@82cf664bb242:/workspace/scubaduck# sed -n '750,820p' scubaduck/static/index
.html
history.pushState(payload, '', paramsToSearch(payload));
}
const view = document.getElementById('view');
view.innerHTML = '<p>Loading...</p>';
window.lastResults = undefined;
queryStart = performance.now();
fetch('/api/query', {method:'POST', headers:{'Content-Type':'application/json'
}, body:JSON.stringify(payload)})
.then(async r => {
const data = await r.json();
if (!r.ok) throw data;
return data;
})
.then(data => {
lastQueryTime = Math.round(performance.now() - queryStart);
showResults(data);
})
.catch(err => {
showError(err);
});
}
function collectParams() {
updateSelectedColumns();
const payload = {
start: document.getElementById('start').value,
end: document.getElementById('end').value,
order_by: document.getElementById('order_by').value,
order_dir: orderDir,
limit: parseInt(document.getElementById('limit').value, 10),
columns: selectedColumns.filter(c => c !== 'Hits'),
graph_type: graphTypeSel.value,
filters: Array.from(document.querySelectorAll('#filters .filter')).map(f =>
{
const chips = f.chips || [];
const op = f.querySelector('.f-op').value;
let value = null;
if (op !== 'empty' && op !== '!empty') {
value = chips.length === 0 ? null : (chips.length === 1 ? chips[0] : chi
ps);
}
return {column: f.querySelector('.f-col').value, op, value};
})
};
if (graphTypeSel.value === 'table') {
payload.group_by = groupBy.chips || [];
payload.aggregate = document.getElementById('aggregate').value;
payload.show_hits = document.getElementById('show_hits').checked;
}
return payload;
}
function paramsToSearch(params) {
const sp = new URLSearchParams();
if (params.start) sp.set('start', params.start);
if (params.end) sp.set('end', params.end);
if (params.order_by) sp.set('order_by', params.order_by);
if (params.order_dir) sp.set('order_dir', params.order_dir);
if (params.limit !== null && params.limit !== undefined) sp.set('limit', param
s.limit);
if (params.columns && params.columns.length) sp.set('columns', params.columns.
join(','));
if (params.filters && params.filters.length) sp.set('filters', JSON.stringify(
params.filters));
if (params.graph_type) sp.set('graph_type', params.graph_type);
if (params.graph_type === 'table') {
if (params.group_by && params.group_by.length) sp.set('group_by', params.gro
up_by.join(','));
if (params.aggregate) sp.set('aggregate', params.aggregate);
if (params.show_hits) sp.set('show_hits', '1');
}
const qs = sp.toString();
return qs ? '?' + qs : '';
}
function applyParams(params) {
document.getElementById('start').value = params.start || '';
document.getElementById('end').value = params.end || '';
I'm thinking that updateSelectedColumns sets selectedColumns, which include non-string columns for tables.
Columns include group_by without duplication. The server ensures no duplication before generating SQL.
I'll create a test for aggregating timestamps while ensuring no reliance on row order.
root@82cf664bb242:/workspace/scubaduck# cat pyproject.toml
[project]
name = "scubaduck"
version = "0.1.0"
description = "Scuba on DuckDB"
readme = "README.md"
requires-python = ">=3.12"
license = {text = "MIT"}
dependencies = [
"duckdb",
"flask",
"python-dateutil>=2.9.0.post0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
addopts = "-n auto --tb=native"
[tool.pyright]
# Pyright configuration with strict settings
include = ["scubaduck", "tests"]
exclude = ["**/__pycache__", "dist"]
stubPath = "stubs"
venvPath = "."
venv = ".venv"
reportMissingImports = true
reportMissingTypeStubs = true
pythonVersion = "3.12"
pythonPlatform = "All"
typeCheckingMode = "strict"
reportUnknownMemberType = true
reportUnknownParameterType = true
reportUnknownVariableType = true
reportUnknownArgumentType = true
reportPrivateImportUsage = true
reportUntypedFunctionDecorator = true
reportFunctionMemberAccess = true
reportIncompatibleMethodOverride = true
[tool.ruff]
target-version = "py312"
[tool.uv]
dev-dependencies = [
"pytest>=8.3.5",
"ruff>=0.11.2",
"pytest-xdist>=3.6.1",
"playwright>=1.52.0",
"pyright>=1.1.350",
"pytest-playwright>=0.7.0",
]
I'll check the tests/conftest.py file to understand the environment configuration.
root@82cf664bb242:/workspace/scubaduck# sed -n '1,200p' tests/conftest.py
from __future__ import annotations
import threading
from collections.abc import Iterator
import pytest
from werkzeug.serving import make_server
from scubaduck.server import app
@pytest.fixture()
def server_url() -> Iterator[str]:
httpd = make_server("127.0.0.1", 0, app)
port = httpd.server_port
thread = threading.Thread(target=httpd.serve_forever)
thread.start()
try:
yield f"http://127.0.0.1:{port}"
finally:
httpd.shutdown()
thread.join()
I'll start by reviewing the current implementation in server.py, then add a test for validation.
root@82cf664bb242:/workspace/scubaduck# git status --short
I'll open lines 301-353 to modify validation and search for 'allow_time.'
root@82cf664bb242:/workspace/scubaduck# grep -n "allow_time" -n scubaduck/server
.py
288: allow_time = False
291: allow_time = True
294: allow_time = False
295: if need_numeric or allow_time:
322: if allow_time and not (is_numeric or is_time):
I'll take a look at lines 280 through 325 to check for relevant information.
return jsonify({"error": f"Unknown column: {col}"}), 400
if params.order_by and params.order_by not in valid_cols:
return jsonify({"error": f"Unknown column: {params.order_by}"}), 400
if params.group_by:
agg = (params.aggregate or "avg").lower()
if agg.startswith("p") or agg in {"avg", "sum"}:
need_numeric = True
allow_time = False
elif agg in {"min", "max"}:
need_numeric = False
allow_time = True
else:
need_numeric = False
allow_time = False
if need_numeric or allow_time:
for c in params.columns:
if c in params.group_by:
continue
ctype = column_types.get(c, "").upper()
is_numeric = any(
t in ctype
for t in [
"INT",
"DECIMAL",
"REAL",
"DOUBLE",
"FLOAT",
"NUMERIC",
"HUGEINT",
]
)
is_time = "TIMESTAMP" in ctype or "DATE" in ctype or "TIME"
in ctype
if need_numeric and not is_numeric:
return (
jsonify(
{
"error": f"Aggregate {agg} cannot be applied
to column {c}",
}
),
400,
)
if allow_time and not (is_numeric or is_time):
return (
jsonify(
{
"error": f"Aggregate {agg} cannot be applied
to column {c}",
}
),
400,
)
I’m adjusting the logic: "avg" sets allow_time to True, ensuring numeric values are allowed.
root@82cf664bb242:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/server.py
> @@
> - if params.group_by:
> - agg = (params.aggregate or "avg").lower()
> - if agg.startswith("p") or agg in {"avg", "sum"}:
> - need_numeric = True
> - allow_time = False
> - elif agg in {"min", "max"}:
> - need_numeric = False
> - allow_time = True
> - else:
> - need_numeric = False
> - allow_time = False
> + if params.group_by:
> + agg = (params.aggregate or "avg").lower()
> + if agg.startswith("p") or agg == "sum":
> + need_numeric = True
> + allow_time = False
> + elif agg == "avg" or agg in {"min", "max"}:
> + need_numeric = False
> + allow_time = True
> + else:
> + need_numeric = False
> + allow_time = False
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/server.py
I'll add the new test to test_server.py
after test_table_group_by_query
.
assert rv.status_code == 400
assert "error" in data
def test_table_unknown_column_error() -> None:
app = server.app
client = app.test_client()
payload = {
"start": "2024-01-01 00:00:00",
"end": "2024-01-03 00:00:00",
"graph_type": "table",
"order_by": "timestamp",
"limit": 100,
"columns": ["user", "Hits", "value"],
"group_by": ["user"],
"aggregate": "Count",
"show_hits": True,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "Unknown column" in data["error"]
def test_samples_view_rejects_group_by() -> None:
app = server.app
client = app.test_client()
payload = {
"graph_type": "samples",
"group_by": ["user"],
"columns": ["timestamp"],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "only valid" in data["error"]
I should check lines 320-400—there might be more tests after line 620. Let's see.
root@82cf664bb242:/workspace/scubaduck# sed -n '400,480p' tests/test_server.py
Hmm, looks like the file actually has 329 lines, not 300. I'll double-check it.
root@82cf664bb242:/workspace/scubaduck# wc -l tests/test_server.py
323 tests/test_server.py
The file has 323 lines. I'll focus on expanding from 308 to 330.
"show_hits": True,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "Unknown column" in data["error"]
def test_samples_view_rejects_group_by() -> None:
app = server.app
client = app.test_client()
payload = {
"graph_type": "samples",
"group_by": ["user"],
"columns": ["timestamp"],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 400
assert "only valid" in data["error"]
I’ll patch the file to include a new test right after the last one.
> *** Update File: tests/test_server.py
> @@
> def test_samples_view_rejects_group_by() -> None:
> @@
> data = rv.get_json()
> assert rv.status_code == 400
> assert "only valid" in data["error"]
> +
> +
> +def test_table_avg_with_timestamp() -> None:
> + app = server.app
> + client = app.test_client()
> + payload = {
> + "start": "2024-01-01 00:00:00",
> + "end": "2024-01-03 00:00:00",
> + "graph_type": "table",
> + "order_by": "user",
> + "limit": 100,
> + "columns": ["user", "timestamp", "value"],
> + "group_by": ["user"],
> + }
> + rv = client.post(
> + "/api/query", data=json.dumps(payload), content_type="application/jso
n"
> + )
> + data = rv.get_json()
> + assert rv.status_code == 200
> + assert "error" not in data
> + rows = data["rows"]
> + assert rows[0][0] == "alice"
> + from dateutil import parser
> +
> + ts = parser.parse(rows[0][1]).replace(tzinfo=None)
> + assert ts == parser.parse("2024-01-01 12:00:00")
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_server.py
root@82cf664bb242:/workspace/scubaduck# source .venv/bin/activate
(scubaduck) root@82cf664bb242:/workspace/scubaduck# ruff format scubaduck/server
.py tests/test_server.py
2 files left unchanged
(scubaduck) root@82cf664bb242:/workspace/scubaduck# ruff check scubaduck/server.
py tests/test_server.py
All checks passed!
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I'll run pyright to check the type definitions in the code.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# pyright
0 errors, 0 warnings, 0 informations
(scubaduck) root@82cf664bb242:/workspace/scubaduck# pytest -q
bringing up nodes...
...........................F................. [100%]
=================================== FAILURES ===================================
_______________________ test_query_error_shown[chromium] _______________________
[gw1] linux -- Python 3.12.3 /workspace/scubaduck/.venv/bin/python
Traceback (most recent call last):
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 341, in from_call
result: TResult | None = func()
^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 242, in <lambda>
lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 92, in pytest_runtest_call
yield from thread_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/threadex
ception.py", line 68, in thread_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 95, in pytest_runtest_call
yield from unraisable_exception_runtest_hook()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/unraisab
leexception.py", line 70, in unraisable_exception_runtest_hook
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 846, in pytest_runtest_call
yield from self._runtest_for(item, "call")
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/logging.
py", line 829, in _runtest_for
yield
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/capture.
py", line 898, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 139, in _multicall
teardown.throw(exception)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/skipping
.py", line 257, in pytest_runtest_call
return (yield)
^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/runner.p
y", line 174, in pytest_runtest_call
item.runtest()
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 1627, in runtest
self.ihook.pytest_pyfunc_call(pyfuncitem=self)
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_hooks.py
", line 512, in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult
)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_manager.
py", line 120, in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 167, in _multicall
raise exception
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/pluggy/_callers.
py", line 121, in _multicall
res = hook_impl.function(*args)
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/.venv/lib/python3.12/site-packages/_pytest/python.p
y", line 159, in pytest_pyfunc_call
result = testfunction(**testargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/workspace/scubaduck/tests/test_web.py", line 282, in test_query_error_s
hown
assert "error" in data
AssertionError: assert 'error' in {'rows': [['bob', 1, 'Mon, 01 Jan 2024 01:00:0
0 GMT', 20], ['alice', 2, 'Mon, 01 Jan 2024 12:00:00 GMT', 20], ['charli...estam
p >= '2024-01-01 00:00:00' AND timestamp <= '2024-01-03 00:00:00' GROUP BY user
ORDER BY timestamp ASC LIMIT 100"}
------------------------------ Captured log call -------------------------------
INFO werkzeug:_internal.py:97 127.0.0.1 - - [21/May/2025 02:47:19] "GET / HT
TP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [21/May/2025 02:47:19] "GET /api
/columns HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [21/May/2025 02:47:19] "POST /ap
i/query HTTP/1.1" 200 -
INFO werkzeug:_internal.py:97 127.0.0.1 - - [21/May/2025 02:47:19] "POST /ap
i/query HTTP/1.1" 200 -
=========================== short test summary info ============================
FAILED tests/test_web.py::test_query_error_shown[chromium] - assert 'error' in {
'rows': [['bob', 1, 'Mon, 01 Jan 2024 01:00:00 GMT', 20]...
1 failed, 44 passed in 8.55s
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
The test's expecting an error, but the query no longer fails; I'll update the test.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# sed -n '260,290p' tests/test
_web.py
data = run_query(
page,
server_url,
start="nonsense",
end="now",
order_by="timestamp",
)
assert "error" in data
msg = page.text_content("#view")
assert "nonsense" in msg
def test_query_error_shown(page: Any, server_url: str) -> None:
data = run_query(
page,
server_url,
start="2024-01-01 00:00:00",
end="2024-01-03 00:00:00",
order_by="timestamp",
group_by=["user"],
aggregate="Avg",
)
assert "error" in data
msg = page.text_content("#view")
assert "Aggregate avg" in msg
def test_column_toggle_and_selection(page: Any, server_url: str) -> None:
page.goto(server_url)
page.wait_for_selector("#order_by option", state="attached")
page.click("text=Columns")
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
The test needs adjustment since the query now succeeds. I'll modify it to expect errors when there's an invalid query. Reproducing a failing situation like averaging the 'event' column can maintain error coverage.
> *** Update File: tests/test_web.py
> @@
> def test_query_error_shown(page: Any, server_url: str) -> None:
> - data = run_query(
> - page,
> - server_url,
> - start="2024-01-01 00:00:00",
> - end="2024-01-03 00:00:00",
> - order_by="timestamp",
> - group_by=["user"],
> - aggregate="Avg",
> - )
> - assert "error" in data
> - msg = page.text_content("#view")
> - assert "Aggregate avg" in msg
> + data = run_query(
> + page,
> + server_url,
> + start="2024-01-01 00:00:00",
> + end="2024-01-03 00:00:00",
> + order_by="timestamp",
> + group_by=["user"],
> + aggregate="Avg",
> + )
> + assert "error" not in data
> + assert len(data["rows"]) == 3
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web.py
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I see we might need to rename tests for clarity, especially 'test_query_error_shown'. Let's check.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# grep -n "test_query_error_sh
own" -n tests/test_web.py
272:def test_query_error_shown(page: Any, server_url: str) -> None:
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I'll rename the function to 'test_table_avg_group_by' to match its updated purpose.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: tests/test_web.py
> @@
> -def test_query_error_shown(page: Any, server_url: str) -> None:
> +def test_table_avg_group_by(page: Any, server_url: str) -> None:
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_web.py
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I changed the function name but kept the call. Time to run ruff format now.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# ruff format tests/test_web.p
y tests/test_server.py scubaduck/server.py
3 files left unchanged
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I'll run the ruff check again to double-check everything.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# ruff check scubaduck/server.
py tests/test_server.py tests/test_web.py
All checks passed!
(scubaduck) root@82cf664bb242:/workspace/scubaduck# pyright
0 errors, 0 warnings, 0 informations
(scubaduck) root@82cf664bb242:/workspace/scubaduck# pytest -q
bringing up nodes...
............................................. [100%]
45 passed in 8.09s
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
All tests passed—time to commit these changes.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# git status --short
M scubaduck/server.py
M tests/test_server.py
M tests/test_web.py
(scubaduck) root@82cf664bb242:/workspace/scubaduck# git add scubaduck/server.py
tests/test_server.py tests/test_web.py
(scubaduck) root@82cf664bb242:/workspace/scubaduck# git commit -m "fix(server):
allow avg on timestamp" && git status --short
[work 591c90d] fix(server): allow avg on timestamp
3 files changed, 31 insertions(+), 6 deletions(-)
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I missed running git status --short
after the commit, but I can do it now.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# git status --short
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I'll locate the lines in server.py after applying the patch. Time to check!
(scubaduck) root@82cf664bb242:/workspace/scubaduck# nl -ba scubaduck/server.py |
sed -n '280,330p'
280 return jsonify({"error": f"Unknown column: {col}"}), 400
281 if params.order_by and params.order_by not in valid_cols:
282 return jsonify({"error": f"Unknown column: {params.order_by}
"}), 400
283
284 if params.group_by:
285 agg = (params.aggregate or "avg").lower()
286 if agg.startswith("p") or agg == "sum":
287 need_numeric = True
288 allow_time = False
289 elif agg == "avg" or agg in {"min", "max"}:
290 need_numeric = False
291 allow_time = True
292 else:
293 need_numeric = False
294 allow_time = False
295 if need_numeric or allow_time:
296 for c in params.columns:
297 if c in params.group_by:
298 continue
299 ctype = column_types.get(c, "").upper()
300 is_numeric = any(
301 t in ctype
302 for t in [
303 "INT",
304 "DECIMAL",
305 "REAL",
306 "DOUBLE",
307 "FLOAT",
308 "NUMERIC",
309 "HUGEINT",
310 ]
311 )
312 is_time = "TIMESTAMP" in ctype or "DATE" in ctype or
"TIME" in ctype
313 if need_numeric and not is_numeric:
314 return (
315 jsonify(
316 {
317 "error": f"Aggregate {agg} cannot be
applied to column {c}",
318 }
319 ),
320 400,
321 )
322 if allow_time and not (is_numeric or is_time):
323 return (
324 jsonify(
325 {
326 "error": f"Aggregate {agg} cannot be
applied to column {c}",
327 }
328 ),
329 400,
330 )
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I'll grab the specific lines for adding the test cases.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# nl -ba tests/test_server.py
| sed -n '292,335p'
292 "start": "2024-01-01 00:00:00",
293 "end": "2024-01-03 00:00:00",
294 "graph_type": "table",
295 "order_by": "timestamp",
296 "limit": 100,
297 "columns": ["user", "Hits", "value"],
298 "group_by": ["user"],
299 "aggregate": "Count",
300 "show_hits": True,
301 }
302 rv = client.post(
303 "/api/query", data=json.dumps(payload), content_type="applicatio
n/json"
304 )
305 data = rv.get_json()
306 assert rv.status_code == 400
307 assert "Unknown column" in data["error"]
308
309
310 def test_samples_view_rejects_group_by() -> None:
311 app = server.app
312 client = app.test_client()
313 payload = {
314 "graph_type": "samples",
315 "group_by": ["user"],
316 "columns": ["timestamp"],
317 }
318 rv = client.post(
319 "/api/query", data=json.dumps(payload), content_type="applicatio
n/json"
320 )
321 data = rv.get_json()
322 assert rv.status_code == 400
323 assert "only valid" in data["error"]
324
325
326 def test_table_avg_with_timestamp() -> None:
327 app = server.app
328 client = app.test_client()
329 payload = {
330 "start": "2024-01-01 00:00:00",
331 "end": "2024-01-03 00:00:00",
332 "graph_type": "table",
333 "order_by": "user",
334 "limit": 100,
335 "columns": ["user", "timestamp", "value"],
(scubaduck) root@82cf664bb242:/workspace/scubaduck# nl -ba tests/test_server.py
| sed -n '335,365p'
335 "columns": ["user", "timestamp", "value"],
336 "group_by": ["user"],
337 }
338 rv = client.post(
339 "/api/query", data=json.dumps(payload), content_type="applicatio
n/json"
340 )
341 data = rv.get_json()
342 assert rv.status_code == 200
343 assert "error" not in data
344 rows = data["rows"]
345 assert rows[0][0] == "alice"
346 from dateutil import parser
347
348 ts = parser.parse(rows[0][1]).replace(tzinfo=None)
349 assert ts == parser.parse("2024-01-01 12:00:00")
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
I'll check the lines I modified in tests/test_web.py.
(scubaduck) root@82cf664bb242:/workspace/scubaduck# nl -ba tests/test_web.py | s
ed -n '260,290p'
260 data = run_query(
261 page,
262 server_url,
263 start="nonsense",
264 end="now",
265 order_by="timestamp",
266 )
267 assert "error" in data
268 msg = page.text_content("#view")
269 assert "nonsense" in msg
270
271
272 def test_table_avg_group_by(page: Any, server_url: str) -> None:
273 data = run_query(
274 page,
275 server_url,
276 start="2024-01-01 00:00:00",
277 end="2024-01-03 00:00:00",
278 order_by="timestamp",
279 group_by=["user"],
280 aggregate="Avg",
281 )
282 assert "error" not in data
283 assert len(data["rows"]) == 3
284
285
286 def test_column_toggle_and_selection(page: Any, server_url: str) -> None
:
287 page.goto(server_url)
288 page.wait_for_selector("#order_by option", state="attached")
289 page.click("text=Columns")
290 page.wait_for_selector("#column_groups input", state="attached")
(scubaduck) root@82cf664bb242:/workspace/scubaduck#
Time to open a pull request and finalize the changes.