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])