Skip to content

Commit 1dd5461

Browse files
authored
Merge pull request #68 from mattip/download-data
add a dump button to admin page and manage.py import_sqlite_dump file
2 parents 1374a23 + b7a3982 commit 1dd5461

7 files changed

Lines changed: 347 additions & 2 deletions

File tree

codespeed/admin_views.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import os
2+
import sqlite3
3+
import tempfile
4+
from datetime import datetime, date
5+
6+
from django.contrib.admin.views.decorators import staff_member_required
7+
from django.http import FileResponse
8+
9+
from django.db import connection
10+
11+
SIZE_LIMIT = 95 * 1024 * 1024 # 95 MB
12+
13+
# Schema for the exported SQLite file, in FK-safe creation order.
14+
# Column names must match Django's generated PostgreSQL column names exactly.
15+
_SCHEMA = """
16+
PRAGMA foreign_keys = OFF;
17+
18+
CREATE TABLE codespeed_project (
19+
id INTEGER PRIMARY KEY,
20+
name TEXT NOT NULL,
21+
repo_type TEXT NOT NULL,
22+
repo_path TEXT NOT NULL,
23+
repo_user TEXT NOT NULL,
24+
repo_pass TEXT NOT NULL,
25+
commit_browsing_url TEXT NOT NULL,
26+
track INTEGER NOT NULL,
27+
default_branch TEXT NOT NULL
28+
);
29+
30+
CREATE TABLE codespeed_branch (
31+
id INTEGER PRIMARY KEY,
32+
name TEXT NOT NULL,
33+
project_id INTEGER NOT NULL,
34+
display_on_comparison_page INTEGER NOT NULL
35+
);
36+
37+
CREATE TABLE codespeed_executable (
38+
id INTEGER PRIMARY KEY,
39+
name TEXT NOT NULL,
40+
description TEXT NOT NULL,
41+
project_id INTEGER NOT NULL
42+
);
43+
44+
CREATE TABLE codespeed_environment (
45+
id INTEGER PRIMARY KEY,
46+
name TEXT NOT NULL,
47+
cpu TEXT NOT NULL,
48+
memory TEXT NOT NULL,
49+
os TEXT NOT NULL,
50+
kernel TEXT NOT NULL
51+
);
52+
53+
CREATE TABLE codespeed_benchmark (
54+
id INTEGER PRIMARY KEY,
55+
name TEXT NOT NULL,
56+
parent_id INTEGER,
57+
source TEXT NOT NULL,
58+
data_type TEXT NOT NULL,
59+
description TEXT NOT NULL,
60+
units_title TEXT NOT NULL,
61+
units TEXT NOT NULL,
62+
lessisbetter INTEGER NOT NULL,
63+
default_on_comparison INTEGER NOT NULL
64+
);
65+
66+
CREATE TABLE codespeed_revision (
67+
id INTEGER PRIMARY KEY,
68+
commitid TEXT NOT NULL,
69+
tag TEXT NOT NULL,
70+
date TEXT,
71+
message TEXT NOT NULL,
72+
project_id INTEGER,
73+
author TEXT NOT NULL,
74+
branch_id INTEGER NOT NULL
75+
);
76+
77+
CREATE TABLE codespeed_result (
78+
id INTEGER PRIMARY KEY,
79+
value REAL NOT NULL,
80+
std_dev REAL,
81+
val_min REAL,
82+
val_max REAL,
83+
q1 REAL,
84+
q3 REAL,
85+
suite_version TEXT NOT NULL,
86+
date TEXT,
87+
revision_id INTEGER NOT NULL,
88+
executable_id INTEGER NOT NULL,
89+
benchmark_id INTEGER NOT NULL,
90+
environment_id INTEGER NOT NULL
91+
);
92+
"""
93+
94+
_SMALL_TABLES = [
95+
'codespeed_project',
96+
'codespeed_branch',
97+
'codespeed_executable',
98+
'codespeed_environment',
99+
'codespeed_benchmark',
100+
'codespeed_revision',
101+
]
102+
103+
104+
def _conv(v):
105+
"""Convert PostgreSQL Python types to SQLite-safe scalars."""
106+
if isinstance(v, (datetime, date)):
107+
return v.isoformat()
108+
return v
109+
110+
111+
def _build_sqlite(path):
112+
lite = sqlite3.connect(path)
113+
try:
114+
lite.executescript(_SCHEMA)
115+
lite.commit()
116+
117+
with connection.cursor() as pg:
118+
for table in _SMALL_TABLES:
119+
pg.execute(f'SELECT * FROM {table}')
120+
cols = [d[0] for d in pg.description]
121+
rows = [tuple(_conv(v) for v in row) for row in pg.fetchall()]
122+
if rows:
123+
ph = ', '.join(['?'] * len(cols))
124+
lite.executemany(
125+
f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({ph})",
126+
rows,
127+
)
128+
lite.commit()
129+
130+
# Result table: newest-first, stop at size cap
131+
pg.execute(
132+
'SELECT * FROM codespeed_result ORDER BY date DESC NULLS LAST'
133+
)
134+
cols = [d[0] for d in pg.description]
135+
ph = ', '.join(['?'] * len(cols))
136+
insert_sql = (
137+
f"INSERT INTO codespeed_result ({', '.join(cols)}) VALUES ({ph})"
138+
)
139+
while True:
140+
batch = pg.fetchmany(5000)
141+
if not batch:
142+
break
143+
lite.executemany(
144+
insert_sql,
145+
[tuple(_conv(v) for v in row) for row in batch],
146+
)
147+
lite.commit()
148+
if os.path.getsize(path) > SIZE_LIMIT:
149+
break
150+
finally:
151+
lite.close()
152+
153+
154+
@staff_member_required
155+
def download_db(request):
156+
fd, path = tempfile.mkstemp(suffix='.sqlite3')
157+
os.close(fd)
158+
try:
159+
_build_sqlite(path)
160+
f = open(path, 'rb')
161+
os.unlink(path) # unlink now; data survives until f is closed
162+
today = datetime.today().strftime('%Y-%m-%d')
163+
response = FileResponse(
164+
f,
165+
as_attachment=True,
166+
filename=f'codespeed-{today}.sqlite3',
167+
)
168+
response.set_cookie(
169+
'codespeed_download_ready', '1',
170+
max_age=60, path='/', samesite='Lax',
171+
)
172+
return response
173+
except Exception:
174+
if os.path.exists(path):
175+
os.unlink(path)
176+
raise

codespeed/management/__init__.py

Whitespace-only changes.

codespeed/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""
2+
Import a codespeed SQLite snapshot into the running database.
3+
4+
Rows that already exist (by primary key or unique constraint) are silently
5+
skipped, so the command is safe to re-run and merges cleanly into existing
6+
data.
7+
8+
Usage:
9+
python manage.py import_sqlite_dump codespeed-YYYY-MM-DD.sqlite3
10+
"""
11+
import sqlite3
12+
from datetime import datetime
13+
14+
from django.core.management.base import BaseCommand, CommandError
15+
from django.db import connection
16+
17+
# Columns that are stored as 0/1 integers in SQLite but need Python bools
18+
# for PostgreSQL's boolean type.
19+
_BOOL_COLS = {
20+
'codespeed_project': {'track'},
21+
'codespeed_branch': {'display_on_comparison_page'},
22+
'codespeed_benchmark': {'lessisbetter', 'default_on_comparison'},
23+
}
24+
25+
# Columns stored as ISO strings in SQLite that must become datetime objects
26+
# for PostgreSQL's timestamp type.
27+
_DT_COLS = {
28+
'codespeed_revision': {'date'},
29+
'codespeed_result': {'date'},
30+
}
31+
32+
# Insertion order respects FK dependencies.
33+
_TABLES = [
34+
'codespeed_project',
35+
'codespeed_branch',
36+
'codespeed_executable',
37+
'codespeed_environment',
38+
'codespeed_benchmark',
39+
'codespeed_revision',
40+
'codespeed_result',
41+
]
42+
43+
44+
def _adapt(value, col, bool_cols, dt_cols):
45+
if value is None:
46+
return None
47+
if col in bool_cols:
48+
return bool(value)
49+
if col in dt_cols and isinstance(value, str):
50+
return datetime.fromisoformat(value)
51+
return value
52+
53+
54+
class Command(BaseCommand):
55+
help = 'Import a SQLite snapshot into the running PostgreSQL database'
56+
57+
def add_arguments(self, parser):
58+
parser.add_argument('sqlite_file', help='Path to the SQLite dump file')
59+
60+
def handle(self, *args, **options):
61+
sqlite_file = options['sqlite_file']
62+
try:
63+
src = sqlite3.connect(sqlite_file)
64+
except Exception as e:
65+
raise CommandError(f'Cannot open {sqlite_file}: {e}')
66+
67+
src.row_factory = sqlite3.Row
68+
69+
with connection.cursor() as cur:
70+
for table in _TABLES:
71+
bool_cols = _BOOL_COLS.get(table, set())
72+
dt_cols = _DT_COLS.get(table, set())
73+
74+
rows = src.execute(f'SELECT * FROM {table}').fetchall()
75+
if not rows:
76+
self.stdout.write(f' {table}: 0 rows')
77+
continue
78+
79+
cols = list(rows[0].keys())
80+
col_list = ', '.join(cols)
81+
placeholders = ', '.join(['%s'] * len(cols))
82+
sql = (
83+
f'INSERT INTO {table} ({col_list}) '
84+
f'VALUES ({placeholders}) '
85+
f'ON CONFLICT DO NOTHING'
86+
)
87+
data = [
88+
tuple(_adapt(row[c], c, bool_cols, dt_cols) for c in cols)
89+
for row in rows
90+
]
91+
cur.executemany(sql, data)
92+
self.stdout.write(f' {table}: {len(data)} rows')
93+
94+
src.close()
95+
self.stdout.write(self.style.SUCCESS('Import complete'))

speed_pypy/settings.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,6 @@
9595
]
9696
DEF_EXECUTABLES = [
9797
{'name': 'pypy3.11-jit-64', 'project': 'PyPy3.11'},
98-
{'name': 'pypy3.10-jit-64', 'project': 'PyPy3.10'},
99-
{'name': 'pypy3.9-jit-64', 'project': 'PyPy3.9'},
10098
]
10199
DEF_ENVIRONMENT = 'benchmarker'
102100
CHART_ORIENTATION = 'horizontal'
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{% extends "admin/index.html" %}
2+
{% load i18n %}
3+
4+
{% block content %}
5+
<div id="content-main">
6+
{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
7+
8+
<div class="app-datatools module">
9+
<table>
10+
<caption>Data tools</caption>
11+
<thead class="visually-hidden">
12+
<tr>
13+
<th scope="col">{% trans "Action" %}</th>
14+
<th scope="col"></th>
15+
<th scope="col">{% trans "Link" %}</th>
16+
</tr>
17+
</thead>
18+
<tbody>
19+
<tr class="model-sqlitesnapshot">
20+
<th scope="row" id="datatools-sqlitesnapshot">SQLite snapshot</th>
21+
<td></td>
22+
<td>
23+
<a id="download-db-btn" href="{% url 'admin-download-db' %}" class="changelink">Download</a>
24+
<span id="download-db-status" style="display:none; margin-left:1em; color:#666; font-style:italic;"></span>
25+
</td>
26+
</tr>
27+
</tbody>
28+
</table>
29+
</div>
30+
</div>
31+
<script>
32+
(function () {
33+
var btn = document.getElementById('download-db-btn');
34+
var status = document.getElementById('download-db-status');
35+
if (!btn) { return; }
36+
37+
btn.addEventListener('click', function (e) {
38+
e.preventDefault();
39+
btn.style.display = 'none';
40+
status.style.display = '';
41+
status.textContent = 'Generating… (0s)';
42+
43+
var start = Date.now();
44+
var timer = setInterval(function () {
45+
var sec = Math.floor((Date.now() - start) / 1000);
46+
status.textContent = 'Generating… (' + sec + 's)';
47+
}, 1000);
48+
49+
window.location.href = btn.href;
50+
51+
var poll = setInterval(function () {
52+
var ready = document.cookie.split(';').some(function (c) {
53+
return c.trim().indexOf('codespeed_download_ready=') === 0;
54+
});
55+
if (ready) {
56+
clearInterval(timer);
57+
clearInterval(poll);
58+
document.cookie = 'codespeed_download_ready=; max-age=0; path=/';
59+
status.style.display = 'none';
60+
btn.style.display = '';
61+
}
62+
}, 500);
63+
64+
setTimeout(function () {
65+
clearInterval(timer);
66+
clearInterval(poll);
67+
status.style.display = 'none';
68+
btn.style.display = '';
69+
}, 90000);
70+
});
71+
}());
72+
</script>
73+
{% endblock %}

speed_pypy/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from django.urls import include, re_path
55
from django.contrib import admin
66

7+
from codespeed import admin_views
8+
79
urlpatterns = [
10+
re_path(r'^admin/download-db/$', admin_views.download_db, name='admin-download-db'),
811
re_path(r'^admin/', admin.site.urls),
912
re_path(r'^', include('codespeed.urls'))
1013
]

0 commit comments

Comments
 (0)