Task ID: task_e_682fe05306688323a7de4e2e796170f6
Configuring language runtimes...
+ rm -- /tmp/QQWxfJ-setup_script.sh
+ source ./codex_setup.sh
++ uv sync --frozen
Using CPython 3.12.3 interpreter at: /usr/bin/python3.12
Creating virtual environment at: .venv
Building scubaduck @ file:///workspace/scubaduck
Downloading pyright (5.3MiB)
Downloading ruff (11.0MiB)
Downloading duckdb (19.3MiB)
Downloading playwright (43.1MiB)
Downloading ruff
Built scubaduck @ file:///workspace/scubaduck
Downloading duckdb
Downloading playwright
Downloading pyright
Prepared 33 packages in 2.59s
Installed 33 packages in 107ms
+ 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/QQWxfJ-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.16.0/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.16.0/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
++ python -c 'import os; import duckdb; con = duckdb.connect(); con.execute(f"SET http_proxy = '\''{os.getenv("HTTP_PROXY")}'\''"); con.execute("INSTALL '\''sqlite'\'';")'
++ 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% 46.4s167.7 MiB [] 0% 52.9s167.7 MiB [] 0% 36.9s167.7 MiB [] 0% 30.3s167.7 MiB [] 0% 24.4s167.7 MiB [] 0% 17.6s167.7 MiB [] 0% 13.0s167.7 MiB [] 1% 10.5s167.7 MiB [] 1% 7.8s167.7 MiB [] 2% 6.7s167.7 MiB [] 4% 4.3s167.7 MiB [] 5% 3.6s167.7 MiB [] 5% 3.5s167.7 MiB [] 6% 3.4s167.7 MiB [] 7% 3.1s167.7 MiB [] 8% 2.9s167.7 MiB [] 8% 3.0s167.7 MiB [] 9% 3.0s167.7 MiB [] 10% 3.0s167.7 MiB [] 12% 2.6s167.7 MiB [] 12% 2.7s167.7 MiB [] 13% 2.7s167.7 MiB [] 14% 2.7s167.7 MiB [] 14% 2.8s167.7 MiB [] 15% 2.8s167.7 MiB [] 15% 2.7s167.7 MiB [] 16% 2.6s167.7 MiB [] 17% 2.5s167.7 MiB [] 18% 2.4s167.7 MiB [] 19% 2.3s167.7 MiB [] 20% 2.3s167.7 MiB [] 21% 2.3s167.7 MiB [] 23% 2.2s167.7 MiB [] 23% 2.1s167.7 MiB [] 25% 2.0s167.7 MiB [] 26% 2.0s167.7 MiB [] 27% 1.9s167.7 MiB [] 28% 1.8s167.7 MiB [] 29% 1.7s167.7 MiB [] 30% 1.8s167.7 MiB [] 30% 1.7s167.7 MiB [] 32% 1.6s167.7 MiB [] 33% 1.6s167.7 MiB [] 35% 1.5s167.7 MiB [] 36% 1.5s167.7 MiB [] 37% 1.4s167.7 MiB [] 38% 1.4s167.7 MiB [] 39% 1.4s167.7 MiB [] 40% 1.3s167.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 [] 47% 1.1s167.7 MiB [] 48% 1.1s167.7 MiB [] 49% 1.1s167.7 MiB [] 50% 1.0s167.7 MiB [] 51% 1.0s167.7 MiB [] 52% 1.0s167.7 MiB [] 53% 1.0s167.7 MiB [] 54% 0.9s167.7 MiB [] 55% 0.9s167.7 MiB [] 57% 0.9s167.7 MiB [] 58% 0.8s167.7 MiB [] 59% 0.8s167.7 MiB [] 60% 0.8s167.7 MiB [] 61% 0.8s167.7 MiB [] 62% 0.7s167.7 MiB [] 63% 0.7s167.7 MiB [] 65% 0.7s167.7 MiB [] 66% 0.7s167.7 MiB [] 67% 0.6s167.7 MiB [] 68% 0.6s167.7 MiB [] 69% 0.6s167.7 MiB [] 70% 0.6s167.7 MiB [] 71% 0.5s167.7 MiB [] 72% 0.5s167.7 MiB [] 74% 0.5s167.7 MiB [] 75% 0.5s167.7 MiB [] 76% 0.4s167.7 MiB [] 77% 0.4s167.7 MiB [] 78% 0.4s167.7 MiB [] 79% 0.4s167.7 MiB [] 80% 0.4s167.7 MiB [] 81% 0.3s167.7 MiB [] 82% 0.3s167.7 MiB [] 83% 0.3s167.7 MiB [] 84% 0.3s167.7 MiB [] 85% 0.3s167.7 MiB [] 86% 0.2s167.7 MiB [] 87% 0.2s167.7 MiB [] 88% 0.2s167.7 MiB [] 89% 0.2s167.7 MiB [] 90% 0.2s167.7 MiB [] 91% 0.2s167.7 MiB [] 92% 0.1s167.7 MiB [] 93% 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 [] 2% 0.7s2.3 MiB [] 5% 0.8s2.3 MiB [] 10% 0.5s2.3 MiB [] 15% 0.4s2.3 MiB [] 22% 0.3s2.3 MiB [] 36% 0.2s2.3 MiB [] 51% 0.1s2.3 MiB [] 80% 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% 29.6s101.4 MiB [] 0% 32.0s101.4 MiB [] 0% 22.3s101.4 MiB [] 0% 18.6s101.4 MiB [] 0% 13.7s101.4 MiB [] 1% 10.3s101.4 MiB [] 1% 7.5s101.4 MiB [] 2% 5.4s101.4 MiB [] 3% 3.8s101.4 MiB [] 6% 2.6s101.4 MiB [] 7% 2.1s101.4 MiB [] 9% 1.9s101.4 MiB [] 9% 2.0s101.4 MiB [] 11% 1.8s101.4 MiB [] 13% 1.6s101.4 MiB [] 16% 1.4s101.4 MiB [] 18% 1.2s101.4 MiB [] 21% 1.1s101.4 MiB [] 23% 1.0s101.4 MiB [] 25% 1.0s101.4 MiB [] 27% 0.9s101.4 MiB [] 29% 0.9s101.4 MiB [] 31% 0.8s101.4 MiB [] 31% 0.9s101.4 MiB [] 32% 0.9s101.4 MiB [] 34% 0.9s101.4 MiB [] 35% 0.8s101.4 MiB [] 38% 0.8s101.4 MiB [] 40% 0.7s101.4 MiB [] 41% 0.7s101.4 MiB [] 43% 0.7s101.4 MiB [] 45% 0.6s101.4 MiB [] 47% 0.6s101.4 MiB [] 49% 0.6s101.4 MiB [] 51% 0.6s101.4 MiB [] 53% 0.5s101.4 MiB [] 55% 0.5s101.4 MiB [] 56% 0.5s101.4 MiB [] 58% 0.5s101.4 MiB [] 60% 0.4s101.4 MiB [] 63% 0.4s101.4 MiB [] 65% 0.4s101.4 MiB [] 66% 0.4s101.4 MiB [] 69% 0.3s101.4 MiB [] 70% 0.3s101.4 MiB [] 73% 0.3s101.4 MiB [] 74% 0.3s101.4 MiB [] 76% 0.2s101.4 MiB [] 79% 0.2s101.4 MiB [] 80% 0.2s101.4 MiB [] 83% 0.2s101.4 MiB [] 85% 0.2s101.4 MiB [] 87% 0.1s101.4 MiB [] 89% 0.1s101.4 MiB [] 91% 0.1s101.4 MiB [] 93% 0.1s101.4 MiB [] 96% 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'
bash: .venv/bin/activate: No such file or directory
root@141de59f5186:/workspace# ls
scubaduck
root@141de59f5186:/workspace# cd scubaduck
root@141de59f5186:/workspace/scubaduck# ls
AGENTS.md codex_setup.sh scubaduck tests
README.md pyproject.toml stubs uv.lock
I'll begin by checking the AGENTS.md file in the root directory.
root@141de59f5186:/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
Looks like the root instructions are clear: activate the environment before running commands!
root@141de59f5186:/workspace/scubaduck# cat README.md
I really like Scuba (Meta's internal real-time database system). The distributed
, real-time database part of Scuba is quite difficult (and expensive) to replica
te, but I also really like Scuba's UI for doing queries, and I have found myself
wishing that I have access to it even for "small" databases, e.g., I have a sql
ite dataset I want to explore.
Pivotal ideas:
* Time series by default. In the dedicated "time series" view, there are many fe
atures specifically oriented towards working towards tables that represent event
s that occurred over time: the start, end, compare, aggregate and granularity fi
elds all specially privilege the timestamp field. In fact, you can't log events
to Scuba's backing data store without a timestamp, they always come with one. (S
cuba also supports other views that don't presuppose a time series, but the time
series is the most beloved and well used view.) This is in contrast to typical
software which tries to generalize to arbitrary data first, with time series bei
ng added on later.
* It's all about exploration. Scuba is predicated on the idea that you don't kno
w what you're looking for, that you are going to spend time tweaking queries and
changing filters/grouping as part of an investigation to figure out why a syste
m behaves the way it is. So the filters/comparisons/groupings you want to edit a
re always visible on the left sidebar, with the expectation that you're going to
tweak the query to look at something else. Similarly, all the parameters of you
r query get saved into your URL, so your browser history can double up as a quer
y history / you can easily share a query with someone else. This is contrast to
typical software which is often oriented to making pretty dashboards and reports
. (This function is important too, but it's not what I want in exploration mode!
)
* You can fix data problems in the query editor. It's pretty common to have mess
ed up and ended up with a database that doesn't have exactly the columns you nee
d, or some columns that are corrupted in some way. Scuba has pretty robust suppo
rt for defining custom columns with arbitrary SQL functions, grouping over them
as if they were native functions, and doing so with minimal runtime cost (Scuba
aims to turn around your query in milliseconds!) Having to go and run a huge dat
a pipeline to fix your data is a big impediment to exploration; quick and easy c
ustom columns means you can patch over problems when you're investigating and fi
x them for real later.
We're going to build a exploratory data analysis tool like Scuba for time series
database (i.e., a database with a mandatory timestamp representing the time an
event occurred). We'll use DuckDB as the underlying SQL engine served from a Py
thon server, and render the GUI/results as a webpage with vanilla HTML and JS. W
e'll use choices.js to support token inputs. We define a token input to mean a
text input element where as you type a dropdown displays with valid values, and
if you select one or press enter, the selection turns into a token/chip that can
only be deleted as one unit.
To start, we are going to support one views: samples. The samples view only all
ows you to view individual samples from the database, subject to a filter. Our m
ain UI concept is that there is a left sidebar that is the query editor, and the
right side that shows the view. The sidebar is always visible and defaults to
the query parameters of the current view. After you make changes to the query,
clicking the "Dive" button updates the view. The URL of the page encodes all of
the values of the query (and gets updated when you Dive), so the browser's back
button lets you view previous queries.
The query editor's job is to generate a SQL query, which then is applied on the
database, and then the result visualized according to the view.
Here are the settings you can apply to the query. The help text should show up w
hen you mouse over the field name:
* Start/End - Help text: "Sets the start/end of the time range to query. Can be
any kind of datetime string. For example: 'April 23, 2014' or 'yesterday'." The
UI for this selector supports both relative selections (now, -1 hour, -3 hours,
-12 hours, -1 day, -3 days, -1 week, -1 fortnight, -30 days, -90 days, -1 month,
-1 year) as well as specifying an absolute date. The way this field is rendere
d is there is a free form text box, a drop down arrow (for the relative selector
s), and then a calendar button (for date selection).
* Order By - Help text: "Choose a column to sort results by." There is an ASC/D
ESC toggle next to it.
* Limit - Help text: "Choose the maximum number of results to show in the chart
after any aggregations have been applied. For example, a limit of 10 will show
no more than 10 rows for a table, etc."
* Filters - You can create as many filters as you want. You can either write a f
ilter using a UI or manual SQL. In the UI, filter consists of a column name, a r
elation (e.g., =, !=, <, >) and then a text field. The text field is a token inp
ut. It accepts multiple tokens for = relation, in which case we match using an O
R for all options.
There is also a "Columns" tab which lets you view all fields in the table, organ
ized by their type. You can also define derived columns, by specifying a column
name and SQL expression. Derived columns can be used for all parts of the UI, in
cluding filters/group by/etc. Columns have checkboxes indicating if we should SE
LECT them or not. Each selected column shows up in the graph. There is an All/N
one link which can be used to select/deselect all checkboxes.
The query UI constructs a SQL query that intuitively has this form:
```
SELECT column, column, ...,
FROM table
WHERE time >= min-timestamp
AND time <= max-timestamp
[AND condition ...]
ORDER BY aggregate(column)
LIMIT number
```
You should write tests for the server backend, demonstrating that at specific qu
ery values we get back the correct rows of data.
## Running the server
Activate the virtual environment and run the Flask development server:
```bash
flask --app scubaduck.server run --debug
```
By default the server loads `sample.csv`. Set the `SCUBADUCK_DB` environment
variable to point at a different database file (CSV, SQLite or DuckDB) if you
want to use another dataset. The special value `TEST` starts the server with a
small in-memory SQLite dataset used by the automated tests. If the file does
not exist, the server will raise a `FileNotFoundError` during startup.
I'll check out the scubaduck directory to explore how to implement the heuristics.
root@141de59f5186:/workspace/scubaduck# ls scubaduck
__init__.py sample.csv server.py static
root@141de59f5186:/workspace/scubaduck# ls scubaduck/static
index.html js
root@141de59f5186:/workspace/scubaduck# ls scubaduck/static/js
chip_input.js table_view.js timeseries_chart.js
sidebar.js time_column.js view_settings.js
root@141de59f5186:/workspace/scubaduck# ls tests
conftest.py test_server_time.py test_web_graph.py
test_multi_table_web.py test_server_timeseries.py test_web_table.py
test_server.py test_web.py test_web_timeseries.py
test_server_basic.py test_web_basics.py web_utils.py
test_server_db_types.py test_web_chips.py
test_server_errors.py test_web_columns.py
I'll search the repo for any existing time parsing code to help with heuristics.
root@141de59f5186:/workspace/scubaduck# grep -n "time_unit" -r
grep: .venv/bin/ruff: binary file matches
scubaduck/static/index.html:205: <select id="time_unit" style="margin-l
eft:4px">
scubaduck/static/js/table_view.js:59: const unit = document.getElementB
yId("time_unit").value;
scubaduck/static/js/view_settings.js:186: document.getElementById('time_unit').
style.display = show ? '' : 'none';
scubaduck/static/js/view_settings.js:696: time_unit: document.getElementById(
'time_unit').value,
scubaduck/static/js/view_settings.js:744: if (params.time_unit) sp.set('time_un
it', params.time_unit);
scubaduck/static/js/view_settings.js:774: if (params.time_unit) document.getEle
mentById('time_unit').value = params.time_unit;
scubaduck/static/js/view_settings.js:845: document.getElementById('time_unit').
value = 's';
scubaduck/static/js/view_settings.js:877: if (sp.has('time_unit')) params.time_
unit = sp.get('time_unit');
scubaduck/server.py:52: time_unit: str = "s"
scubaduck/server.py:257: xexpr = _time_expr(x_axis, column_types, params.
time_unit)
scubaduck/server.py:352: time_expr = _time_expr(params.time_column, colum
n_types, params.time_unit)
scubaduck/server.py:517: time_unit=payload.get("time_unit", "s"),
scubaduck/server.py:532: if params.time_unit not in {"s", "ms", "us", "ns
"}:
scubaduck/server.py:533: return jsonify({"error": "Invalid time_unit"
}), 400
scubaduck/server.py:643: mn = _numeric_to_datetime(mn, params
.time_unit)
scubaduck/server.py:650: f" with time_unit {p
arams.time_unit}"
scubaduck/server.py:658: mx = _numeric_to_datetime(mx, params
.time_unit)
scubaduck/server.py:665: f" with time_unit {p
arams.time_unit}"
tests/test_web_timeseries.py:410: select_value(page, "#time_unit", "s")
tests/test_server_time.py:33:def test_integer_time_unit_ms(tmp_path: Path) -> No
ne:
tests/test_server_time.py:45: "time_unit": "ms",
tests/test_server_time.py:69: "time_unit": "ms",
tests/test_server_time.py:79:def test_integer_time_unit_us_default_start_end(tmp
_path: Path) -> None:
tests/test_server_time.py:91: "time_unit": "us",
tests/test_server_time.py:103:def test_sqlite_integer_time_unit_us(tmp_path: Pat
h) -> None:
tests/test_server_time.py:123: "time_unit": "us",
tests/test_server_time.py:239: "time_unit": "s",
tests/test_server_time.py:257: "time_unit": "us",
tests/test_web_basics.py:77:def test_time_unit_dropdown(page: Any, server_url: s
tr) -> None:
tests/test_web_basics.py:79: page.wait_for_selector("#time_unit", state="atta
ched")
tests/test_web_basics.py:80: opts = page.locator("#time_unit option").all_inn
er_texts()
tests/test_web_basics.py:82: assert page.input_value("#time_unit") == "s"
tests/test_web_basics.py:85:def test_time_unit_hidden_when_no_time_column(page:
Any, server_url: str) -> None:
tests/test_web_basics.py:89: assert page.is_hidden("#time_unit")
I'll check lines around 650 for time unit conversions in the server.py.
root@141de59f5186:/workspace/scubaduck# sed -n '640,675p' scubaduck/server.py
mn, mx = row
if isinstance(mn, (int, float)):
try:
mn = _numeric_to_datetime(mn, params.time_unit)
except Exception:
return (
jsonify(
{
"error": (
f"Invalid time value {mn} for column {axis}"
f" with time_unit {params.time_unit}"
)
}
),
400,
)
if isinstance(mx, (int, float)):
try:
mx = _numeric_to_datetime(mx, params.time_unit)
except Exception:
return (
jsonify(
{
"error": (
f"Invalid time value {mx} for column {axis}"
f" with time_unit {params.time_unit}"
)
}
),
400,
)
if params.start is None and mn is not None:
params.start = (
mn.strftime("%Y-%m-%d %H:%M:%S") if not isinstance(mn, str)
else mn
)
if params.end is None and mx is not None:
I'm reviewing the _numeric_to_datetime function to understand the current date conversion logic.
root@141de59f5186:/workspace/scubaduck# grep -n "_numeric_to_datetime" -n scubad
uck/server.py
143:def _numeric_to_datetime(value: int | float, unit: str) -> datetime:
643: mn = _numeric_to_datetime(mn, params.time_unit)
658: mx = _numeric_to_datetime(mx, params.time_unit)
root@141de59f5186:/workspace/scubaduck# sed -n '140,155p' scubaduck/server.py
return dt.replace(microsecond=0, tzinfo=None).strftime("%Y-%m-%d %H:%M:%S")
def _numeric_to_datetime(value: int | float, unit: str) -> datetime:
"""Convert a numeric timestamp ``value`` with unit ``unit`` to ``datetime``.
Heuristically fall back to seconds when the converted value is before 1990
but the seconds interpretation is in a reasonable range. This handles
integer columns stored in seconds even when ``unit`` is mistakenly set to a
finer granularity.
"""
divisor = {
"s": 1,
"ms": 1000,
"us": 1_000_000,
root@141de59f5186:/workspace/scubaduck# sed -n '155,175p' scubaduck/server.py
"us": 1_000_000,
"ns": 1_000_000_000,
}.get(unit, 1)
dt = datetime.fromtimestamp(int(value) / divisor, tz=timezone.utc)
if unit != "s" and dt.year < 1990:
alt = datetime.fromtimestamp(int(value), tz=timezone.utc)
if alt.year >= 1990:
dt = alt
return dt
def _granularity_seconds(granularity: str, start: str | None, end: str | None) -
> int:
gran = granularity.lower()
mapping = {
"1 second": 1,
"5 seconds": 5,
"10 seconds": 10,
"30 seconds": 30,
"1 minute": 60,
"4 minutes": 240,
I think there might be an issue with interpreting integers as time. I'll check _time_expr.
root@141de59f5186:/workspace/scubaduck# grep -n "_time_expr" -n scubaduck/server
.py
201:def _time_expr(col: str, column_types: Dict[str, str] | None, unit: str) ->
str:
257: xexpr = _time_expr(x_axis, column_types, params.time_unit)
352: time_expr = _time_expr(params.time_column, column_types, params.time
_unit)
root@141de59f5186:/workspace/scubaduck# sed -n '190,215p' scubaduck/server.py
try:
s = dtparser.parse(start)
e = dtparser.parse(end)
except Exception:
return 3600
total = max((e - s).total_seconds(), 1)
buckets = 100 if gran == "auto" else 500
return max(int(total // buckets), 1)
return 3600
def _time_expr(col: str, column_types: Dict[str, str] | None, unit: str) -> str:
"""Return SQL expression for column interpreted as timestamp."""
qcol = _quote(col)
if column_types is None:
return qcol
ctype = column_types.get(col, "").upper()
if not any(t in ctype for t in ["TIMESTAMP", "DATE", "TIME"]):
if any(
t in ctype
for t in [
"INT",
"DECIMAL",
"REAL",
"DOUBLE",
"FLOAT",
root@141de59f5186:/workspace/scubaduck# sed -n '215,245p' scubaduck/server.py
"FLOAT",
"NUMERIC",
"HUGEINT",
]
):
if unit == "ns":
# Use nanosecond helper unless column cannot represent such larg
e values
if "INT" in ctype and "BIGINT" not in ctype and "HUGEINT" not in
ctype:
unit = "s"
else:
expr = f"CAST({qcol} AS BIGINT)"
return f"make_timestamp_ns({expr})"
if (
unit != "s"
and "INT" in ctype
and "BIGINT" not in ctype
and "HUGEINT" not in ctype
):
# 32-bit integers cannot store sub-second precision for modern d
ates
unit = "s"
multiplier = {
"s": 1_000_000,
"ms": 1_000,
"us": 1,
}.get(unit, 1_000_000)
base = f"CAST({qcol} AS BIGINT)"
expr = f"CAST({base} * {multiplier} AS BIGINT)" if multiplier != 1 e
lse base
return f"make_timestamp({expr})"
return qcol
I’ll check the tests in test_server_time.py to see what's already being covered.
root@141de59f5186:/workspace/scubaduck# sed -n '1,160p' tests/test_server_time.p
y
from __future__ import annotations
import json
from pathlib import Path
import pytest
from scubaduck import server
def test_integer_time_column(tmp_path: Path) -> None:
csv_file = tmp_path / "events.csv"
csv_file.write_text("created,event\n1704067200,login\n1704070800,logout\n")
app = server.create_app(csv_file)
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-01 01:00:00",
"order_by": "created",
"columns": ["created", "event"],
"time_column": "created",
}
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"]) == 2
def test_integer_time_unit_ms(tmp_path: Path) -> None:
csv_file = tmp_path / "events.csv"
csv_file.write_text("created,event\n1704067200000,login\n1704070800000,logou
t\n")
app = server.create_app(csv_file)
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-01 01:00:00",
"order_by": "created",
"columns": ["created", "event"],
"time_column": "created",
"time_unit": "ms",
}
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"]) == 2
def test_timeseries_default_xaxis_uses_time_column(tmp_path: Path) -> None:
csv_file = tmp_path / "events.csv"
csv_file.write_text("created,event\n1704067200000,login\n1704070800000,logou
t\n")
app = server.create_app(csv_file)
client = app.test_client()
payload = {
"table": "events",
"start": "2024-01-01 00:00:00",
"end": "2024-01-01 01:00:00",
"graph_type": "timeseries",
"granularity": "1 hour",
"columns": ["event"],
"aggregate": "Count",
"time_column": "created",
"time_unit": "ms",
}
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"]) == 2
def test_integer_time_unit_us_default_start_end(tmp_path: Path) -> None:
csv_file = tmp_path / "events.csv"
csv_file.write_text(
"created,event\n1704067200000000,login\n1704070800000000,logout\n"
)
app = server.create_app(csv_file)
client = app.test_client()
payload = {
"table": "events",
"order_by": "created",
"columns": ["created", "event"],
"time_column": "created",
"time_unit": "us",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["start"] == "2024-01-01 00:00:00"
assert data["end"] == "2024-01-01 01:00:00"
assert len(data["rows"]) == 2
def test_sqlite_integer_time_unit_us(tmp_path: Path) -> None:
sqlite_file = tmp_path / "events.sqlite"
import sqlite3
conn = sqlite3.connect(sqlite_file)
conn.execute("CREATE TABLE visits (visit_time INTEGER, event TEXT)")
big_ts = 13384551652000000
conn.execute("INSERT INTO visits VALUES (?, ?)", (big_ts, "foo"))
conn.commit()
conn.close() # pyright: ignore[reportUnknownMemberType, reportAttributeAcce
ssIssue]
app = server.create_app(sqlite_file)
client = app.test_client()
payload = {
"table": "visits",
"start": "2394-02-20 00:00:00",
"end": "2394-02-21 00:00:00",
"order_by": "visit_time",
"columns": ["visit_time", "event"],
"time_column": "visit_time",
"time_unit": "us",
}
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
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 = {
"table": "events",
"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()
root@141de59f5186:/workspace/scubaduck# sed -n '160,320p' tests/test_server_time
.py
data = rv.get_json()
assert rv.status_code == 200
assert len(data["rows"]) == 1
assert data["rows"][0][3] == "charlie"
def test_relative_month_year(monkeypatch: pytest.MonkeyPatch) -> None:
app = server.app
client = app.test_client()
from datetime import datetime
fixed_now = datetime(2024, 1, 2, 0, 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 = {
"table": "events",
"start": "-1 year",
"end": "-1 month",
"order_by": "timestamp",
"limit": 10,
"columns": ["timestamp"],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["start"] == "2023-01-02 00:00:00"
assert data["end"] == "2023-12-02 00:00:00"
def test_default_start_end_returned() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"order_by": "timestamp",
"limit": 5,
"columns": ["timestamp"],
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["start"] == "2024-01-01 00:00:00"
assert data["end"] == "2024-01-02 03:00:00"
def test_time_column_none_no_time_filter() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"columns": ["timestamp", "event"],
"time_column": "",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert "start" not in data and "end" not in data
assert len(data["rows"]) == 4
def test_test_dataset_int32_time_s() -> None:
app = server.create_app("TEST")
client = app.test_client()
payload = {
"table": "events",
"time_column": "ts",
"time_unit": "s",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["start"] == "2024-01-01 00:00:00"
assert data["end"] == "2024-01-01 01:00:00"
assert len(data["rows"]) == 2
def test_test_dataset_int32_time_us() -> None:
app = server.create_app("TEST")
client = app.test_client()
payload = {
"table": "events",
"time_column": "ts",
"time_unit": "us",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["start"] == "2024-01-01 00:00:00"
assert data["end"] == "2024-01-01 01:00:00"
assert len(data["rows"]) == 2
I’m scrolling down to check additional tests.
root@141de59f5186:/workspace/scubaduck# sed -n '320,480p' tests/test_server_time
.py
I'll look at tests/test_server_errors.py for time parsing and invalid values.
root@141de59f5186:/workspace/scubaduck# grep -n "Invalid time value" -r tests
I'll search for tests related to invalid time errors now.
root@141de59f5186:/workspace/scubaduck# grep -n "invalid time" -r tests
I don't see a test for invalid time values, so I'll check the server tests for errors.
root@141de59f5186:/workspace/scubaduck# sed -n '1,160p' tests/test_server_errors
.py
from __future__ import annotations
import json
from scubaduck import server
def test_invalid_time_error() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"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 = {
"table": "events",
"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 = {
"table": "events",
"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 == 200
def test_samples_view_rejects_group_by() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"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"]
root@141de59f5186:/workspace/scubaduck# sed -n '160,320p' tests/test_server_erro
rs.py
I'll check server tests for timeseries to see if heuristics are involved with time_unit.
root@141de59f5186:/workspace/scubaduck# grep -n "Invalid time" -r scubaduck
scubaduck/server.py:530: return jsonify({"error": "Invalid time_colum
n"}), 400
scubaduck/server.py:533: return jsonify({"error": "Invalid time_unit"
}), 400
scubaduck/server.py:649: f"Invalid time value
{mn} for column {axis}"
scubaduck/server.py:664: f"Invalid time value
{mx} for column {axis}"
I'll search earlier in the file for a previous invalid time_column check.
root@141de59f5186:/workspace/scubaduck# sed -n '520,536p' scubaduck/server.py
params.order_by = "Hits"
for f in payload.get("filters", []):
params.filters.append(Filter(f["column"], f["op"], f.get("value")))
if params.table not in tables:
return jsonify({"error": "Invalid table"}), 400
column_types = get_columns(params.table)
if params.time_column and params.time_column not in column_types:
return jsonify({"error": "Invalid time_column"}), 400
if params.time_unit not in {"s", "ms", "us", "ns"}:
return jsonify({"error": "Invalid time_unit"}), 400
if params.graph_type not in {"table", "timeseries"} and (
params.group_by or params.aggregate or params.show_hits
I'm trying to figure out how to handle large numeric values as time in different units.
root@141de59f5186:/workspace/scubaduck# python - <<'EOF'
> from datetime import datetime
> print(datetime.utcfromtimestamp(1338441480))
> print(datetime.utcfromtimestamp(1338441480/1000))
> print(datetime.utcfromtimestamp(1338441480/1000000))
> EOF
2012-05-31 05:18:00
1970-01-16 11:47:21.480000
1970-01-01 00:22:18.441480
I think I calculated incorrectly—let me double-check the conversion on that.
root@141de59f5186:/workspace/scubaduck# python - <<'EOF'
> import datetime
> print(datetime.datetime.utcfromtimestamp(13384414800000000/1e6))
> EOF
2394-02-19 05:00:00
If given microseconds as seconds, this would point to a year far in the future.
root@141de59f5186:/workspace/scubaduck# python - <<'EOF'
> import datetime
> print(datetime.datetime.utcfromtimestamp(13384414800000000))
> EOF
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
ValueError: year 424137190 is out of range
I'll create a function to heuristically guess the time unit if _numeric_to_datetime fails.
I’ll integrate _guess_time_unit when calculating default start/end in _numeric_to_datetime code.
root@141de59f5186:/workspace/scubaduck# sed -n '600,660p' scubaduck/server.py
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,
)
if (params.start is None or params.end is None) and (
params.x_axis or params.time_column
):
axis = params.x_axis or params.time_column
assert axis is not None
row = cast(
tuple[datetime | None, datetime | None],
con.execute(
f'SELECT min({_quote(axis)}), max({_quote(axis)}) FROM "{par
ams.table}"'
).fetchall()[0],
)
mn, mx = row
if isinstance(mn, (int, float)):
try:
mn = _numeric_to_datetime(mn, params.time_unit)
except Exception:
return (
jsonify(
{
"error": (
f"Invalid time value {mn} for column {axis}"
f" with time_unit {params.time_unit}"
)
}
),
400,
)
if isinstance(mx, (int, float)):
try:
mx = _numeric_to_datetime(mx, params.time_unit)
except Exception:
return (
root@141de59f5186:/workspace/scubaduck# sed -n '660,720p' scubaduck/server.py
return (
jsonify(
{
"error": (
f"Invalid time value {mx} for column {axis}"
f" with time_unit {params.time_unit}"
)
}
),
400,
)
if params.start is None and mn is not None:
params.start = (
mn.strftime("%Y-%m-%d %H:%M:%S") if not isinstance(mn, str)
else mn
)
if params.end is None and mx is not None:
params.end = (
mx.strftime("%Y-%m-%d %H:%M:%S") if not isinstance(mx, str)
else mx
)
bucket_size: int | None = None
if params.graph_type == "timeseries":
bucket_size = _granularity_seconds(
params.granularity,
params.start if isinstance(params.start, str) else None,
params.end if isinstance(params.end, str) else None,
)
if (
params.limit is not None
and params.start is not None
and params.end is not None
):
try:
start_dt = dtparser.parse(params.start)
end_dt = dtparser.parse(params.end)
buckets = math.ceil(
(end_dt - start_dt).total_seconds() / bucket_size
)
if buckets > 1:
params.limit *= buckets
except Exception:
pass
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,
)
def _serialize(value: Any) -> Any:
if isinstance(value, bytes):
return repr(value)
return value
rows = [[_serialize(v) for v in r] for r in rows]
result: Dict[str, Any] = {"sql": sql, "rows": rows}
I want to modify the error messaging to guess the correct time unit based on the value. I'll create a function to check units like seconds, milliseconds, microseconds, and nanoseconds. I'll test them by parsing the timestamp and ensure the year is within a reasonable range. This helps avoid unrealistic guesses, while not snooping data.
I’ll implement a function to suggest a time unit based on date validity, avoiding unrealistic years.
I’ll add a function to suggest a time unit upon error and update exception handling accordingly.
I’ll write a test to check if the server suggests the correct time unit in errors.
root@141de59f5186:/workspace/scubaduck# python - <<'EOF'
> from datetime import datetime
> value = 1704067200000000
> for u,d in ["s",1],["ms",1000],["us",1_000_000],["ns",1_000_000_000]:
> try:
> dt=datetime.utcfromtimestamp(value/d)
> print(u, dt)
> except Exception as e:
> print(u, 'err')
> EOF
s err
ms err
us 2024-01-01 00:00:00
ns 1970-01-20 17:21:07.200000
I'm reviewing the timestamp 1704067200000000 and confirming it's close to January 2024.
root@141de59f5186:/workspace/scubaduck# python - <<'EOF'
> import datetime
> print(datetime.datetime.utcfromtimestamp(1704067200))
> EOF
2024-01-01 00:00:00
I realized I made a mistake by not properly creating tuples in the zip.
root@141de59f5186:/workspace/scubaduck# python - <<'EOF'
> from datetime import datetime
> value=1704067200000000
> for u,d in [("s",1),("ms",1000),("us",1_000_000),("ns",1_000_000_000)]:
> try:
> dt=datetime.utcfromtimestamp(value/d)
> print(u, dt)
> except Exception:
> print(u, 'err')
> EOF
s err
ms err
us 2024-01-01 00:00:00
ns 1970-01-20 17:21:07.200000
The conversion seems valid, but I’ll replicate the step to verify the issue.
root@141de59f5186:/workspace/scubaduck# python - <<'EOF'
> import datetime
> print(1704067200000000/1000)
> print(datetime.datetime.utcfromtimestamp(1704067200000000/1000))
> EOF
1704067200000.0
Traceback (most recent call last):
File "<stdin>", line 3, in <module>
ValueError: year 55969 is out of range
I'm determining that the proper unit is microseconds, so I'll adjust my heuristic accordingly.
I will add a test for incorrect time units and implement a helper function to guess.
I will modify the exception block to suggest a corrected time unit when an error occurs.
I’ll check the README for relevant documentation and add details about the new behavior.
root@141de59f5186:/workspace/scubaduck# grep -n "maybe" -r README.md scubaduck
I'll search for "time_unit" in the README to see if it covers new time units.
root@141de59f5186:/workspace/scubaduck# grep -n "time_unit" README.md
I'll open the README to see if the "Heuristically fall back to seconds" section is mentioned.
root@141de59f5186:/workspace/scubaduck# grep -n "heuristic" -r
grep: .venv/lib/python3.12/site-packages/playwright/driver/node: binary file mat
ches
.venv/lib/python3.12/site-packages/playwright/driver/package/types/protocol.d.ts
:1364: * Specified whether a filled field was done so by using the html auto
complete attribute or autofill heuristics.
.venv/lib/python3.12/site-packages/playwright/driver/package/types/protocol.d.ts
:11020: * Whether 3pc heuristics exceptions should be enabled; false by de
fault.
.venv/lib/python3.12/site-packages/playwright/driver/package/types/protocol.d.ts
:13825:produced upon backend discretion, based on internal heuristics.
.venv/lib/python3.12/site-packages/xdist/scheduler/load.py:185: heuristic
to influence how many tests the node is assigned.
.venv/lib/python3.12/site-packages/xdist/scheduler/load.py:193: # if
our node goes below a heuristic minimum, fill it out to
.venv/lib/python3.12/site-packages/xdist/scheduler/load.py:194: # heu
ristic maximum
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/net
workx/networkx/algorithms/shortest_paths/astar.pyi:13: heuristic: Callable[..
., Incomplete] | None = None,
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/net
workx/networkx/algorithms/shortest_paths/astar.pyi:23: heuristic: Callable[..
., Incomplete] | None = None,
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/net
workx/networkx/algorithms/shortest_paths/weighted.pyi:97: heuristic: bool = F
alse,
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/net
workx/networkx/algorithms/shortest_paths/weighted.pyi:144: heuristic: bool =
True,
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/net
workx/networkx/utils/rcm.pyi:4:def cuthill_mckee_ordering(G, heuristic: Incomple
te | None = None) -> Generator[Incomplete, Incomplete, None]: ...
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/net
workx/networkx/utils/rcm.pyi:5:def reverse_cuthill_mckee_ordering(G, heuristic:
Incomplete | None = None): ...
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/pyw
in32/_win32typing.pyi:1381: def srvheuristics(self) -> str: ...
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/pyw
in32/_win32typing.pyi:1443: def srvheuristics(self) -> str: ...
.venv/lib/python3.12/site-packages/pyright/dist/dist/typeshed-fallback/stubs/pyw
in32/_win32typing.pyi:2183: def wrk_heuristics(self) -> str: ...
.venv/lib/python3.12/site-packages/charset_normalizer/api.py:619: Based on th
e same main heuristic algorithms and default kwargs at the sole exception that f
allbacks match
grep: .venv/bin/ruff: binary file matches
I'll check the README for data errors and time/start details before adding the new heuristic.
root@141de59f5186:/workspace/scubaduck# grep -n "Invalid" README.md
I'll add a note about the server guessing the probable time unit for invalid inputs.
root@141de59f5186:/workspace/scubaduck# sed -n '1,80p' README.md
I really like Scuba (Meta's internal real-time database system). The distributed
, real-time database part of Scuba is quite difficult (and expensive) to replica
te, but I also really like Scuba's UI for doing queries, and I have found myself
wishing that I have access to it even for "small" databases, e.g., I have a sql
ite dataset I want to explore.
Pivotal ideas:
* Time series by default. In the dedicated "time series" view, there are many fe
atures specifically oriented towards working towards tables that represent event
s that occurred over time: the start, end, compare, aggregate and granularity fi
elds all specially privilege the timestamp field. In fact, you can't log events
to Scuba's backing data store without a timestamp, they always come with one. (S
cuba also supports other views that don't presuppose a time series, but the time
series is the most beloved and well used view.) This is in contrast to typical
software which tries to generalize to arbitrary data first, with time series bei
ng added on later.
* It's all about exploration. Scuba is predicated on the idea that you don't kno
w what you're looking for, that you are going to spend time tweaking queries and
changing filters/grouping as part of an investigation to figure out why a syste
m behaves the way it is. So the filters/comparisons/groupings you want to edit a
re always visible on the left sidebar, with the expectation that you're going to
tweak the query to look at something else. Similarly, all the parameters of you
r query get saved into your URL, so your browser history can double up as a quer
y history / you can easily share a query with someone else. This is contrast to
typical software which is often oriented to making pretty dashboards and reports
. (This function is important too, but it's not what I want in exploration mode!
)
* You can fix data problems in the query editor. It's pretty common to have mess
ed up and ended up with a database that doesn't have exactly the columns you nee
d, or some columns that are corrupted in some way. Scuba has pretty robust suppo
rt for defining custom columns with arbitrary SQL functions, grouping over them
as if they were native functions, and doing so with minimal runtime cost (Scuba
aims to turn around your query in milliseconds!) Having to go and run a huge dat
a pipeline to fix your data is a big impediment to exploration; quick and easy c
ustom columns means you can patch over problems when you're investigating and fi
x them for real later.
We're going to build a exploratory data analysis tool like Scuba for time series
database (i.e., a database with a mandatory timestamp representing the time an
event occurred). We'll use DuckDB as the underlying SQL engine served from a Py
thon server, and render the GUI/results as a webpage with vanilla HTML and JS. W
e'll use choices.js to support token inputs. We define a token input to mean a
text input element where as you type a dropdown displays with valid values, and
if you select one or press enter, the selection turns into a token/chip that can
only be deleted as one unit.
To start, we are going to support one views: samples. The samples view only all
ows you to view individual samples from the database, subject to a filter. Our m
ain UI concept is that there is a left sidebar that is the query editor, and the
right side that shows the view. The sidebar is always visible and defaults to
the query parameters of the current view. After you make changes to the query,
clicking the "Dive" button updates the view. The URL of the page encodes all of
the values of the query (and gets updated when you Dive), so the browser's back
button lets you view previous queries.
The query editor's job is to generate a SQL query, which then is applied on the
database, and then the result visualized according to the view.
Here are the settings you can apply to the query. The help text should show up w
hen you mouse over the field name:
* Start/End - Help text: "Sets the start/end of the time range to query. Can be
any kind of datetime string. For example: 'April 23, 2014' or 'yesterday'." The
UI for this selector supports both relative selections (now, -1 hour, -3 hours,
-12 hours, -1 day, -3 days, -1 week, -1 fortnight, -30 days, -90 days, -1 month,
-1 year) as well as specifying an absolute date. The way this field is rendere
d is there is a free form text box, a drop down arrow (for the relative selector
s), and then a calendar button (for date selection).
* Order By - Help text: "Choose a column to sort results by." There is an ASC/D
ESC toggle next to it.
* Limit - Help text: "Choose the maximum number of results to show in the chart
after any aggregations have been applied. For example, a limit of 10 will show
no more than 10 rows for a table, etc."
* Filters - You can create as many filters as you want. You can either write a f
ilter using a UI or manual SQL. In the UI, filter consists of a column name, a r
elation (e.g., =, !=, <, >) and then a text field. The text field is a token inp
ut. It accepts multiple tokens for = relation, in which case we match using an O
R for all options.
There is also a "Columns" tab which lets you view all fields in the table, organ
ized by their type. You can also define derived columns, by specifying a column
name and SQL expression. Derived columns can be used for all parts of the UI, in
cluding filters/group by/etc. Columns have checkboxes indicating if we should SE
LECT them or not. Each selected column shows up in the graph. There is an All/N
one link which can be used to select/deselect all checkboxes.
The query UI constructs a SQL query that intuitively has this form:
```
SELECT column, column, ...,
FROM table
WHERE time >= min-timestamp
AND time <= max-timestamp
[AND condition ...]
ORDER BY aggregate(column)
LIMIT number
```
You should write tests for the server backend, demonstrating that at specific qu
ery values we get back the correct rows of data.
## Running the server
Activate the virtual environment and run the Flask development server:
```bash
flask --app scubaduck.server run --debug
```
By default the server loads `sample.csv`. Set the `SCUBADUCK_DB` environment
variable to point at a different database file (CSV, SQLite or DuckDB) if you
want to use another dataset. The special value `TEST` starts the server with a
small in-memory SQLite dataset used by the automated tests. If the file does
not exist, the server will raise a `FileNotFoundError` during startup.
I'll add a test for invalid time values in test_server_errors.py
using the existing format.
root@141de59f5186:/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 in test_server_errors
to match their style with mine.
from __future__ import annotations
import json
from scubaduck import server
def test_invalid_time_error() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"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 = {
"table": "events",
"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 = {
"table": "events",
"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 open the remaining sections to examine the style further.
s.py
"show_hits": True,
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
_data = rv.get_json()
assert rv.status_code == 200
def test_samples_view_rejects_group_by() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"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’m checking if the new test should go in test_server_time
or test_server_errors
.
"table": "events",
"time_column": "ts",
"time_unit": "s",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["start"] == "2024-01-01 00:00:00"
assert data["end"] == "2024-01-01 01:00:00"
assert len(data["rows"]) == 2
def test_test_dataset_int32_time_us() -> None:
app = server.create_app("TEST")
client = app.test_client()
payload = {
"table": "events",
"time_column": "ts",
"time_unit": "us",
}
rv = client.post(
"/api/query", data=json.dumps(payload), content_type="application/json"
)
data = rv.get_json()
assert rv.status_code == 200
assert data["start"] == "2024-01-01 00:00:00"
assert data["end"] == "2024-01-01 01:00:00"
assert len(data["rows"]) == 2
I’ll add the new test in test_server_errors.py
and then review server.__init__
for specifics.
root@141de59f5186:/workspace/scubaduck# ls scubaduck
__init__.py sample.csv server.py static
I'll check init.py to review the exports and related details.
root@141de59f5186:/workspace/scubaduck# cat scubaduck/__init__.py
def hello() -> str:
return "Hello from scubaduck!"
I'll open server.py and add the _suggest_unit function, integrating it with _numeric_to_datetime.
dt = dtparser.parse(s)
return dt.replace(microsecond=0, tzinfo=None).strftime("%Y-%m-%d %H:%M:%S")
def _numeric_to_datetime(value: int | float, unit: str) -> datetime:
"""Convert a numeric timestamp ``value`` with unit ``unit`` to ``datetime``.
Heuristically fall back to seconds when the converted value is before 1990
but the seconds interpretation is in a reasonable range. This handles
integer columns stored in seconds even when ``unit`` is mistakenly set to a
finer granularity.
"""
divisor = {
"s": 1,
"ms": 1000,
"us": 1_000_000,
"ns": 1_000_000_000,
}.get(unit, 1)
dt = datetime.fromtimestamp(int(value) / divisor, tz=timezone.utc)
if unit != "s" and dt.year < 1990:
alt = datetime.fromtimestamp(int(value), tz=timezone.utc)
if alt.year >= 1990:
dt = alt
return dt
def _granularity_seconds(granularity: str, start: str | None, end: str | None) -
> int:
gran = granularity.lower()
mapping = {
"1 second": 1,
"5 seconds": 5,
"10 seconds": 10,
"30 seconds": 30,
"1 minute": 60,
"4 minutes": 240,
"5 minutes": 300,
"10 minutes": 600,
"15 minutes": 900,
"30 minutes": 1800,
I’ll add the function after _numeric_to_datetime for clarity, checking different time units.
> from datetime import datetime
> value = 13384414800000000
> for u, d in [('s', 1), ('ms', 1000), ('us', 1_000_000), ('ns', 1_000_000_000)]
:
> try:
> dt = datetime.utcfromtimestamp(value / d)
> print(u, dt)
> except Exception as e:
> print(u, 'error')
> EOF
s error
ms error
us 2394-02-19 05:00:00
ns 1970-06-04 21:53:34.800000
I'll add the test, handle positive values, and run format, check, and tests after.
dt = dtparser.parse(s)
return dt.replace(microsecond=0, tzinfo=None).strftime("%Y-%m-%d %H:%M:%S")
def _numeric_to_datetime(value: int | float, unit: str) -> datetime:
"""Convert a numeric timestamp ``value`` with unit ``unit`` to ``datetime``.
Heuristically fall back to seconds when the converted value is before 1990
but the seconds interpretation is in a reasonable range. This handles
integer columns stored in seconds even when ``unit`` is mistakenly set to a
finer granularity.
"""
divisor = {
"s": 1,
"ms": 1000,
"us": 1_000_000,
"ns": 1_000_000_000,
}.get(unit, 1)
dt = datetime.fromtimestamp(int(value) / divisor, tz=timezone.utc)
if unit != "s" and dt.year < 1990:
alt = datetime.fromtimestamp(int(value), tz=timezone.utc)
if alt.year >= 1990:
dt = alt
return dt
def _granularity_seconds(granularity: str, start: str | None, end: str | None) -
> int:
gran = granularity.lower()
mapping = {
"1 second": 1,
"5 seconds": 5,
"10 seconds": 10,
"30 seconds": 30,
"1 minute": 60,
"4 minutes": 240,
"5 minutes": 300,
"10 minutes": 600,
"15 minutes": 900,
"30 minutes": 1800,
"1 hour": 3600,
"3 hours": 10800,
"6 hours": 21600,
"1 day": 86400,
"1 week": 604800,
"30 days": 2592000,
I’ll add the _suggest_time_unit function right after _numeric_to_datetime.
> *** Begin Patch
> *** Update File: scubaduck/server.py
> @@
> def _numeric_to_datetime(value: int | float, unit: str) -> datetime:
> @@
> if unit != "s" and dt.year < 1990:
> alt = datetime.fromtimestamp(int(value), tz=timezone.utc)
> if alt.year >= 1990:
> dt = alt
> return dt
> +
> +
> +def _suggest_time_unit(value: int | float, given: str) -> str | None:
> + """Return a plausible time unit for ``value`` not equal to ``given``."""
> +
> + for unit in ("s", "ms", "us", "ns"):
> + if unit == given:
> + continue
> + try:
> + dt = _numeric_to_datetime(value, unit)
> + except Exception:
> + continue
> + if 1990 <= dt.year <= 2500:
> + return unit
> + return None
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/server.py
I'll include suggestions in the error messages to make them more helpful.
}
),
400,
)
if (params.start is None or params.end is None) and (
params.x_axis or params.time_column
):
axis = params.x_axis or params.time_column
assert axis is not None
row = cast(
tuple[datetime | None, datetime | None],
con.execute(
f'SELECT min({_quote(axis)}), max({_quote(axis)}) FROM "{par
ams.table}"'
).fetchall()[0],
)
mn, mx = row
if isinstance(mn, (int, float)):
try:
mn = _numeric_to_datetime(mn, params.time_unit)
except Exception:
return (
jsonify(
{
"error": (
f"Invalid time value {mn} for column {axis}"
f" with time_unit {params.time_unit}"
)
}
),
I'll improve the error message to include a helpful suggestion. Time to patch it!
root@141de59f5186:/workspace/scubaduck# apply_patch<<'PATCH'
> *** Begin Patch
> *** Update File: scubaduck/server.py
> @@
> if isinstance(mn, (int, float)):
> try:
> mn = _numeric_to_datetime(mn, params.time_unit)
> except Exception:
> - return (
> - jsonify(
> - {
> - "error": (
> - f"Invalid time value {mn} for column {axi
s}"
> - f" with time_unit {params.time_unit}"
> - )
> - }
> - ),
> - 400,
> - )
> + suggestion = _suggest_time_unit(mn, params.time_unit)
> + msg = (
> + f"Invalid time value {mn} for column {axis} with time
_unit {params.time_unit}"
> + )
> + if suggestion:
> + msg += f"; maybe try time_unit {suggestion}"
> + return jsonify({"error": msg}), 400
> if isinstance(mx, (int, float)):
> try:
> mx = _numeric_to_datetime(mx, params.time_unit)
> except Exception:
> - return (
> - jsonify(
> - {
> - "error": (
> - f"Invalid time value {mx} for column {axi
s}"
> - f" with time_unit {params.time_unit}"
> - )
> - }
> - ),
> - 400,
> - )
> + suggestion = _suggest_time_unit(mx, params.time_unit)
> + msg = (
> + f"Invalid time value {mx} for column {axis} with time
_unit {params.time_unit}"
> + )
> + if suggestion:
> + msg += f"; maybe try time_unit {suggestion}"
> + return jsonify({"error": msg}), 400
> *** End Patch
> PATCH
Success. Updated the following files:
M scubaduck/server.py
I need to activate the environment before running ruff format.
root@141de59f5186:/workspace/scubaduck# source .venv/bin/activate
(scubaduck) root@141de59f5186:/workspace/scubaduck# ruff format scubaduck/server
.py tests/test_server_errors.py
1 file reformatted, 1 file left unchanged
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I’ll add a new test to tests/test_server_errors.py and open the file to verify.
import json
from scubaduck import server
def test_invalid_time_error() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"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 = {
"table": "events",
"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 = {
"table": "events",
"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 == 200
def test_samples_view_rejects_group_by() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"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"]
(scubaduck) root@141de59f5186:/workspace/scubaduck#
The file's around 100 lines. I’ll add the new test and update it.
> *** Begin Patch
> *** Update File: tests/test_server_errors.py
> @@
> def test_samples_view_rejects_group_by() -> None:
> @@
> assert page.is_hidden("#time_unit")
> *** End Patch
> PATCH
Failed to find expected lines [" assert page.is_hidden(\"#time_unit\")"] in t
ests/test_server_errors.py
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I made a mistake; I’ll open the file at the bottom to fix it.
(scubaduck) root@141de59f5186:/workspace/scubaduck# tail -n 20 tests/test_server
_errors.py
)
_data = rv.get_json()
assert rv.status_code == 200
def test_samples_view_rejects_group_by() -> None:
app = server.app
client = app.test_client()
payload = {
"table": "events",
"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"]
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I'll add a new test at the end of this file. Time to patch!
> *** Begin Patch
> *** Update File: tests/test_server_errors.py
> @@
> def test_samples_view_rejects_group_by() -> None:
> @@
> assert "only valid" in data["error"]
> +
> +
> +def test_invalid_integer_time_value_suggests_unit(tmp_path: Path) -> None:
> + csv_file = tmp_path / "events.csv"
> + csv_file.write_text("created,event\n1704067200000000,login\n")
> + app = server.create_app(csv_file)
> + client = app.test_client()
> + payload = {
> + "table": "events",
> + "columns": ["created"],
> + "time_column": "created",
> + "time_unit": "s",
> + }
> + rv = client.post(
> + "/api/query", data=json.dumps(payload), content_type="application/jso
n"
> + )
> + data = rv.get_json()
> + assert rv.status_code == 400
> + assert "maybe try time_unit us" in data["error"]
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_server_errors.py
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I need to import Path from pathlib at the top of the file. Let's do that.
rver_errors.py
from __future__ import annotations
import json
from scubaduck import server
def test_invalid_time_error() -> None:
app = server.app
client = app.test_client()
(scubaduck) root@141de59f5186:/workspace/scubaduck#
The file’s truncated; I'll open the first 10 lines to inspect the content.
(scubaduck) root@141de59f5186:/workspace/scubaduck# head -n 10 tests/test_server
_errors.py
from __future__ import annotations
import json
from scubaduck import server
def test_invalid_time_error() -> None:
app = server.app
client = app.test_client()
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I'll add 'from pathlib import Path' after 'import json' and patch the new test.
> *** Begin Patch
> *** Update File: tests/test_server_errors.py
> @@
> -from scubaduck import server
> +from pathlib import Path
> +
> +from scubaduck import server
> *** End Patch
> PATCH
Success. Updated the following files:
M tests/test_server_errors.py
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I'll format the tests and server files using ruff.
(scubaduck) root@141de59f5186:/workspace/scubaduck# ruff format scubaduck/server
.py tests/test_server_errors.py
2 files left unchanged
(scubaduck) root@141de59f5186:/workspace/scubaduck# ruff check scubaduck/server.
py tests/test_server_errors.py
All checks passed!
tests/test_server_errors.py > /tmp/pyright.log && tail -n 20 /tmp/pyright.log
0 errors, 0 warnings, 0 informations
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I'm going to go ahead and run the tests now.
(scubaduck) root@141de59f5186:/workspace/scubaduck# pytest -q tests/test_server_
errors.py::test_invalid_integer_time_value_suggests_unit > /tmp/pytest.log && ta
il -n 20 /tmp/pytest.log
bringing up nodes...
bringing up nodes...
. [100%]
1 passed in 0.99s
(scubaduck) root@141de59f5186:/workspace/scubaduck# pytest -q > /tmp/pytest.log
&& tail -n 20 /tmp/pytest.log
bringing up nodes...
bringing up nodes...
........................................................................ [ 58%]
.................................................... [100%]
124 passed in 18.83s
(scubaduck) root@141de59f5186:/workspace/scubaduck#
All tests pass, now let's check the git status to see my changes.
(scubaduck) root@141de59f5186:/workspace/scubaduck# git status --short
M scubaduck/server.py
M tests/test_server_errors.py
(scubaduck) root@141de59f5186:/workspace/scubaduck# git add scubaduck/server.py
tests/test_server_errors.py
(scubaduck) root@141de59f5186:/workspace/scubaduck# git commit -m "feat(server):
suggest time unit in errors" && git status --short
[work a79afa1] feat(server): suggest time unit in errors
2 files changed, 46 insertions(+), 22 deletions(-)
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I’ll check the git status to confirm the current state of the repository.
(scubaduck) root@141de59f5186:/workspace/scubaduck# git status --short
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I'll summarize the changes and include relevant lines from server.py with the updates.
140 return dt.replace(microsecond=0, tzinfo=None).strftime("%Y-%m-%d %H:
%M:%S")
141
142
143 def _numeric_to_datetime(value: int | float, unit: str) -> datetime:
144 """Convert a numeric timestamp ``value`` with unit ``unit`` to ``dat
etime``.
145
146 Heuristically fall back to seconds when the converted value is befor
e 1990
147 but the seconds interpretation is in a reasonable range. This handl
es
148 integer columns stored in seconds even when ``unit`` is mistakenly s
et to a
149 finer granularity.
150 """
151
152 divisor = {
153 "s": 1,
154 "ms": 1000,
155 "us": 1_000_000,
156 "ns": 1_000_000_000,
157 }.get(unit, 1)
158
159 dt = datetime.fromtimestamp(int(value) / divisor, tz=timezone.utc)
160 if unit != "s" and dt.year < 1990:
161 alt = datetime.fromtimestamp(int(value), tz=timezone.utc)
162 if alt.year >= 1990:
163 dt = alt
164 return dt
165
166
167 def _suggest_time_unit(value: int | float, given: str) -> str | None:
168 """Return a plausible time unit for ``value`` not equal to ``given``
."""
169
170 for unit in ("s", "ms", "us", "ns"):
171 if unit == given:
172 continue
173 try:
174 dt = _numeric_to_datetime(value, unit)
175 except Exception:
176 continue
177 if 1990 <= dt.year <= 2500:
178 return unit
179 return None
180
181
182 def _granularity_seconds(granularity: str, start: str | None, end: str |
None) -> int:
183 gran = granularity.lower()
(scubaduck) root@141de59f5186:/workspace/scubaduck# nl -ba scubaduck/server.py |
sed -n '660,707p'
660 suggestion = _suggest_time_unit(mn, params.time_unit
)
661 msg = f"Invalid time value {mn} for column {axis} wi
th time_unit {params.time_unit}"
662 if suggestion:
663 msg += f"; maybe try time_unit {suggestion}"
664 return jsonify({"error": msg}), 400
665 if isinstance(mx, (int, float)):
666 try:
667 mx = _numeric_to_datetime(mx, params.time_unit)
668 except Exception:
669 suggestion = _suggest_time_unit(mx, params.time_unit
)
670 msg = f"Invalid time value {mx} for column {axis} wi
th time_unit {params.time_unit}"
671 if suggestion:
672 msg += f"; maybe try time_unit {suggestion}"
673 return jsonify({"error": msg}), 400
674 if params.start is None and mn is not None:
675 params.start = (
676 mn.strftime("%Y-%m-%d %H:%M:%S") if not isinstance(m
n, str) else mn
677 )
678 if params.end is None and mx is not None:
679 params.end = (
680 mx.strftime("%Y-%m-%d %H:%M:%S") if not isinstance(m
x, str) else mx
681 )
682
683 bucket_size: int | None = None
684 if params.graph_type == "timeseries":
685 bucket_size = _granularity_seconds(
686 params.granularity,
687 params.start if isinstance(params.start, str) else None,
688 params.end if isinstance(params.end, str) else None,
689 )
690 if (
691 params.limit is not None
692 and params.start is not None
693 and params.end is not None
694 ):
695 try:
696 start_dt = dtparser.parse(params.start)
697 end_dt = dtparser.parse(params.end)
698 buckets = math.ceil(
699 (end_dt - start_dt).total_seconds() / bucket_siz
e
700 )
701 if buckets > 1:
702 params.limit *= buckets
703 except Exception:
704 pass
705
706 sql = build_query(params, column_types)
707 try:
(scubaduck) root@141de59f5186:/workspace/scubaduck# nl -ba tests/test_server_err
ors.py | sed -n '70,120p'
70
71 def test_samples_view_rejects_group_by() -> None:
72 app = server.app
73 client = app.test_client()
74 payload = {
75 "table": "events",
76 "graph_type": "samples",
77 "group_by": ["user"],
78 "columns": ["timestamp"],
79 }
80 rv = client.post(
81 "/api/query", data=json.dumps(payload), content_type="applicatio
n/json"
82 )
83 data = rv.get_json()
84 assert rv.status_code == 400
85 assert "only valid" in data["error"]
86
87
88 def test_invalid_integer_time_value_suggests_unit(tmp_path: Path) -> Non
e:
89 csv_file = tmp_path / "events.csv"
90 csv_file.write_text("created,event\n1704067200000000,login\n")
91 app = server.create_app(csv_file)
92 client = app.test_client()
93 payload = {
94 "table": "events",
95 "columns": ["created"],
96 "time_column": "created",
97 "time_unit": "s",
98 }
99 rv = client.post(
100 "/api/query", data=json.dumps(payload), content_type="applicatio
n/json"
101 )
102 data = rv.get_json()
103 assert rv.status_code == 400
104 assert "maybe try time_unit us" in data["error"]
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I'll grab the lines near the beginning to highlight the import change.
(scubaduck) root@141de59f5186:/workspace/scubaduck# nl -ba tests/test_server_err
ors.py | sed -n '1,10p'
1 from __future__ import annotations
2
3 import json
4
5 from pathlib import Path
6
7 from scubaduck import server
8
9
10 def test_invalid_time_error() -> None:
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I’ll summarize the PR body and make sure the commit is correct before finalizing it.
(scubaduck) root@141de59f5186:/workspace/scubaduck# git log -1 --stat
commit a79afa1ec0163f97df24e2f1a9b501eba95cc07b (HEAD -> work)
Author: Codex <codex@openai.com>
Date: Fri May 23 02:44:42 2025 +0000
feat(server): suggest time unit in errors
scubaduck/server.py | 47 ++++++++++++++++++++++++---------------------
tests/test_server_errors.py | 21 ++++++++++++++++++++
2 files changed, 46 insertions(+), 22 deletions(-)
(scubaduck) root@141de59f5186:/workspace/scubaduck#
I'll summarize the feature and reference the relevant lines for the final PR.