From 80e2e764fea3032d78b7f26da004a74ac1ede27c Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Tue, 24 Feb 2026 22:13:58 +0530 Subject: [PATCH 1/4] models/runs.py: add tag field to Run table Add an optional metadata string tag to runs, allowing users to associate a tag with a collection of runs. Signed-off-by: Aishwarya Mathuria --- paddles/models/runs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/paddles/models/runs.py b/paddles/models/runs.py index 92d8757..8615af9 100644 --- a/paddles/models/runs.py +++ b/paddles/models/runs.py @@ -110,6 +110,7 @@ class Run(Base): machine_type = Column(String(32), index=True) posted = Column(DateTime, index=True) started = Column(DateTime, index=True) + tag = Column(String(256), index=True, nullable=True) updated = Column(DateTime, index=True) jobs = relationship('Job', backref=backref('run'), @@ -128,8 +129,9 @@ class Run(Base): 'finished fail', ) - def __init__(self, name): + def __init__(self, name, tag=None): self.name = name + self.tag = tag self.posted = datetime.utcnow() parsed_name = self.parse_name() self.user = parsed_name.get('user', '') @@ -154,6 +156,7 @@ def __json__(self): status = self.set_status(results) return dict( name=self.name, + tag=self.tag, href=self.href, user=self.user, status=status, From 562bbaf102273d960d214c9b433620f3eca9776f Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Tue, 24 Feb 2026 22:14:04 +0530 Subject: [PATCH 2/4] controllers/runs.py: add tag filtering and TagsController Support tag in run creation (POST) and query-param filtering (?tag=foo). Add TagsController/TagController for path-based access (/runs/tag/foo/) and wire tag into all existing filter controllers for composable queries. Signed-off-by: Aishwarya Mathuria --- paddles/controllers/runs.py | 75 ++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/paddles/controllers/runs.py b/paddles/controllers/runs.py index a3cdd93..6966f81 100644 --- a/paddles/controllers/runs.py +++ b/paddles/controllers/runs.py @@ -15,8 +15,11 @@ date_format = '%Y-%m-%d' -def latest_runs(fields=None, count=conf.default_latest_runs_count, page=1): - query = Run.query.order_by(Run.posted.desc()) +def latest_runs(fields=None, count=conf.default_latest_runs_count, page=1, tag=None): + query = Run.query + if tag: + query = query.filter(Run.tag == tag) + query = query.order_by(Run.posted.desc()) query = offset_query(query, page_size=count, page=page) runs = list(query) if fields: @@ -150,6 +153,14 @@ def get_lookup_controller(self): return SuiteController +class TagsController(RunFilterIndexController): + def get_subquery(self, query): + return query.values(Run.tag) + + def get_lookup_controller(self): + return TagController + + class UsersController(RunFilterIndexController): def get_subquery(self, query): return query.values(Run.user) @@ -202,6 +213,8 @@ def get_lookup_controller(self, field): return StatusesController() if field == 'suite': return SuitesController() + if field == 'tag': + return TagsController() if field == 'user': return UsersController() if field == 'flavor': @@ -232,6 +245,8 @@ def get_lookup_controller(self, field): return Sha1sController() if field == 'suite': return SuitesController() + if field == 'tag': + return TagsController() if field == 'user': return UsersController() if field == 'flavor': @@ -253,6 +268,8 @@ def get_lookup_controller(self, field): return Sha1sController() if field == 'suite': return SuitesController() + if field == 'tag': + return TagsController() if field == 'user': return UsersController() if field == 'flavor': @@ -274,6 +291,8 @@ def get_lookup_controller(self, field): return Sha1sController() if field == 'suite': return SuitesController() + if field == 'tag': + return TagsController() if field == 'user': return UsersController() if field == 'flavor': @@ -295,6 +314,31 @@ def get_lookup_controller(self, field): return Sha1sController() if field == 'status': return StatusesController() + if field == 'tag': + return TagsController() + if field == 'user': + return UsersController() + if field == 'flavor': + return FlavorsController() + + +class TagController(RunFilterController): + def get_subquery(self, query): + return query.filter(Run.tag == self.value) + + def get_lookup_controller(self, field): + if field == 'branch': + return BranchesController() + if field == 'date': + return DatesController() + if field == 'machine_type': + return MachineTypesController() + if field == 'sha1': + return Sha1sController() + if field == 'status': + return StatusesController() + if field == 'suite': + return SuitesController() if field == 'user': return UsersController() if field == 'flavor': @@ -318,6 +362,8 @@ def get_lookup_controller(self, field): return StatusesController() if field == 'suite': return SuitesController() + if field == 'tag': + return TagsController() if field == 'flavor': return FlavorsController() @@ -339,6 +385,8 @@ def get_lookup_controller(self, field): return StatusesController() if field == 'suite': return SuitesController() + if field == 'tag': + return TagsController() if field == 'user': return UsersController() @@ -393,6 +441,8 @@ def get_lookup_controller(self, field): return StatusesController() if field == 'suite': return SuitesController() + if field == 'tag': + return TagsController() if field == 'user': return UsersController() if field == 'flavor': @@ -402,31 +452,36 @@ def get_lookup_controller(self, field): class RunsController(object): @expose(generic=True, template='json') - def index(self, fields='', count=conf.default_latest_runs_count, page=1): - return latest_runs(fields=fields, count=count, page=page) + def index(self, fields='', count=conf.default_latest_runs_count, page=1, tag=None): + return latest_runs(fields=fields, count=count, page=page, tag=tag) @index.when(method='POST', template='json') def index_post(self): - # save to DB here try: name = request.json.get('name') + # 1. Grab the tag from the incoming JSON + tag = request.json.get('tag') except ValueError: rollback() error('/errors/invalid/', 'could not decode JSON body') + if not name: error('/errors/invalid/', "could not find required key: 'name'") + if not Run.query.filter_by(name=name).first(): - self._create_run(name) + # 2. Pass the tag to the internal creator + self._create_run(name, tag=tag) return dict() else: error('/errors/invalid/', "run with name %s already exists" % name) @classmethod @retryOperation - def _create_run(cls, name): - log.info("Creating run: %s", name) + def _create_run(cls, name, tag=None): + # 3. Log it and pass it to the Run model __init__ + log.info("Creating run: %s with tag: %r", name, tag) Session.flush() - return Run(name) + return Run(name, tag=tag) branch = BranchesController() @@ -438,6 +493,8 @@ def _create_run(cls, name): suite = SuitesController() + tag = TagsController() + queued = QueuedRunsController() sha1 = Sha1sController() From 08b50926b8a0acfd19c382fee868202f189b340e Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Tue, 24 Feb 2026 22:14:10 +0530 Subject: [PATCH 3/4] tests: add tests for run tag feature Signed-off-by: Aishwarya Mathuria --- paddles/tests/controllers/test_runs.py | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/paddles/tests/controllers/test_runs.py b/paddles/tests/controllers/test_runs.py index c9867d9..870a74b 100644 --- a/paddles/tests/controllers/test_runs.py +++ b/paddles/tests/controllers/test_runs.py @@ -222,6 +222,39 @@ def test_queued_partially(self): result_names = sorted([r['name'] for r in result]) assert result_names == sorted(run_names) + def test_create_run_with_tag(self): + self.app.post_json('/runs/', dict(name="tagged_run", tag="squid")) + new_run = Run.get(1) + assert new_run.tag == 'squid' + + def test_create_run_without_tag(self): + self.app.post_json('/runs/', dict(name="untagged_run")) + new_run = Run.get(1) + assert new_run.tag is None + + def test_filter_by_tag_query_param(self): + self.app.post_json('/runs/', dict(name="run_a", tag="alpha")) + self.app.post_json('/runs/', dict(name="run_b", tag="beta")) + self.app.post_json('/runs/', dict(name="run_c")) + response = self.app.get('/runs/?tag=alpha') + assert len(response.json) == 1 + assert response.json[0]['name'] == 'run_a' + + def test_filter_by_tag_url_path(self): + self.app.post_json('/runs/', dict(name="run_a", tag="alpha")) + self.app.post_json('/runs/', dict(name="run_b", tag="beta")) + self.app.post_json('/runs/', dict(name="run_c")) + response = self.app.get('/runs/tag/alpha/') + assert len(response.json) == 1 + assert response.json[0]['name'] == 'run_a' + + def test_list_tags(self): + self.app.post_json('/runs/', dict(name="run_a", tag="alpha")) + self.app.post_json('/runs/', dict(name="run_b", tag="beta")) + self.app.post_json('/runs/', dict(name="run_c")) + response = self.app.get('/runs/tag/') + assert sorted(response.json) == ['alpha', 'beta'] + class TestRunControllerDateFilters(TestApp): From c3bb3308a3bbc42b3dbfcc57fae7582534b4e9b0 Mon Sep 17 00:00:00 2001 From: Aishwarya Mathuria Date: Thu, 2 Apr 2026 21:50:13 +0530 Subject: [PATCH 4/4] runs: Store tags[] and allow updating runs Replace Run.tag with Run.tags (JSONType list) and add PUT /runs// to update tags. Update tag filtering/listing to work with tag arrays, including comma-separated ?tag= We have to add a cast to UnicodeText for filtering to avoid JSONType bind-param JSON encoding. Signed-off-by: Aishwarya Mathuria --- paddles/controllers/runs.py | 64 +++++++++++++--- paddles/models/runs.py | 9 ++- paddles/tests/controllers/test_runs.py | 101 +++++++++++++++++++++---- paddles/tests/models/test_runs.py | 22 ++++++ 4 files changed, 166 insertions(+), 30 deletions(-) diff --git a/paddles/controllers/runs.py b/paddles/controllers/runs.py index 6966f81..3c5f7c9 100644 --- a/paddles/controllers/runs.py +++ b/paddles/controllers/runs.py @@ -1,6 +1,6 @@ import logging import datetime -from sqlalchemy import Date, cast +from sqlalchemy import Date, cast, UnicodeText from sqlalchemy.exc import InvalidRequestError, OperationalError from pecan import abort, conf, expose, request @@ -18,7 +18,13 @@ def latest_runs(fields=None, count=conf.default_latest_runs_count, page=1, tag=None): query = Run.query if tag: - query = query.filter(Run.tag == tag) + # JSONType uses process_bind_param (json.dumps) for bind params and + # process_result_value (json.loads) when reading rows. The read path + # is fine; the filter RHS here is a bind param, so it would be + # JSON-encoded unless we compare as plain text: + tags_text = cast(Run.tags, UnicodeText) + for t in tag.split(','): + query = query.filter(tags_text.contains('"%s"' % t.strip())) query = query.order_by(Run.posted.desc()) query = offset_query(query, page_size=count, page=page) runs = list(query) @@ -74,6 +80,26 @@ def index(self): json_run['jobs'] = self.run.get_jobs() return json_run + @index.when(method='PUT', template='json') + def index_put(self): + if not self.run: + error('/errors/not_found/', + 'attempted to update a non-existent run') + try: + data = request.json + except ValueError: + rollback() + error('/errors/invalid/', 'could not decode JSON body') + + if 'tags' in data: + tags = data['tags'] + if not isinstance(tags, list): + error('/errors/invalid/', "'tags' must be a list of strings") + self.run.tags = tags + + log.info("Updated run: %r", self.run) + return {} + @index.when(method='DELETE', template='json') def index_delete(self): if not self.run: @@ -155,7 +181,18 @@ def get_lookup_controller(self): class TagsController(RunFilterIndexController): def get_subquery(self, query): - return query.values(Run.tag) + return query.values(Run.tags) + + @expose('json') + @retryOperation + def index(self): + query = request.context.get('query', Run.query) + all_tags = set() + for (tags_value,) in self.get_subquery(query): + if tags_value: + for tag in tags_value: + all_tags.add(tag) + return sorted(all_tags) def get_lookup_controller(self): return TagController @@ -324,7 +361,10 @@ def get_lookup_controller(self, field): class TagController(RunFilterController): def get_subquery(self, query): - return query.filter(Run.tag == self.value) + # Same as latest_runs: compare as UnicodeText so the filter bind param + # is not run through JSONType.process_bind_param (json.dumps). + return query.filter( + cast(Run.tags, UnicodeText).contains('"%s"' % self.value)) def get_lookup_controller(self, field): if field == 'branch': @@ -459,8 +499,7 @@ def index(self, fields='', count=conf.default_latest_runs_count, page=1, tag=Non def index_post(self): try: name = request.json.get('name') - # 1. Grab the tag from the incoming JSON - tag = request.json.get('tag') + tags = request.json.get('tags') except ValueError: rollback() error('/errors/invalid/', 'could not decode JSON body') @@ -468,20 +507,21 @@ def index_post(self): if not name: error('/errors/invalid/', "could not find required key: 'name'") + if tags is not None and not isinstance(tags, list): + error('/errors/invalid/', "'tags' must be a list of strings") + if not Run.query.filter_by(name=name).first(): - # 2. Pass the tag to the internal creator - self._create_run(name, tag=tag) + self._create_run(name, tags=tags) return dict() else: error('/errors/invalid/', "run with name %s already exists" % name) @classmethod @retryOperation - def _create_run(cls, name, tag=None): - # 3. Log it and pass it to the Run model __init__ - log.info("Creating run: %s with tag: %r", name, tag) + def _create_run(cls, name, tags=None): + log.info("Creating run: %s with tags: %r", name, tags) Session.flush() - return Run(name, tag=tag) + return Run(name, tags=tags) branch = BranchesController() diff --git a/paddles/models/runs.py b/paddles/models/runs.py index 8615af9..00fc906 100644 --- a/paddles/models/runs.py +++ b/paddles/models/runs.py @@ -8,6 +8,7 @@ from paddles.util import local_datetime_to_utc from paddles.models import Base, TEUTHOLOGY_TIMESTAMP_FMT from paddles.models.jobs import Job +from paddles.models.types import JSONType suite_names = ['big', 'ceph-deploy', @@ -110,7 +111,7 @@ class Run(Base): machine_type = Column(String(32), index=True) posted = Column(DateTime, index=True) started = Column(DateTime, index=True) - tag = Column(String(256), index=True, nullable=True) + tags = Column(JSONType(), nullable=True) updated = Column(DateTime, index=True) jobs = relationship('Job', backref=backref('run'), @@ -129,9 +130,9 @@ class Run(Base): 'finished fail', ) - def __init__(self, name, tag=None): + def __init__(self, name, tags=None): self.name = name - self.tag = tag + self.tags = tags or [] self.posted = datetime.utcnow() parsed_name = self.parse_name() self.user = parsed_name.get('user', '') @@ -156,7 +157,7 @@ def __json__(self): status = self.set_status(results) return dict( name=self.name, - tag=self.tag, + tags=self.tags or [], href=self.href, user=self.user, status=status, diff --git a/paddles/tests/controllers/test_runs.py b/paddles/tests/controllers/test_runs.py index 870a74b..80af12a 100644 --- a/paddles/tests/controllers/test_runs.py +++ b/paddles/tests/controllers/test_runs.py @@ -222,38 +222,111 @@ def test_queued_partially(self): result_names = sorted([r['name'] for r in result]) assert result_names == sorted(run_names) - def test_create_run_with_tag(self): - self.app.post_json('/runs/', dict(name="tagged_run", tag="squid")) + def test_create_run_with_tags(self): + self.app.post_json('/runs/', dict(name="tagged_run", + tags=["squid"])) new_run = Run.get(1) - assert new_run.tag == 'squid' + assert new_run.tags == ['squid'] - def test_create_run_without_tag(self): + def test_create_run_with_multiple_tags(self): + self.app.post_json('/runs/', dict(name="multi_tag", + tags=["squid", "tracker-123"])) + new_run = Run.get(1) + assert sorted(new_run.tags) == ['squid', 'tracker-123'] + + def test_create_run_without_tags(self): self.app.post_json('/runs/', dict(name="untagged_run")) new_run = Run.get(1) - assert new_run.tag is None + assert new_run.tags == [] + + def test_create_run_tags_must_be_list(self): + response = self.app.post_json('/runs/', + dict(name="bad", tags="not-a-list"), + expect_errors=True) + assert response.status_int == 400 + + def test_tags_in_json_response(self): + self.app.post_json('/runs/', dict(name="foo", + tags=["reef", "squid"])) + response = self.app.get('/runs/') + assert sorted(response.json[0]['tags']) == ['reef', 'squid'] def test_filter_by_tag_query_param(self): - self.app.post_json('/runs/', dict(name="run_a", tag="alpha")) - self.app.post_json('/runs/', dict(name="run_b", tag="beta")) + self.app.post_json('/runs/', dict(name="run_a", tags=["reef"])) + self.app.post_json('/runs/', dict(name="run_b", tags=["squid"])) self.app.post_json('/runs/', dict(name="run_c")) - response = self.app.get('/runs/?tag=alpha') + response = self.app.get('/runs/?tag=reef') + assert len(response.json) == 1 + assert response.json[0]['name'] == 'run_a' + + def test_filter_by_tag_query_param_multi(self): + self.app.post_json('/runs/', dict(name="run_a", + tags=["reef", "squid"])) + self.app.post_json('/runs/', dict(name="run_b", tags=["squid"])) + self.app.post_json('/runs/', dict(name="run_c", tags=["tentacle"])) + response = self.app.get('/runs/?tag=squid') + assert len(response.json) == 2 + got_names = sorted(r['name'] for r in response.json) + assert got_names == ['run_a', 'run_b'] + + def test_filter_by_comma_separated_tags(self): + self.app.post_json('/runs/', dict(name="run_a", + tags=["reef", "squid"])) + self.app.post_json('/runs/', dict(name="run_b", tags=["squid"])) + self.app.post_json('/runs/', dict(name="run_c", tags=["tentacle"])) + response = self.app.get('/runs/?tag=reef,squid') assert len(response.json) == 1 assert response.json[0]['name'] == 'run_a' def test_filter_by_tag_url_path(self): - self.app.post_json('/runs/', dict(name="run_a", tag="alpha")) - self.app.post_json('/runs/', dict(name="run_b", tag="beta")) + self.app.post_json('/runs/', dict(name="run_a", tags=["reef"])) + self.app.post_json('/runs/', dict(name="run_b", tags=["squid"])) self.app.post_json('/runs/', dict(name="run_c")) - response = self.app.get('/runs/tag/alpha/') + response = self.app.get('/runs/tag/reef/') assert len(response.json) == 1 assert response.json[0]['name'] == 'run_a' + def test_filter_by_tag_url_path_multi(self): + self.app.post_json('/runs/', dict(name="run_a", + tags=["reef", "tentacle"])) + self.app.post_json('/runs/', dict(name="run_b", + tags=["squid", "tentacle"])) + response = self.app.get('/runs/tag/tentacle/') + assert len(response.json) == 2 + def test_list_tags(self): - self.app.post_json('/runs/', dict(name="run_a", tag="alpha")) - self.app.post_json('/runs/', dict(name="run_b", tag="beta")) + self.app.post_json('/runs/', dict(name="run_a", + tags=["reef", "tentacle"])) + self.app.post_json('/runs/', dict(name="run_b", + tags=["squid", "tentacle"])) self.app.post_json('/runs/', dict(name="run_c")) response = self.app.get('/runs/tag/') - assert sorted(response.json) == ['alpha', 'beta'] + assert sorted(response.json) == ['reef', 'squid', 'tentacle'] + + def test_put_tags(self): + self.app.post_json('/runs/', dict(name="foo")) + self.app.put_json('/runs/foo/', dict(tags=["new-tag"])) + response = self.app.get('/runs/foo/') + assert response.json['tags'] == ['new-tag'] + + def test_put_tags_append(self): + self.app.post_json('/runs/', dict(name="foo", tags=["old"])) + self.app.put_json('/runs/foo/', dict(tags=["old", "new"])) + response = self.app.get('/runs/foo/') + assert sorted(response.json['tags']) == ['new', 'old'] + + def test_put_tags_must_be_list(self): + self.app.post_json('/runs/', dict(name="foo")) + response = self.app.put_json('/runs/foo/', + dict(tags="not-a-list"), + expect_errors=True) + assert response.status_int == 400 + + def test_put_nonexistent_run(self): + response = self.app.put_json('/runs/nope/', + dict(tags=["x"]), + expect_errors=True) + assert response.status_int == 400 class TestRunControllerDateFilters(TestApp): diff --git a/paddles/tests/models/test_runs.py b/paddles/tests/models/test_runs.py index 4a4efb7..4c696e2 100644 --- a/paddles/tests/models/test_runs.py +++ b/paddles/tests/models/test_runs.py @@ -255,3 +255,25 @@ def test_run_flavor(self): Job(dict(job_id=1, id=1, status='queued', flavor="blah"), new_run) run_result = new_run.get_results() assert run_result.get('flavor') == "blah" + + def test_run_tags(self): + new_run = Run("run_with_tags", tags=["tracker-456"]) + assert new_run.tags == ["tracker-456"] + + def test_run_multiple_tags(self): + new_run = Run("run_multi_tags", tags=["alpha", "beta"]) + assert sorted(new_run.tags) == ["alpha", "beta"] + + def test_run_tags_default_empty(self): + new_run = Run("run_without_tags") + assert new_run.tags == [] + + def test_run_tags_in_json(self): + new_run = Run("run_tags_json", tags=["my-tag"]) + run_json = new_run.__json__() + assert run_json['tags'] == ["my-tag"] + + def test_run_tags_empty_in_json(self): + new_run = Run("run_tags_empty_json") + run_json = new_run.__json__() + assert run_json['tags'] == []