Skip to content

Commit 481828f

Browse files
authored
Merge pull request #81 from mattip/source
disambiguate benchmarks by source
2 parents 35f5f91 + e812104 commit 481828f

9 files changed

Lines changed: 132 additions & 23 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 5.2.13 on 2026-07-17 06:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('codespeed', '0005_benchmark_source_result_suite_version'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='benchmark',
15+
name='name',
16+
field=models.CharField(max_length=100),
17+
),
18+
migrations.AlterUniqueTogether(
19+
name='benchmark',
20+
unique_together={('name', 'source')},
21+
),
22+
]

codespeed/models.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ class Benchmark(models.Model):
181181
('M', 'Median'),
182182
)
183183

184-
name = models.CharField(unique=True, max_length=100)
184+
name = models.CharField(max_length=100)
185185
parent = models.ForeignKey(
186186
'self', on_delete=models.CASCADE, verbose_name="parent",
187187
help_text="allows to group benchmarks in hierarchies",
@@ -195,6 +195,20 @@ class Benchmark(models.Model):
195195
default_on_comparison = models.BooleanField(
196196
"Default on comparison page", default=True)
197197

198+
class Meta:
199+
# The same benchmark name can exist in more than one suite
200+
# (e.g. 'nbody' in both the legacy and pyperformance suites);
201+
# source is part of the identity so results don't get merged.
202+
unique_together = (('name', 'source'),)
203+
204+
def ident(self):
205+
"""Stable identifier used in timeline URLs/permalinks.
206+
207+
A bare name (no ``.source`` suffix) is treated as 'legacy' when
208+
parsed back, so old ``?ben=<name>`` permalinks keep working.
209+
"""
210+
return "%s.%s" % (self.name, self.source)
211+
198212
def __str__(self):
199213
return self.name
200214

codespeed/results.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ def validate_result(item):
3838
elif key in item and item[key] == "":
3939
return 'Value for key "' + key + '" empty in request', error
4040

41+
# source is optional but, when given, must be a known suite. It is part
42+
# of the Benchmark identity, so an unvalidated value would silently
43+
# create a bogus benchmark row via get_or_create.
44+
if 'source' in item and item['source'] not in dict(Benchmark.S_TYPES):
45+
return 'Invalid source "%s"' % item['source'], error
46+
4147
# Check that the Environment exists
4248
try:
4349
e = Environment.objects.get(name=item['environment'])
@@ -58,7 +64,11 @@ def save_result(data, update_repo=True):
5864
p, created = Project.objects.get_or_create(name=data["project"])
5965
branch, created = Branch.objects.get_or_create(name=data["branch"],
6066
project=p)
61-
b, created = Benchmark.objects.get_or_create(name=data["benchmark"])
67+
# source is part of the benchmark identity: the same name in a different
68+
# suite is a distinct benchmark, so results are never merged across suites.
69+
source = data.get("source", "legacy")
70+
b, created = Benchmark.objects.get_or_create(
71+
name=data["benchmark"], source=source)
6272

6373
if created:
6474
if "description" in data:
@@ -69,8 +79,6 @@ def save_result(data, update_repo=True):
6979
b.units_title = data["units_title"]
7080
if "lessisbetter" in data:
7181
b.lessisbetter = data["lessisbetter"]
72-
if "source" in data:
73-
b.source = data["source"]
7482
b.full_clean()
7583
b.save()
7684

codespeed/static/js/codespeed.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ $(function() {
4343

4444
$('.togglefold').each(function() {
4545
var lis = $(this).parent().children("li");
46-
var allUnchecked = lis.find("input[type='checkbox']").filter(':checked').length === 0;
46+
// count radios too (timeline groups use radio inputs, comparison checkboxes)
47+
var allUnchecked = lis.find("input").filter(':checked').length === 0;
4748
if (allUnchecked) {
4849
lis.hide();
4950
$(this).addClass('folded');

codespeed/static/js/timeline.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,15 @@ function setValuesOfInputFields(params) {
605605
});
606606

607607
var benchmark = valueOrDefault(params.ben, defaults.benchmark);
608-
$("input:radio[name='benchmark']").filter("[value='" + benchmark + "']").prop('checked', true);
608+
var benchRadio = $("input:radio[name='benchmark']").filter("[value='" + benchmark + "']");
609+
if (benchRadio.length === 0 && benchmark !== "grid" && benchmark !== "show_none") {
610+
// backwards compat: a bare '<name>' permalink defaults to the legacy suite
611+
benchmark = benchmark + ".legacy";
612+
benchRadio = $("input:radio[name='benchmark']").filter("[value='" + benchmark + "']");
613+
}
614+
benchRadio.prop('checked', true);
615+
// reveal the suite accordion section that holds the selected benchmark
616+
benchRadio.closest("ul").show().children("a.togglefold").removeClass('folded');
609617

610618
var envDefault = (defaults.environments || []).map(String).join(',');
611619
var envIds = valueOrDefault(params.env, envDefault).split(',').filter(Boolean);

codespeed/templates/codespeed/timeline.html

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,16 @@
6565
<label for="b_show_none">Display none</label>
6666
</li>
6767
</ul>{% endif %}
68-
<ul>{% for bench in benchmarks|dictsort:"name" %}
68+
{% regroup benchmarks by get_source_display as benchmark_groups %}
69+
{% for group in benchmark_groups %}
70+
<ul><a href="#" class="togglefold">{{ group.grouper }}</a>
71+
{% for bench in group.list|dictsort:"name" %}
6972
<li title="{{ bench.description }}">
70-
<input id="benchmark_{{ bench.id }}" type="radio" name="benchmark" value="{{ bench.name }}" />
73+
<input id="benchmark_{{ bench.id }}" type="radio" name="benchmark" value="{{ bench.ident }}" />
7174
<label for="benchmark_{{ bench.id }}">{{ bench }}</label>
7275
</li>
7376
{% endfor %}
74-
</ul>
77+
</ul>{% endfor %}
7578
</div>
7679
</div>
7780
</div>
@@ -120,7 +123,7 @@
120123
baseline: "{{ defaultbaseline }}",
121124
executables: [{% for exe in checkedexecutables %}{{ exe.id }}, {% endfor %}],
122125
branches: [{% for b in branch_list %}"{{ branch }}", {% endfor %}],
123-
benchmark: "{{ defaultbenchmark }}",
126+
benchmark: "{{ defaultbenchmark_value }}",
124127
environments: [{% for env in defaultenvironments %}{{ env.id }}, {% endfor %}],
125128
equidistant: "{{ defaultequid }}",
126129
quartiles: "{{ defaultquarts }}",

codespeed/tests/test_views.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,34 @@ def test_source_set_on_new_benchmark(self):
190190
b = Benchmark.objects.get(name='newbench')
191191
self.assertEqual(b.source, 'pyperformance')
192192

193-
def test_source_not_changed_on_existing_benchmark(self):
194-
"""source in the payload should not overwrite an existing Benchmark"""
193+
def test_source_defaults_to_legacy(self):
194+
"""A payload without a source creates a 'legacy' Benchmark"""
195+
self.client.post(self.path, self.data)
196+
b = Benchmark.objects.get(name='float')
197+
self.assertEqual(b.source, 'legacy')
198+
199+
def test_same_name_different_source_are_distinct(self):
200+
"""The same name in a different suite is a separate Benchmark, so
201+
results are not merged across suites."""
195202
self.client.post(self.path, self.data)
196203
modified_data = copy.deepcopy(self.data)
197204
modified_data['source'] = 'pyperformance'
198205
self.client.post(self.path, modified_data)
199-
b = Benchmark.objects.get(name='float')
200-
self.assertEqual(b.source, 'legacy')
206+
207+
legacy = Benchmark.objects.get(name='float', source='legacy')
208+
pyperf = Benchmark.objects.get(name='float', source='pyperformance')
209+
self.assertNotEqual(legacy.pk, pyperf.pk)
210+
# each benchmark owns its own result, nothing merged onto the other
211+
self.assertEqual(legacy.results.count(), 1)
212+
self.assertEqual(pyperf.results.count(), 1)
213+
214+
def test_invalid_source_rejected(self):
215+
"""An unknown source is rejected instead of creating a bogus row"""
216+
modified_data = copy.deepcopy(self.data)
217+
modified_data['source'] = 'bogus'
218+
response = self.client.post(self.path, modified_data)
219+
self.assertEqual(response.status_code, 400)
220+
self.assertFalse(Benchmark.objects.filter(name='float').exists())
201221

202222

203223
@override_settings(ALLOW_ANONYMOUS_POST=True)

codespeed/views.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from .views_data import (get_default_environment, getbaselineexecutables,
2626
getdefaultexecutable, getcomparisonexes,
2727
get_benchmark_results, get_num_revs_and_benchmarks,
28-
get_stats_with_defaults)
28+
get_stats_with_defaults, parse_benchmark_ident)
2929
from .results import save_result, create_report_if_enough_data
3030
from . import commits
3131
from .validators import validate_results_request
@@ -717,7 +717,9 @@ def timeline(request):
717717
baseline = getbaselineexecutables()
718718
defaultbaseline = None
719719
if len(baseline) > 1:
720-
defaultbaseline = str(baseline[1]['executable'].id) + "+"
720+
# must match the option keys built in getbaselineexecutables()
721+
# ("<exe.id>:<rev.id>"), which gettimelinedata splits on ":"
722+
defaultbaseline = str(baseline[1]['executable'].id) + ":"
721723
defaultbaseline += str(baseline[1]['revision'].id)
722724
if "base" in data and data['base'] != "undefined":
723725
try:
@@ -737,7 +739,9 @@ def timeline(request):
737739
lastrevisions.append(revs_int)
738740
defaultlast = revs_int
739741

740-
benchmarks = Benchmark.objects.all()
742+
# order by source so the timeline sidebar can {% regroup %} into
743+
# per-suite accordion sections
744+
benchmarks = Benchmark.objects.all().order_by('source', 'name')
741745

742746
defaultbenchmark = "grid"
743747
if not len(benchmarks):
@@ -748,9 +752,10 @@ def timeline(request):
748752
if settings.DEF_BENCHMARK in ['grid', 'show_none']:
749753
defaultbenchmark = settings.DEF_BENCHMARK
750754
else:
755+
def_name, def_source = parse_benchmark_ident(settings.DEF_BENCHMARK)
751756
try:
752757
defaultbenchmark = Benchmark.objects.get(
753-
name=settings.DEF_BENCHMARK)
758+
name=def_name, source=def_source)
754759
except Benchmark.DoesNotExist:
755760
pass
756761
elif len(benchmarks) >= get_setting('TIMELINE_GRID_LIMIT', 30):
@@ -760,7 +765,9 @@ def timeline(request):
760765
if data['ben'] == "show_none":
761766
defaultbenchmark = data['ben']
762767
else:
763-
defaultbenchmark = get_object_or_404(Benchmark, name=data['ben'])
768+
ben_name, ben_source = parse_benchmark_ident(data['ben'])
769+
defaultbenchmark = get_object_or_404(
770+
Benchmark, name=ben_name, source=ben_source)
764771

765772
if 'equid' in data:
766773
defaultequid = data['equid']
@@ -785,12 +792,19 @@ def timeline(request):
785792
for proj in Project.objects.filter(track=True):
786793
executables[proj] = Executable.objects.filter(project=proj)
787794
use_median_bands = hasattr(settings, 'USE_MEDIAN_BANDS') and settings.USE_MEDIAN_BANDS
795+
# The radio buttons carry 'name.source' idents, so the JS default must
796+
# match that form (the 'grid'/'show_none' sentinels are passed through).
797+
if isinstance(defaultbenchmark, Benchmark):
798+
defaultbenchmark_value = defaultbenchmark.ident()
799+
else:
800+
defaultbenchmark_value = defaultbenchmark
788801
return render(request, 'codespeed/timeline.html', {
789802
'pagedesc': pagedesc,
790803
'checkedexecutables': checkedexecutables,
791804
'defaultbaseline': defaultbaseline,
792805
'baseline': baseline,
793806
'defaultbenchmark': defaultbenchmark,
807+
'defaultbenchmark_value': defaultbenchmark_value,
794808
'defaultenvironment': defaultenviro,
795809
'defaultenvironments': defaultenvironments,
796810
'lastrevisions': lastrevisions,
@@ -921,9 +935,11 @@ def changes(request):
921935
pass
922936

923937
baseline = getbaselineexecutables()
924-
defaultbaseline = "+"
938+
defaultbaseline = "none"
925939
if len(baseline) > 1:
926-
defaultbaseline = str(baseline[1]['executable'].id) + "+"
940+
# must match the "<exe.id>:<rev.id>" option keys from
941+
# getbaselineexecutables()
942+
defaultbaseline = str(baseline[1]['executable'].id) + ":"
927943
defaultbaseline += str(baseline[1]['revision'].id)
928944
if "base" in data and data['base'] != "undefined":
929945
try:

codespeed/views_data.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@
1010
Environment, Benchmark, Result)
1111

1212

13+
def parse_benchmark_ident(ben):
14+
"""Split a timeline ``ben`` value into (name, source).
15+
16+
Accepts ``<name>.<source>`` (e.g. 'nbody.pyperformance') and, for
17+
backwards compatibility, a bare ``<name>`` which defaults to the
18+
'legacy' source. Benchmark names may themselves contain dots, so only
19+
a trailing segment that is a known source slug is treated as the source.
20+
"""
21+
name, _, suffix = ben.rpartition('.')
22+
if name and suffix in dict(Benchmark.S_TYPES):
23+
return name, suffix
24+
return ben, 'legacy'
25+
26+
1327
def get_default_environment(enviros, data, multi=False):
1428
"""Returns the default environment. Preference level is:
1529
* Present in URL parameters (permalinks)
@@ -169,7 +183,8 @@ def get_benchmark_results(data):
169183
project = Project.objects.get(name=data['proj'])
170184
executable = Executable.objects.get(name=data['exe'], project=project)
171185
branch = Branch.objects.get(name=data['branch'], project=project)
172-
benchmark = Benchmark.objects.get(name=data['ben'])
186+
ben_name, ben_source = parse_benchmark_ident(data['ben'])
187+
benchmark = Benchmark.objects.get(name=ben_name, source=ben_source)
173188

174189
number_of_revs = int(data.get('revs', 10))
175190

@@ -252,7 +267,9 @@ def get_num_revs_and_benchmarks(data):
252267
benchmarks = []
253268
number_of_revs = int(data.get('revs', 10))
254269
else:
255-
benchmarks = [get_object_or_404(Benchmark, name=data['ben'])]
270+
ben_name, ben_source = parse_benchmark_ident(data['ben'])
271+
benchmarks = [get_object_or_404(
272+
Benchmark, name=ben_name, source=ben_source)]
256273
number_of_revs = int(data.get('revs', 10))
257274
return number_of_revs, benchmarks
258275

0 commit comments

Comments
 (0)