fix lint error Created using spr 1.3.7
diff --git a/lnt/lnttool/__init__.py b/lnt/lnttool/__init__.py index f93a9c1..a949161 100644 --- a/lnt/lnttool/__init__.py +++ b/lnt/lnttool/__init__.py
@@ -13,6 +13,7 @@ from .runtest import group_runtest from .send_daily_report import action_send_daily_report from .send_run_comparison import action_send_run_comparison +from .expire_abtests import action_expire_abtests from .showtests import action_showtests from .submit import action_submit from .updatedb import action_updatedb @@ -45,6 +46,7 @@ main.add_command(action_checkformat) main.add_command(action_convert) main.add_command(action_create) +main.add_command(action_expire_abtests) main.add_command(action_import) main.add_command(action_importreport) main.add_command(action_profile)
diff --git a/lnt/lnttool/expire_abtests.py b/lnt/lnttool/expire_abtests.py new file mode 100644 index 0000000..33c369d --- /dev/null +++ b/lnt/lnttool/expire_abtests.py
@@ -0,0 +1,93 @@ +import contextlib +import datetime +import re + +import click + + +def _parse_age(value): + """Parse an age string like '90d', '4w', '6m', '1y' into a cutoff datetime.""" + m = re.fullmatch(r'(\d+)([dwmy])', value) + if not m: + raise click.BadParameter( + "expected a positive integer followed by d/w/m/y " + "(e.g. 90d, 4w, 6m, 1y)", + param_hint="'--older-than'") + n, unit = int(m.group(1)), m.group(2) + days = {'d': n, 'w': n * 7, 'm': n * 30, 'y': n * 365}[unit] + return datetime.datetime.utcnow() - datetime.timedelta(days=days) + + +@click.command("expire-abtests") +@click.argument("instance_path", type=click.UNPROCESSED) +@click.option("--database", default="default", show_default=True, + help="database to expire experiments from") +@click.option("--testsuite", "-s", default="nts", show_default=True, + help="testsuite to expire experiments from") +@click.option("--older-than", "older_than", required=True, + help="delete experiments older than this age (e.g. 90d, 4w, 6m, 1y)") +@click.option("--dry-run", is_flag=True, + help="print what would be deleted without making any changes") +def action_expire_abtests(instance_path, database, testsuite, + older_than, dry_run): + """Delete unpinned A/B experiments older than a given age. + +\b +Removes unpinned ABExperiment records (and their associated ABRun and +ABSample rows) whose creation time predates the specified age threshold. +Pinned ('Keep Forever') experiments are never deleted. + +\b +Age format: a positive integer followed by a unit: + d days (e.g. 90d) + w weeks (e.g. 4w) + m months (approx 30 days each, e.g. 6m) + y years (approx 365 days each, e.g. 1y) + """ + import lnt.server.instance + + cutoff = _parse_age(older_than) + + instance = lnt.server.instance.Instance.frompath(instance_path) + with contextlib.closing(instance.get_database(database)) as db: + session = db.make_session() + ts = db.testsuite[testsuite] + + to_delete = ( + session.query(ts.ABExperiment) + .filter(ts.ABExperiment.created_time < cutoff, + ts.ABExperiment.pinned == False) # noqa: E712 + .all()) + + if not to_delete: + click.echo("No experiments to delete.") + return + + for exp in to_delete: + click.echo("%s experiment #%d: %s" % ( + "Would delete" if dry_run else "Deleting", + exp.id, exp.name or "(unnamed)")) + + if dry_run: + return + + # Delete ABSample and ABRun children before the ABExperiment rows to + # respect foreign-key constraints. + run_ids = [rid for exp in to_delete + for rid in (exp.control_run_id, exp.variant_run_id) + if rid is not None] + + session.query(ts.ABSample) \ + .filter(ts.ABSample.run_id.in_(run_ids)) \ + .delete(synchronize_session=False) + + session.query(ts.ABRun) \ + .filter(ts.ABRun.id.in_(run_ids)) \ + .delete(synchronize_session=False) + + for exp in to_delete: + session.delete(exp) + + session.commit() + click.echo("Deleted %d experiment%s." % + (len(to_delete), "s" if len(to_delete) != 1 else ""))
diff --git a/tests/server/ui/test_expire_abtests.py b/tests/server/ui/test_expire_abtests.py new file mode 100644 index 0000000..285a825 --- /dev/null +++ b/tests/server/ui/test_expire_abtests.py
@@ -0,0 +1,131 @@ +# Check lnt expire-abtests command. +# RUN: rm -rf %t.instance +# RUN: python %{shared_inputs}/create_temp_instance.py \ +# RUN: %s %{shared_inputs}/SmallInstance \ +# RUN: %t.instance %S/Inputs/V4Pages_extra_records.sql +# +# RUN: python %s %t.instance + +import contextlib +import datetime +import json +import sys +import unittest + +import lnt.server.db.migrate +import lnt.server.instance +import lnt.server.ui.app +from click.testing import CliRunner +from lnt.lnttool.expire_abtests import action_expire_abtests + +AUTH_TOKEN = 'test_token' +BASE_URL = 'api/db_default/v4/nts/' + +CONTROL_DATA = { + 'machine': {'name': 'apple-m2-macmini', + 'hardware': 'arm64', 'os': 'macosx14.0'}, + 'run': {'start_time': '2024-01-01T00:00:00', + 'end_time': '2024-01-01T00:05:00'}, + 'tests': [{'name': 'CTMark/sqlite3/sqlite3.compile', + 'compile_time': 1.0}], +} + +VARIANT_DATA = { + 'machine': {'name': 'apple-m2-macmini', + 'hardware': 'arm64', 'os': 'macosx14.0'}, + 'run': {'start_time': '2024-01-01T00:10:00', + 'end_time': '2024-01-01T00:15:00'}, + 'tests': [{'name': 'CTMark/sqlite3/sqlite3.compile', + 'compile_time': 1.05}], +} + + +class ExpireABTestsTest(unittest.TestCase): + def setUp(self): + _, self.instance_path = sys.argv + app = lnt.server.ui.app.App.create_standalone(self.instance_path) + app.testing = True + self.client = app.test_client() + + def _create_exp(self, name, pinned=False): + body = {'name': name, 'pinned': pinned, + 'control': CONTROL_DATA, 'variant': VARIANT_DATA} + resp = self.client.post(BASE_URL + 'abtest', + data=json.dumps(body), + content_type='application/json', + headers={'AuthToken': AUTH_TOKEN}) + self.assertEqual(resp.status_code, 201, + "POST abtest returned %d: %s" % + (resp.status_code, resp.data)) + return json.loads(resp.data)['id'] + + def _set_created_time(self, exp_id, dt): + """Back-date an experiment's created_time directly in the DB.""" + instance = lnt.server.instance.Instance.frompath(self.instance_path) + with contextlib.closing(instance.get_database('default')) as db: + session = db.make_session() + ts = db.testsuite['nts'] + exp = session.query(ts.ABExperiment).filter_by(id=exp_id).one() + exp.created_time = dt + session.commit() + + def _invoke(self, *extra_args): + runner = CliRunner() + return runner.invoke( + action_expire_abtests, + [self.instance_path, '--testsuite', 'nts'] + list(extra_args)) + + # ------------------------------------------------------------------ # + + def test_01_expire_only_old_unpinned(self): + """Only old unpinned experiments are deleted; recent and pinned survive.""" + now = datetime.datetime.utcnow() + old_id = self._create_exp('old-unpinned') + recent_id = self._create_exp('recent-unpinned') + pinned_id = self._create_exp('old-pinned', pinned=True) + self._set_created_time(old_id, now - datetime.timedelta(days=120)) + self._set_created_time(recent_id, now - datetime.timedelta(days=10)) + self._set_created_time(pinned_id, now - datetime.timedelta(days=120)) + + result = self._invoke('--older-than', '90d') + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn('Deleted 1 experiment', result.output) + self.assertIn('old-unpinned', result.output) + + # old-unpinned is gone. + self.assertEqual( + self.client.get(BASE_URL + 'abtest/%d' % old_id).status_code, 404) + # recent and pinned are still present. + self.assertEqual( + self.client.get(BASE_URL + 'abtest/%d' % recent_id).status_code, 200) + self.assertEqual( + self.client.get(BASE_URL + 'abtest/%d' % pinned_id).status_code, 200) + + def test_02_dry_run_leaves_experiments_intact(self): + """--dry-run reports what would be deleted but changes nothing.""" + now = datetime.datetime.utcnow() + exp_id = self._create_exp('dry-run-target') + self._set_created_time(exp_id, now - datetime.timedelta(days=200)) + + result = self._invoke('--older-than', '90d', '--dry-run') + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn('Would delete', result.output) + self.assertIn('dry-run-target', result.output) + # Experiment still exists. + self.assertEqual( + self.client.get(BASE_URL + 'abtest/%d' % exp_id).status_code, 200) + + def test_03_nothing_to_delete(self): + """When nothing qualifies, a suitable message is printed.""" + result = self._invoke('--older-than', '1y') + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn('No experiments to delete', result.output) + + def test_04_bad_age_format_exits_nonzero(self): + """An unrecognised age string causes a non-zero exit.""" + result = self._invoke('--older-than', 'banana') + self.assertNotEqual(result.exit_code, 0) + + +if __name__ == '__main__': + unittest.main(argv=sys.argv[:1])