From 5e015a34c94f761a1d342237cdeec87b80f838fa Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 25 Jun 2026 23:53:50 +0200 Subject: [PATCH 1/3] feat(remove-outputs): tag-scoped retention for limit and age (issue #309) Make `limit` and `age` respect a `tags` filter so housekeeping can be scoped to a subset of runs instead of acting globally: - limit + tags -> keep the N newest runs carrying any of the given tag(s), remove older matching runs, leave untagged runs untouched. - age + tags -> remove only tagged runs within the age range, leave untagged runs untouched (supports both older `10d` and younger `-10d`). CLI combination syntax uses the regular comma separator, e.g. `-r "limit=10,tag=nightly"` / `-r "age=10d,tag=nightly,tag=prod"`. arguments.py detects the combination, validates it, and rewrites it into the internal `limit=N;tag=...` / `age=X;tag=...` form (the legacy `;` form still works). Rules: - tags may combine with EITHER limit OR age, not both (error). - only one limit/age may be combined with tags (error). - a clear INFO line explains exactly what will be deleted; when independent options (index/alias/run_start) are also present the deletion order is printed. Server `/remove-outputs` scopes the same way when `tags` is sent with `limit` and/or `age`, and rejects the limit+age+tags combination before removing anything. Backward compatible: plain limit/age/tag (no partner) behave exactly as before; the elif dispatch in database.py is reordered so combined queries route to the scoped handler. Docs (CLI + server), OpenAPI examples, and the admin UI labels updated. Also documents the previously under-documented negative-age (`-10d`) behavior in the docs. --- docs/basic-command-line-interface-cli.md | 14 +- docs/dashboard-server.md | 3 + robotframework_dashboard/arguments.py | 99 +++++++++++++- robotframework_dashboard/database.py | 66 ++++++++-- robotframework_dashboard/server.py | 41 +++++- robotframework_dashboard/templates/admin.html | 5 +- tests/python/test_arguments.py | 66 ++++++++++ tests/python/test_database.py | 124 ++++++++++++++++++ tests/python/test_server.py | 47 +++++++ 9 files changed, 439 insertions(+), 26 deletions(-) diff --git a/docs/basic-command-line-interface-cli.md b/docs/basic-command-line-interface-cli.md index f2e005e3..a444bea6 100644 --- a/docs/basic-command-line-interface-cli.md +++ b/docs/basic-command-line-interface-cli.md @@ -114,8 +114,12 @@ robotdashboard -r index=0,index=1:4;9,index=10 robotdashboard --removeruns 'run_start=2024-07-30 15:27:20.184407,index=20' robotdashboard -r alias=some_cool_alias,tag=prod,tag=dev -r alias=alias12345 robotdashboard -r limit=10 -robotdashboard -r age=10d # (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported -robotdashboard -r age=-10d +robotdashboard -r limit=10,tag=nightly # keep 10 newest 'nightly' runs, leave others +robotdashboard -r limit=10,tag=nightly,tag=prod # scope to multiple tags +robotdashboard -r age=10d # remove runs OLDER than 10 days. (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported +robotdashboard -r age=-10d # remove runs YOUNGER than 10 days (note the leading minus) +robotdashboard -r age=10d,tag=nightly # remove 'nightly' runs older than 10 days, leave others +robotdashboard -r age=10d,tag=nightly,tag=prod # scope to multiple tags # Log data of removed runs in jsonl robotdashboard -r limit=10 --logremoved "/myLogDir/removedRuns.jsonl" robotdashboard -r limit=10 --logremoved "run:suite:/myLogDir/removedRuns.jsonl" @@ -128,8 +132,12 @@ robotdashboard -r limit=10 --logremoved run:keyword - Index ranges use `:` for ranges and `;` for lists. - Quotation marks are required when spaces exist in identifiers. - With limit=10 only the 10 most recent runs will be kept, all others will be removed. +- With limit=10,tag=nightly only the 10 most recent runs **carrying that tag** are kept; older tagged runs are removed and runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags. - With age=10d only runs _**older**_ than 10 days will be removed -- With age=-10d only runs _**younger**_ than 10 days will be removed +- With age=-10d (leading minus) only runs _**younger**_ than 10 days will be removed +- Supported age units: (y)ear, (d)ay, (h)our, (m)inute, (s)econd — e.g. `age=12h`, `age=-30m` +- With age=10d,tag=nightly only runs **carrying that tag** older than 10 days are removed; runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags. +- `tag` can be combined with **either** `limit` **or** `age` (not both at once — that is an error). The combination is detected automatically and an `INFO` line explains exactly what will be deleted; if other options (index/alias/run_start) are present too, the deletion order is printed. - Optional: `--logremoved` logs run data to a `.jsonl` file before removal. - Format: `[types:]path` where types are colon-separated from `run`, `suite`, `test`, `keyword`, `all`. - If no types are specified, defaults to `all` (runs, suites, tests and keywords). diff --git a/docs/dashboard-server.md b/docs/dashboard-server.md index 3a4118f0..7c909ba3 100644 --- a/docs/dashboard-server.md +++ b/docs/dashboard-server.md @@ -161,6 +161,9 @@ The admin page supports four methods for adding test results: | **By Alias** | Comma-separated alias names. | | **By Tag** | Comma-separated tags — removes all runs matching any of the specified tags. | | **By Limit** | Keep only the N most recent runs; all older runs are deleted. | +| **By Limit + Tag(s)** | Supply `limit` together with `tags` to scope the limit: the N most recent runs matching any given tag are kept, older matching runs are deleted, and runs without those tags are left untouched (e.g. `{"limit": 10, "tags": ["nightly"]}`). | +| **By Age** | Remove runs by age threshold. `"10d"` removes runs **older** than 10 days; a leading minus `"-10d"` removes runs **younger** than 10 days. Units: (y)ear, (d)ay, (h)our, (m)inute, (s)econd. | +| **By Age + Tag(s)** | Supply `age` together with `tags` to scope the age threshold: only runs matching any given tag within the age range are deleted, runs without those tags are left untouched (e.g. `{"age": "10d", "tags": ["nightly"]}`). | | **Remove All** | Irreversibly deletes all runs from the database. | > **Note:** When a run is removed, the server automatically checks whether a corresponding log file exists in the `robot_logs/` folder (derived by replacing `output` → `log` and `.xml` → `.html` in the stored path). If found, it is deleted alongside the run. The console response will confirm whether a log was removed or note that none was found. diff --git a/robotframework_dashboard/arguments.py b/robotframework_dashboard/arguments.py index 4db71db6..59590813 100644 --- a/robotframework_dashboard/arguments.py +++ b/robotframework_dashboard/arguments.py @@ -69,6 +69,92 @@ def _check_project_version_usage(self, tags, arguments): print(" ERROR: Mixing --projectversion and version_ tags not supported") exit(2) + def _process_remove_runs(self, parts): + """Process and validate the comma-separated --removeruns parts. + + Detects a scoped retention combination ('limit'+'tags' or 'age'+'tags') + and rewrites it into the internal 'limit=N;tag=...' / 'age=X;tag=...' + form so the retention acts on the tagged subset instead of running as + independent operations. This lets users combine options with the regular + comma separator (e.g. '-r "limit=10,tag=nightly"') instead of ';'. + + Rules: + - 'tags' may be combined with EITHER 'limit' OR 'age', not both (error). + - Only one 'limit'/'age' may be combined with tags (error otherwise). + - When a combination is detected an INFO message explains exactly what + will be deleted; if other options are also present, the deletion order + is printed too. + """ + limit_parts = [p for p in parts if p.startswith("limit=")] + age_parts = [p for p in parts if p.startswith("age=")] + tag_parts = [p for p in parts if p.startswith("tag=")] + other_parts = [ + p + for p in parts + if not ( + p.startswith("limit=") + or p.startswith("age=") + or p.startswith("tag=") + ) + ] + has_limit, has_age, has_tag = bool(limit_parts), bool(age_parts), bool(tag_parts) + + # No tags, or tags without a retention partner -> no combination, + # behave exactly as before (every part is an independent operation). + if not has_tag or (not has_limit and not has_age): + return parts + + # Tags require exactly one retention partner. + if has_limit and has_age: + print( + " ERROR: Cannot combine 'limit' and 'age' with 'tags' at the same time.\n" + " Provide either 'limit'+'tag(s)' (keep N newest tagged runs) OR\n" + " 'age'+'tag(s)' (remove tagged runs in an age range), not both." + ) + exit(3) + if len(limit_parts) > 1: + print(" ERROR: Only one 'limit' may be combined with 'tag(s)'.") + exit(4) + if len(age_parts) > 1: + print(" ERROR: Only one 'age' may be combined with 'tag(s)'.") + exit(5) + + tag_values = [p.replace("tag=", "") for p in tag_parts] + tag_suffix = "".join(f";tag={value}" for value in tag_values) + tag_list = ", ".join(tag_values) + if has_limit: + retention = limit_parts[0] + limit_value = retention.replace("limit=", "") + combo_message = ( + f" INFO: Combining 'limit' with 'tag(s)' -> keeping the {limit_value} most recent " + f"run(s) tagged with [{tag_list}] and removing older matching runs " + f"(runs without these tags are left untouched)." + ) + else: + retention = age_parts[0] + age_value = retention.replace("age=", "") + direction = "younger than" if age_value.startswith("-") else "older than" + combo_message = ( + f" INFO: Combining 'age' with 'tag(s)' -> removing run(s) tagged with " + f"[{tag_list}] that are {direction} {age_value.lstrip('-')} " + f"(runs without these tags are left untouched)." + ) + combo = f"{retention}{tag_suffix}" + + # Independent options (index/run_start/alias) keep their original order + # and run before the scoped combination. + result = other_parts + [combo] + if other_parts: + print( + " INFO: A scoped combination was detected alongside other remove options.\n" + " They will be deleted in this order:" + ) + for position, part in enumerate(other_parts, start=1): + print(f" {position}. {part}") + print(f" {len(other_parts) + 1}. {combo} (the scoped combination)") + print(combo_message) + return result + def _check_argument_warnings(self, arguments, outputs, outputfolderpaths, use_logs, generate_dashboard, no_autoupdate, offline_dependencies): """Checks for argument combinations that are valid but likely unintended and prints warnings""" no_outputs = not outputs and not outputfolderpaths @@ -275,8 +361,11 @@ def _parse_arguments(self): " • '-r run_start=2024-07-30 15:27:20.184407' -> remove specified run\n" " • '-r alias=some_alias,tag=prod'\n" " • '-r limit=10' -> keep only the 10 most recent runs\n" + " • '-r limit=10,tag=nightly' -> keep 10 newest 'nightly' runs, leave others\n" " • '-r age=10d' -> remove runs older than 10 days\n" " • '-r age=-10d' -> remove runs younger than 10 days\n" + " • '-r age=10d,tag=nightly' -> remove 'nightly' runs older than 10 days, leave others\n" + " • combine 'tag' with EITHER 'limit' OR 'age' (not both) to scope retention to those tag(s)\n" " • (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported\n" ), action="append", @@ -531,11 +620,13 @@ def _process_arguments(self, arguments): # handles the processing of --removeruns remove_runs = None if arguments.removeruns: - remove_runs = [] + raw_parts = [] for runs in arguments.removeruns: - parts = str(runs[0]).split(",") - for part in parts: - remove_runs.append(part) + for part in str(runs[0]).split(","): + part = part.strip() + if part: + raw_parts.append(part) + remove_runs = self._process_remove_runs(raw_parts) # handles the boolean handling of relevant arguments generate_dashboard = self._normalize_bool( diff --git a/robotframework_dashboard/database.py b/robotframework_dashboard/database.py index 928b50a1..c134191c 100644 --- a/robotframework_dashboard/database.py +++ b/robotframework_dashboard/database.py @@ -433,12 +433,12 @@ def remove_runs(self, remove_runs: list): console += self._remove_by_index(run, run_starts) elif "alias=" in run: console += self._remove_by_alias(run, run_starts, run_aliases) - elif "tag=" in run: - console += self._remove_by_tag(run, run_starts, run_tags) elif "limit=" in run: - console += self._remove_by_limit(run, run_starts) + console += self._remove_by_limit(run, run_starts, run_tags) elif "age=" in run: - console += self._remove_by_age(run, run_starts) + console += self._remove_by_age(run, run_starts, run_tags) + elif "tag=" in run: + console += self._remove_by_tag(run, run_starts, run_tags) else: print( f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions" @@ -513,16 +513,39 @@ def _remove_by_tag(self, run: str, run_starts: list, run_tags: list): console += f" WARNING: no runs were removed as no runs were found with tag: {tag}\n" return console - def _remove_by_limit(self, run: str, run_starts: list): + def _remove_by_limit(self, run: str, run_starts: list, run_tags: list = None): + """Keep the N newest runs, removing older ones. + + When tag filters are appended (e.g. 'limit=10;tag=nightly;tag=prod'), + the limit is scoped to runs matching any of those tags: the N newest + matching runs are kept, older matching runs are removed, and runs that + do not match any tag are left untouched. + """ console = "" - limit = int(run.replace("limit=", "")) - if limit >= len(run_starts): + parts = run.split(";") + limit = int(parts[0].replace("limit=", "")) + tag_filters = [ + part.replace("tag=", "") for part in parts[1:] if part.startswith("tag=") + ] + # run_starts are ordered oldest -> newest, so keeping the N newest means + # dropping the leading (oldest) candidates. + if tag_filters and run_tags is not None: + candidates = [ + index + for index, run_tag in enumerate(run_tags) + if any(tag in run_tag for tag in tag_filters) + ] + scope = f" with tag(s) {', '.join(tag_filters)}" + else: + candidates = list(range(len(run_starts))) + scope = "" + if limit >= len(candidates): print( - f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs ({len(run_starts)})" + f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs{scope} ({len(candidates)})" ) - console += f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs ({len(run_starts)})\n" + console += f" WARNING: no runs were removed as the provided limit ({limit}) is higher than the total number of runs{scope} ({len(candidates)})\n" return console - for index in range(len(run_starts) - limit): + for index in candidates[: len(candidates) - limit]: self._remove_run(run_starts[index]) print( f" Removed run from the database: index={index}, run_start={run_starts[index]}" @@ -530,16 +553,30 @@ def _remove_by_limit(self, run: str, run_starts: list): console += f" Removed run from the database: index={index}, run_start={run_starts[index]}\n" return console - def _remove_by_age(self, run_query: str, run_starts: list): + def _remove_by_age(self, run_query: str, run_starts: list, run_tags: list = None): + """Remove runs by age threshold. + + When tag filters are appended (e.g. 'age=10d;tag=nightly;tag=prod'), + only runs matching any of those tags are considered: matching runs that + fall within the age range are removed, runs without the tag(s) are left + untouched. + """ console = "" + parts = run_query.split(";") + tag_filters = [ + part.replace("tag=", "") for part in parts[1:] if part.startswith("tag=") + ] try: - clean_query = run_query.replace("age=", "") + clean_query = parts[0].replace("age=", "") mod, delta = self.parse_time_range(clean_query) except ValueError as e: return f" ERROR: {e}" cutoff = datetime.now(timezone.utc)-delta targets = [] - for r in run_starts: + for index, r in enumerate(run_starts): + if tag_filters and run_tags is not None: + if not any(tag in run_tags[index] for tag in tag_filters): + continue try: run_dt = datetime.fromisoformat(r) if run_dt.tzinfo is None: @@ -553,7 +590,8 @@ def _remove_by_age(self, run_query: str, run_starts: list): except ValueError as e: print(f" WARNING: Skipping invalid timestamp: '{r}' ({e})") if not targets: - console += f" WARNING: no runs were removed as no runs were within range {clean_query}" + scope = f" with tag(s) {', '.join(tag_filters)}" if tag_filters else "" + console += f" WARNING: no runs were removed as no runs{scope} were within range {clean_query}" return console for run_to_remove in targets: self._remove_run(run_to_remove) diff --git a/robotframework_dashboard/server.py b/robotframework_dashboard/server.py index 2be5222f..4bdab150 100644 --- a/robotframework_dashboard/server.py +++ b/robotframework_dashboard/server.py @@ -113,7 +113,9 @@ "tags": ["tag1", "tag2", "tag3"], }, {"limit": 10}, + {"limit": 10, "tags": ["nightly"]}, {"age": "10d"}, + {"age": "10d", "tags": ["nightly"]}, {"age": "-10d"}, {"all": True}, ], @@ -147,11 +149,21 @@ "description": "Remove runs older than a threshold (e.g., '10d') or younger than a threshold (e.g., '-10d'). Supports (y)ear/(d)ay/(h)our/(m)inute/(s)econd.", "value": {"age": "10d"}, }, + "age_by_tag": { + "summary": "Remove tagged runs by age threshold", + "description": "When 'tags' is combined with 'age', the age threshold is scoped to runs matching any given tag: matching runs within the age range are removed, and runs without those tags are left untouched.", + "value": {"age": "10d", "tags": ["nightly"]}, + }, "limit": { "summary": "Remove all but the N most recent runs", "description": "Keep only the specified number of most recent runs, deleting the rest.", "value": {"limit": 10}, }, + "limit_by_tag": { + "summary": "Keep N most recent runs within a tag", + "description": "When 'tags' is combined with 'limit', the limit is scoped to runs matching any given tag: the N newest matching runs are kept, older matching runs are removed, and runs without those tags are left untouched.", + "value": {"limit": 10, "tags": ["nightly"]}, + }, "all": { "summary": "Remove all outputs", "description": "Delete all runs currently stored in the database. This is irreversible.", @@ -601,6 +613,17 @@ async def remove_outputs_from_database( Can be either indexes or run_starts that are known in the database """ console = "no console output" + # 'tags' may be scoped to EITHER 'limit' OR 'age', not both at once. + if ( + remove_output.tags != None + and remove_output.limit != None + and remove_output.age != None + ): + message = ( + "ERROR: Cannot combine 'limit' and 'age' with 'tags' at the same time. " + "Provide either 'limit'+'tags' or 'age'+'tags', not both." + ) + return {"success": "0", "message": message, "console": message} try: # Because the argparser makes use of the format: [[outputtoremove1], [outputtoremove2]] # We have to create a list of lists with 1 item to match the handling of the API @@ -621,13 +644,25 @@ async def remove_outputs_from_database( if remove_output.aliases != None: for run in remove_output.aliases: remove_runs.append(f"alias={run}") - if remove_output.tags != None: + # When tags are combined with limit and/or age, scope that + # retention to the tagged runs (keep/remove only matching + # runs, leave others alone) instead of removing all tagged + # runs outright. + scope_tags = remove_output.tags != None and ( + remove_output.limit != None or remove_output.age != None + ) + tag_suffix = ( + "".join(f";tag={tag}" for tag in remove_output.tags) + if scope_tags + else "" + ) + if remove_output.tags != None and not scope_tags: for run in remove_output.tags: remove_runs.append(f"tag={run}") if remove_output.age != None: - remove_runs.append(f"age={remove_output.age}") + remove_runs.append(f"age={remove_output.age}{tag_suffix}") if remove_output.limit != None: - remove_runs.append(f"limit={remove_output.limit}") + remove_runs.append(f"limit={remove_output.limit}{tag_suffix}") paths_before = self.robotdashboard.get_run_paths() console = self.robotdashboard.remove_outputs(remove_runs) paths_after = self.robotdashboard.get_run_paths() diff --git a/robotframework_dashboard/templates/admin.html b/robotframework_dashboard/templates/admin.html index 8595971c..63a5cdb4 100644 --- a/robotframework_dashboard/templates/admin.html +++ b/robotframework_dashboard/templates/admin.html @@ -238,7 +238,8 @@

Remove output.xml(s) From Database

+ or -10d (delete younger than 10 days). (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported. + Combine with the tag field above to only delete tagged runs in that range (other runs untouched).
@@ -248,7 +249,7 @@

Remove output.xml(s) From Database

+ runs to keep). Combine with the tag field above to keep the N newest runs per tag (other runs untouched).
diff --git a/tests/python/test_arguments.py b/tests/python/test_arguments.py index c0a677a7..45162a9d 100644 --- a/tests/python/test_arguments.py +++ b/tests/python/test_arguments.py @@ -198,6 +198,72 @@ def test_process_arguments_with_removeruns(): assert result.remove_runs == ["index=0", "index=1"] +# --- _process_remove_runs: scoped retention combinations --- + +def test_remove_runs_passthrough_without_combination(): + # No tags -> nothing combined + assert ArgumentParser()._process_remove_runs(["index=0", "limit=10"]) == [ + "index=0", + "limit=10", + ] + + +def test_remove_runs_tag_only_passthrough(): + # tags without a limit/age partner stay independent (full wipe) + assert ArgumentParser()._process_remove_runs(["tag=dev"]) == ["tag=dev"] + + +def test_remove_runs_limit_and_tag_combined(): + assert ArgumentParser()._process_remove_runs(["limit=10", "tag=dev"]) == [ + "limit=10;tag=dev" + ] + + +def test_remove_runs_age_and_tag_combined(): + assert ArgumentParser()._process_remove_runs(["age=10d", "tag=dev"]) == [ + "age=10d;tag=dev" + ] + + +def test_remove_runs_limit_and_multiple_tags_combined(): + assert ArgumentParser()._process_remove_runs( + ["limit=10", "tag=dev", "tag=prod"] + ) == ["limit=10;tag=dev;tag=prod"] + + +def test_remove_runs_combination_with_other_options_keeps_order(): + # independent ops first (in order), scoped combination last + assert ArgumentParser()._process_remove_runs( + ["index=0", "limit=10", "tag=dev"] + ) == ["index=0", "limit=10;tag=dev"] + + +def test_remove_runs_all_three_errors(capsys): + with pytest.raises(SystemExit) as exc: + ArgumentParser()._process_remove_runs(["limit=10", "age=10d", "tag=dev"]) + assert exc.value.code == 3 + assert "Cannot combine 'limit' and 'age'" in capsys.readouterr().out + + +def test_remove_runs_multiple_limits_with_tag_errors(capsys): + with pytest.raises(SystemExit) as exc: + ArgumentParser()._process_remove_runs(["limit=10", "limit=5", "tag=dev"]) + assert exc.value.code == 4 + assert "Only one 'limit'" in capsys.readouterr().out + + +def test_remove_runs_emits_info_message(capsys): + ArgumentParser()._process_remove_runs(["limit=10", "tag=dev"]) + out = capsys.readouterr().out + assert "INFO: Combining 'limit' with 'tag(s)'" in out + + +def test_remove_runs_combination_via_process_arguments(): + args = _make_namespace(removeruns=[["limit=10,tag=dev"]]) + result = ArgumentParser()._process_arguments(args) + assert result.remove_runs == ["limit=10;tag=dev"] + + def test_process_arguments_with_messageconfig(tmp_path): msg_file = tmp_path / "messages.txt" msg_file.write_text("Template: ${name}\nLine 2\n") diff --git a/tests/python/test_database.py b/tests/python/test_database.py index 25c75a3d..927a8bc2 100644 --- a/tests/python/test_database.py +++ b/tests/python/test_database.py @@ -201,6 +201,130 @@ def test_remove_by_limit_higher_than_count_is_noop(populated_db): assert len(populated_db.get_data()["runs"]) == 1 +# --- remove_runs by limit scoped to tag(s) (issue #309) --- + +def _insert_run(db, xml, tags): + """Helper: insert a run from the given XML with the given tags.""" + processor = OutputProcessor(xml) + processor.get_run_start() + data = processor.get_output_data() + db.insert_output_data(data, tags, None, xml, None) + + +def _tags_of(runs): + return [run["tags"] for run in runs] + + +def _run_starts(db): + return [run["run_start"] for run in db.get_data()["runs"]] + + +def test_remove_by_limit_with_single_tag_keeps_newest_matching(db): + db.open_database() + # oldest -> newest; three "nightly" runs + one unrelated "release" run + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002257.xml", ["release"]) + starts_before = _run_starts(db) # ordered oldest -> newest + db.remove_runs(["limit=2;tag=nightly"]) + starts_after = _run_starts(db) + # oldest nightly removed; 2 newest nightly + release remain + assert len(starts_after) == 3 + assert starts_before[0] not in starts_after # oldest nightly removed + assert starts_before[3] in starts_after # release untouched + db.close_database() + + +def test_remove_by_limit_with_multiple_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["alpha"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["beta"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["alpha"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002257.xml", ["gamma"]) + starts_before = _run_starts(db) + # candidates = union of alpha+beta = 3 oldest runs; keep 2 newest of those + db.remove_runs(["limit=2;tag=alpha;tag=beta"]) + starts_after = _run_starts(db) + assert len(starts_after) == 3 + assert starts_before[0] not in starts_after # oldest alpha removed + assert starts_before[3] in starts_after # gamma untouched + db.close_database() + + +def test_remove_by_limit_with_tag_higher_than_count_is_noop(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["other"]) + console = db.remove_runs(["limit=5;tag=nightly"]) + assert len(db.get_data()["runs"]) == 3 + assert "WARNING" in console + db.close_database() + + +def test_remove_by_limit_only_ignores_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) + starts_before = _run_starts(db) + # no tag scope -> global limit, keep 1 newest regardless of tag + db.remove_runs(["limit=1"]) + starts_after = _run_starts(db) + assert starts_after == [starts_before[-1]] # only the newest remains + db.close_database() + + +# --- remove_runs by age scoped to tag(s) (issue #309 follow-up) --- +# Sample XMLs are dated 2025-03 -> always "older" than the test run date. + +def test_remove_by_age_with_single_tag_only_removes_matching(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) + starts_before = _run_starts(db) + # remove only 'nightly' runs older than 10 days; release untouched + db.remove_runs(["age=10d;tag=nightly"]) + starts_after = _run_starts(db) + assert len(starts_after) == 1 + assert starts_after == [starts_before[1]] # the release run remains + db.close_database() + + +def test_remove_by_age_with_multiple_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["alpha"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["beta"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["gamma"]) + starts_before = _run_starts(db) + db.remove_runs(["age=10d;tag=alpha;tag=beta"]) + starts_after = _run_starts(db) + assert starts_after == [starts_before[2]] # only gamma remains + db.close_database() + + +def test_remove_by_age_with_tag_no_match_is_noop(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) + console = db.remove_runs(["age=10d;tag=nonexistent"]) + assert len(db.get_data()["runs"]) == 2 + assert "WARNING" in console + db.close_database() + + +def test_remove_by_age_only_ignores_tags(db): + db.open_database() + _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) + _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) + # no tag scope -> all runs older than 10 days removed regardless of tag + db.remove_runs(["age=10d"]) + assert len(db.get_data()["runs"]) == 0 + db.close_database() + + # --- list_runs --- def test_list_runs_empty_prints_warning(db, capsys): diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 96b9ad9a..8aa39d0d 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -434,6 +434,53 @@ def test_remove_outputs_by_limit(): assert response.json()["success"] == "1" +def test_remove_outputs_by_limit_and_tags_builds_scoped_query(): + """limit + tags -> single scoped 'limit=N;tag=...' query, no standalone tag removals.""" + server = _make_server() + client = _client(server) + response = client.request( + "DELETE", + "/remove-outputs", + json={"limit": 5, "tags": ["nightly", "prod"]}, + ) + assert response.status_code == 200 + args = server.robotdashboard.remove_outputs.call_args[0][0] + assert args == ["limit=5;tag=nightly;tag=prod"] + # tags must not be removed independently when scoped to the limit + assert not any(r == "tag=nightly" for r in args) + + +def test_remove_outputs_by_age_and_tags_builds_scoped_query(): + """age + tags -> single scoped 'age=...;tag=...' query, no standalone tag removals.""" + server = _make_server() + client = _client(server) + response = client.request( + "DELETE", + "/remove-outputs", + json={"age": "10d", "tags": ["nightly", "prod"]}, + ) + assert response.status_code == 200 + args = server.robotdashboard.remove_outputs.call_args[0][0] + assert args == ["age=10d;tag=nightly;tag=prod"] + assert not any(r == "tag=nightly" for r in args) + + +def test_remove_outputs_limit_age_tags_all_three_errors(): + """limit + age + tags together is rejected before any removal.""" + server = _make_server() + client = _client(server) + response = client.request( + "DELETE", + "/remove-outputs", + json={"limit": 5, "age": "10d", "tags": ["nightly"]}, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"] == "0" + assert "Cannot combine 'limit' and 'age'" in body["message"] + server.robotdashboard.remove_outputs.assert_not_called() + + def test_remove_outputs_all_flag(): server = _make_server() server.robotdashboard.get_runs.return_value = ( From 1d47aee67cf5fa4783b9bf67587cb709a02f74df Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 14 Sep 2026 00:18:28 +0200 Subject: [PATCH 2/3] Narrow tag-scoped retention to limit only, matching issue #309 The branch had grown a parallel "age" + "tags" combination beyond what #309 asked for. That doubled the combination-detection/validation logic across arguments.py and server.py, and required database.py to parse a hand-rolled ';'-encoded suffix in two functions instead of one. Drop the age+tag combo: age+tags now behaves exactly like on main (two independent operations). limit+tags is unchanged and still matches the issue's proposed direction and examples exactly. Updated docs, the CLI help-text fixture, and tests accordingly. --- docs/basic-command-line-interface-cli.md | 6 +- docs/dashboard-server.md | 3 +- robotframework_dashboard/arguments.py | 95 +++++-------------- robotframework_dashboard/database.py | 33 +++---- robotframework_dashboard/server.py | 36 +++---- robotframework_dashboard/templates/admin.html | 3 +- tests/python/test_arguments.py | 16 ++-- tests/python/test_database.py | 53 +---------- tests/python/test_server.py | 24 +---- tests/robot/resources/cli_output/help.txt | 1 + 10 files changed, 66 insertions(+), 204 deletions(-) diff --git a/docs/basic-command-line-interface-cli.md b/docs/basic-command-line-interface-cli.md index a444bea6..0fb201a7 100644 --- a/docs/basic-command-line-interface-cli.md +++ b/docs/basic-command-line-interface-cli.md @@ -118,8 +118,6 @@ robotdashboard -r limit=10,tag=nightly # keep 10 newest 'nightly' runs, leave ot robotdashboard -r limit=10,tag=nightly,tag=prod # scope to multiple tags robotdashboard -r age=10d # remove runs OLDER than 10 days. (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported robotdashboard -r age=-10d # remove runs YOUNGER than 10 days (note the leading minus) -robotdashboard -r age=10d,tag=nightly # remove 'nightly' runs older than 10 days, leave others -robotdashboard -r age=10d,tag=nightly,tag=prod # scope to multiple tags # Log data of removed runs in jsonl robotdashboard -r limit=10 --logremoved "/myLogDir/removedRuns.jsonl" robotdashboard -r limit=10 --logremoved "run:suite:/myLogDir/removedRuns.jsonl" @@ -132,12 +130,10 @@ robotdashboard -r limit=10 --logremoved run:keyword - Index ranges use `:` for ranges and `;` for lists. - Quotation marks are required when spaces exist in identifiers. - With limit=10 only the 10 most recent runs will be kept, all others will be removed. -- With limit=10,tag=nightly only the 10 most recent runs **carrying that tag** are kept; older tagged runs are removed and runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags. +- With limit=10,tag=nightly only the 10 most recent runs **carrying that tag** are kept; older tagged runs are removed and runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags. Only `limit` supports this tag scoping — `tag` and `age` combined just run as two independent operations. - With age=10d only runs _**older**_ than 10 days will be removed - With age=-10d (leading minus) only runs _**younger**_ than 10 days will be removed - Supported age units: (y)ear, (d)ay, (h)our, (m)inute, (s)econd — e.g. `age=12h`, `age=-30m` -- With age=10d,tag=nightly only runs **carrying that tag** older than 10 days are removed; runs without the tag are left untouched. Add more `tag=` values to scope to multiple tags. -- `tag` can be combined with **either** `limit` **or** `age` (not both at once — that is an error). The combination is detected automatically and an `INFO` line explains exactly what will be deleted; if other options (index/alias/run_start) are present too, the deletion order is printed. - Optional: `--logremoved` logs run data to a `.jsonl` file before removal. - Format: `[types:]path` where types are colon-separated from `run`, `suite`, `test`, `keyword`, `all`. - If no types are specified, defaults to `all` (runs, suites, tests and keywords). diff --git a/docs/dashboard-server.md b/docs/dashboard-server.md index 7c909ba3..b284ecc9 100644 --- a/docs/dashboard-server.md +++ b/docs/dashboard-server.md @@ -161,9 +161,8 @@ The admin page supports four methods for adding test results: | **By Alias** | Comma-separated alias names. | | **By Tag** | Comma-separated tags — removes all runs matching any of the specified tags. | | **By Limit** | Keep only the N most recent runs; all older runs are deleted. | -| **By Limit + Tag(s)** | Supply `limit` together with `tags` to scope the limit: the N most recent runs matching any given tag are kept, older matching runs are deleted, and runs without those tags are left untouched (e.g. `{"limit": 10, "tags": ["nightly"]}`). | +| **By Limit + Tag(s)** | Supply `limit` together with `tags` to scope the limit: the N most recent runs matching any given tag are kept, older matching runs are deleted, and runs without those tags are left untouched (e.g. `{"limit": 10, "tags": ["nightly"]}`). Only `limit` supports this tag scoping — `age` + `tags` together just run as two independent operations. | | **By Age** | Remove runs by age threshold. `"10d"` removes runs **older** than 10 days; a leading minus `"-10d"` removes runs **younger** than 10 days. Units: (y)ear, (d)ay, (h)our, (m)inute, (s)econd. | -| **By Age + Tag(s)** | Supply `age` together with `tags` to scope the age threshold: only runs matching any given tag within the age range are deleted, runs without those tags are left untouched (e.g. `{"age": "10d", "tags": ["nightly"]}`). | | **Remove All** | Irreversibly deletes all runs from the database. | > **Note:** When a run is removed, the server automatically checks whether a corresponding log file exists in the `robot_logs/` folder (derived by replacing `output` → `log` and `.xml` → `.html` in the stored path). If found, it is deleted alongside the run. The console response will confirm whether a log was removed or note that none was found. diff --git a/robotframework_dashboard/arguments.py b/robotframework_dashboard/arguments.py index 59590813..c8bebe71 100644 --- a/robotframework_dashboard/arguments.py +++ b/robotframework_dashboard/arguments.py @@ -72,88 +72,43 @@ def _check_project_version_usage(self, tags, arguments): def _process_remove_runs(self, parts): """Process and validate the comma-separated --removeruns parts. - Detects a scoped retention combination ('limit'+'tags' or 'age'+'tags') - and rewrites it into the internal 'limit=N;tag=...' / 'age=X;tag=...' - form so the retention acts on the tagged subset instead of running as - independent operations. This lets users combine options with the regular - comma separator (e.g. '-r "limit=10,tag=nightly"') instead of ';'. + Detects a scoped-retention combination ('limit' + 'tags') and rewrites + it into the internal 'limit=N;tag=...' form so the limit acts on the + tagged subset instead of running as two independent operations. This + lets users write the combo with the regular comma separator (e.g. + '-r "limit=10,tag=nightly"') instead of ';'. + + Scope note (matches issue #309): only 'limit' can be scoped by tags. + 'age' + 'tags' is intentionally NOT supported here — combining them + just runs as two independent operations, same as on main. Rules: - - 'tags' may be combined with EITHER 'limit' OR 'age', not both (error). - - Only one 'limit'/'age' may be combined with tags (error otherwise). - - When a combination is detected an INFO message explains exactly what - will be deleted; if other options are also present, the deletion order - is printed too. + - No tags, or tags without a 'limit' partner -> no combination, every + part stays independent (unchanged behavior, including 'age'+'tags'). + - Only one 'limit' may be combined with tags (error otherwise). """ limit_parts = [p for p in parts if p.startswith("limit=")] - age_parts = [p for p in parts if p.startswith("age=")] tag_parts = [p for p in parts if p.startswith("tag=")] other_parts = [ - p - for p in parts - if not ( - p.startswith("limit=") - or p.startswith("age=") - or p.startswith("tag=") - ) + p for p in parts if not (p.startswith("limit=") or p.startswith("tag=")) ] - has_limit, has_age, has_tag = bool(limit_parts), bool(age_parts), bool(tag_parts) - # No tags, or tags without a retention partner -> no combination, - # behave exactly as before (every part is an independent operation). - if not has_tag or (not has_limit and not has_age): + if not tag_parts or not limit_parts: return parts - - # Tags require exactly one retention partner. - if has_limit and has_age: - print( - " ERROR: Cannot combine 'limit' and 'age' with 'tags' at the same time.\n" - " Provide either 'limit'+'tag(s)' (keep N newest tagged runs) OR\n" - " 'age'+'tag(s)' (remove tagged runs in an age range), not both." - ) - exit(3) if len(limit_parts) > 1: print(" ERROR: Only one 'limit' may be combined with 'tag(s)'.") - exit(4) - if len(age_parts) > 1: - print(" ERROR: Only one 'age' may be combined with 'tag(s)'.") - exit(5) + exit(3) tag_values = [p.replace("tag=", "") for p in tag_parts] - tag_suffix = "".join(f";tag={value}" for value in tag_values) - tag_list = ", ".join(tag_values) - if has_limit: - retention = limit_parts[0] - limit_value = retention.replace("limit=", "") - combo_message = ( - f" INFO: Combining 'limit' with 'tag(s)' -> keeping the {limit_value} most recent " - f"run(s) tagged with [{tag_list}] and removing older matching runs " - f"(runs without these tags are left untouched)." - ) - else: - retention = age_parts[0] - age_value = retention.replace("age=", "") - direction = "younger than" if age_value.startswith("-") else "older than" - combo_message = ( - f" INFO: Combining 'age' with 'tag(s)' -> removing run(s) tagged with " - f"[{tag_list}] that are {direction} {age_value.lstrip('-')} " - f"(runs without these tags are left untouched)." - ) - combo = f"{retention}{tag_suffix}" - - # Independent options (index/run_start/alias) keep their original order - # and run before the scoped combination. - result = other_parts + [combo] - if other_parts: - print( - " INFO: A scoped combination was detected alongside other remove options.\n" - " They will be deleted in this order:" - ) - for position, part in enumerate(other_parts, start=1): - print(f" {position}. {part}") - print(f" {len(other_parts) + 1}. {combo} (the scoped combination)") - print(combo_message) - return result + limit_value = limit_parts[0].replace("limit=", "") + combo = limit_parts[0] + "".join(f";tag={value}" for value in tag_values) + print( + f" INFO: Combining 'limit' with 'tag(s)' -> keeping the {limit_value} most recent " + f"run(s) tagged with [{', '.join(tag_values)}] and removing older matching runs " + f"(runs without these tags are left untouched)." + ) + # other independent options (index/run_start/alias) run before the combo + return other_parts + [combo] def _check_argument_warnings(self, arguments, outputs, outputfolderpaths, use_logs, generate_dashboard, no_autoupdate, offline_dependencies): """Checks for argument combinations that are valid but likely unintended and prints warnings""" @@ -364,8 +319,6 @@ def _parse_arguments(self): " • '-r limit=10,tag=nightly' -> keep 10 newest 'nightly' runs, leave others\n" " • '-r age=10d' -> remove runs older than 10 days\n" " • '-r age=-10d' -> remove runs younger than 10 days\n" - " • '-r age=10d,tag=nightly' -> remove 'nightly' runs older than 10 days, leave others\n" - " • combine 'tag' with EITHER 'limit' OR 'age' (not both) to scope retention to those tag(s)\n" " • (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported\n" ), action="append", diff --git a/robotframework_dashboard/database.py b/robotframework_dashboard/database.py index c134191c..2643695a 100644 --- a/robotframework_dashboard/database.py +++ b/robotframework_dashboard/database.py @@ -434,11 +434,14 @@ def remove_runs(self, remove_runs: list): elif "alias=" in run: console += self._remove_by_alias(run, run_starts, run_aliases) elif "limit=" in run: + # checked before "tag=" because a scoped combo ("limit=10;tag=x") + # still contains the substring "tag=" and would otherwise be + # misrouted to _remove_by_tag console += self._remove_by_limit(run, run_starts, run_tags) - elif "age=" in run: - console += self._remove_by_age(run, run_starts, run_tags) elif "tag=" in run: console += self._remove_by_tag(run, run_starts, run_tags) + elif "age=" in run: + console += self._remove_by_age(run, run_starts) else: print( f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions" @@ -553,30 +556,19 @@ def _remove_by_limit(self, run: str, run_starts: list, run_tags: list = None): console += f" Removed run from the database: index={index}, run_start={run_starts[index]}\n" return console - def _remove_by_age(self, run_query: str, run_starts: list, run_tags: list = None): - """Remove runs by age threshold. - - When tag filters are appended (e.g. 'age=10d;tag=nightly;tag=prod'), - only runs matching any of those tags are considered: matching runs that - fall within the age range are removed, runs without the tag(s) are left - untouched. - """ + def _remove_by_age(self, run_query: str, run_starts: list): + # NOTE: issue #309 / PR #313 only asked for tag-scoped retention on + # "limit" (see _remove_by_limit); age intentionally has no tag scoping + # here, matching the original issue's proposed direction. console = "" - parts = run_query.split(";") - tag_filters = [ - part.replace("tag=", "") for part in parts[1:] if part.startswith("tag=") - ] try: - clean_query = parts[0].replace("age=", "") + clean_query = run_query.replace("age=", "") mod, delta = self.parse_time_range(clean_query) except ValueError as e: return f" ERROR: {e}" cutoff = datetime.now(timezone.utc)-delta targets = [] - for index, r in enumerate(run_starts): - if tag_filters and run_tags is not None: - if not any(tag in run_tags[index] for tag in tag_filters): - continue + for r in run_starts: try: run_dt = datetime.fromisoformat(r) if run_dt.tzinfo is None: @@ -590,8 +582,7 @@ def _remove_by_age(self, run_query: str, run_starts: list, run_tags: list = None except ValueError as e: print(f" WARNING: Skipping invalid timestamp: '{r}' ({e})") if not targets: - scope = f" with tag(s) {', '.join(tag_filters)}" if tag_filters else "" - console += f" WARNING: no runs were removed as no runs{scope} were within range {clean_query}" + console += f" WARNING: no runs were removed as no runs were within range {clean_query}" return console for run_to_remove in targets: self._remove_run(run_to_remove) diff --git a/robotframework_dashboard/server.py b/robotframework_dashboard/server.py index 4bdab150..4a8eb21f 100644 --- a/robotframework_dashboard/server.py +++ b/robotframework_dashboard/server.py @@ -115,7 +115,6 @@ {"limit": 10}, {"limit": 10, "tags": ["nightly"]}, {"age": "10d"}, - {"age": "10d", "tags": ["nightly"]}, {"age": "-10d"}, {"all": True}, ], @@ -149,11 +148,9 @@ "description": "Remove runs older than a threshold (e.g., '10d') or younger than a threshold (e.g., '-10d'). Supports (y)ear/(d)ay/(h)our/(m)inute/(s)econd.", "value": {"age": "10d"}, }, - "age_by_tag": { - "summary": "Remove tagged runs by age threshold", - "description": "When 'tags' is combined with 'age', the age threshold is scoped to runs matching any given tag: matching runs within the age range are removed, and runs without those tags are left untouched.", - "value": {"age": "10d", "tags": ["nightly"]}, - }, + # NOTE: 'age' intentionally has no tag-scoped variant — see issue #309, + # which only asked for tag-scoped retention on 'limit' (below). + # 'age' + 'tags' together just run as two independent operations. "limit": { "summary": "Remove all but the N most recent runs", "description": "Keep only the specified number of most recent runs, deleting the rest.", @@ -613,17 +610,6 @@ async def remove_outputs_from_database( Can be either indexes or run_starts that are known in the database """ console = "no console output" - # 'tags' may be scoped to EITHER 'limit' OR 'age', not both at once. - if ( - remove_output.tags != None - and remove_output.limit != None - and remove_output.age != None - ): - message = ( - "ERROR: Cannot combine 'limit' and 'age' with 'tags' at the same time. " - "Provide either 'limit'+'tags' or 'age'+'tags', not both." - ) - return {"success": "0", "message": message, "console": message} try: # Because the argparser makes use of the format: [[outputtoremove1], [outputtoremove2]] # We have to create a list of lists with 1 item to match the handling of the API @@ -644,13 +630,13 @@ async def remove_outputs_from_database( if remove_output.aliases != None: for run in remove_output.aliases: remove_runs.append(f"alias={run}") - # When tags are combined with limit and/or age, scope that - # retention to the tagged runs (keep/remove only matching - # runs, leave others alone) instead of removing all tagged - # runs outright. - scope_tags = remove_output.tags != None and ( - remove_output.limit != None or remove_output.age != None - ) + # When tags are combined with limit, scope the limit to the + # tagged runs (keep/remove only matching runs, leave others + # alone) instead of removing all tagged runs outright. + # NOTE: 'age' has no tag-scoped variant (see issue #309, + # which only requested this for 'limit') — 'age' + 'tags' + # together just run as two independent operations below. + scope_tags = remove_output.tags != None and remove_output.limit != None tag_suffix = ( "".join(f";tag={tag}" for tag in remove_output.tags) if scope_tags @@ -660,7 +646,7 @@ async def remove_outputs_from_database( for run in remove_output.tags: remove_runs.append(f"tag={run}") if remove_output.age != None: - remove_runs.append(f"age={remove_output.age}{tag_suffix}") + remove_runs.append(f"age={remove_output.age}") if remove_output.limit != None: remove_runs.append(f"limit={remove_output.limit}{tag_suffix}") paths_before = self.robotdashboard.get_run_paths() diff --git a/robotframework_dashboard/templates/admin.html b/robotframework_dashboard/templates/admin.html index 63a5cdb4..5c75b712 100644 --- a/robotframework_dashboard/templates/admin.html +++ b/robotframework_dashboard/templates/admin.html @@ -238,8 +238,7 @@

Remove output.xml(s) From Database

+ or -10d (delete younger than 10 days). (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported
diff --git a/tests/python/test_arguments.py b/tests/python/test_arguments.py index 45162a9d..593d878d 100644 --- a/tests/python/test_arguments.py +++ b/tests/python/test_arguments.py @@ -219,9 +219,12 @@ def test_remove_runs_limit_and_tag_combined(): ] -def test_remove_runs_age_and_tag_combined(): +def test_remove_runs_age_and_tag_not_combined(): + # issue #309 only asked for tag-scoped retention on "limit" — "age" + "tag" + # is intentionally left as two independent operations, unchanged from main assert ArgumentParser()._process_remove_runs(["age=10d", "tag=dev"]) == [ - "age=10d;tag=dev" + "age=10d", + "tag=dev", ] @@ -238,17 +241,10 @@ def test_remove_runs_combination_with_other_options_keeps_order(): ) == ["index=0", "limit=10;tag=dev"] -def test_remove_runs_all_three_errors(capsys): - with pytest.raises(SystemExit) as exc: - ArgumentParser()._process_remove_runs(["limit=10", "age=10d", "tag=dev"]) - assert exc.value.code == 3 - assert "Cannot combine 'limit' and 'age'" in capsys.readouterr().out - - def test_remove_runs_multiple_limits_with_tag_errors(capsys): with pytest.raises(SystemExit) as exc: ArgumentParser()._process_remove_runs(["limit=10", "limit=5", "tag=dev"]) - assert exc.value.code == 4 + assert exc.value.code == 3 assert "Only one 'limit'" in capsys.readouterr().out diff --git a/tests/python/test_database.py b/tests/python/test_database.py index 927a8bc2..5f24dc5d 100644 --- a/tests/python/test_database.py +++ b/tests/python/test_database.py @@ -276,54 +276,11 @@ def test_remove_by_limit_only_ignores_tags(db): db.close_database() -# --- remove_runs by age scoped to tag(s) (issue #309 follow-up) --- -# Sample XMLs are dated 2025-03 -> always "older" than the test run date. - -def test_remove_by_age_with_single_tag_only_removes_matching(db): - db.open_database() - _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) - _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) - _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["nightly"]) - starts_before = _run_starts(db) - # remove only 'nightly' runs older than 10 days; release untouched - db.remove_runs(["age=10d;tag=nightly"]) - starts_after = _run_starts(db) - assert len(starts_after) == 1 - assert starts_after == [starts_before[1]] # the release run remains - db.close_database() - - -def test_remove_by_age_with_multiple_tags(db): - db.open_database() - _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["alpha"]) - _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["beta"]) - _insert_run(db, OUTPUTS_DIR / "output-20250313-002222.xml", ["gamma"]) - starts_before = _run_starts(db) - db.remove_runs(["age=10d;tag=alpha;tag=beta"]) - starts_after = _run_starts(db) - assert starts_after == [starts_before[2]] # only gamma remains - db.close_database() - - -def test_remove_by_age_with_tag_no_match_is_noop(db): - db.open_database() - _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) - _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["nightly"]) - console = db.remove_runs(["age=10d;tag=nonexistent"]) - assert len(db.get_data()["runs"]) == 2 - assert "WARNING" in console - db.close_database() - - -def test_remove_by_age_only_ignores_tags(db): - db.open_database() - _insert_run(db, OUTPUTS_DIR / "output-20250313-002134.xml", ["nightly"]) - _insert_run(db, OUTPUTS_DIR / "output-20250313-002151.xml", ["release"]) - # no tag scope -> all runs older than 10 days removed regardless of tag - db.remove_runs(["age=10d"]) - assert len(db.get_data()["runs"]) == 0 - db.close_database() - +# NOTE: issue #309 only asked for tag-scoped retention on "limit" +# (see the block above). "age" intentionally has no tag-scoped variant: +# db.remove_runs(["age=10d", "tag=nightly"]) runs as two independent +# operations, same as before this feature — no dedicated test needed here +# beyond the existing plain "age=10d" / "tag=x" coverage. # --- list_runs --- diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 8aa39d0d..a109f8c5 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -450,8 +450,9 @@ def test_remove_outputs_by_limit_and_tags_builds_scoped_query(): assert not any(r == "tag=nightly" for r in args) -def test_remove_outputs_by_age_and_tags_builds_scoped_query(): - """age + tags -> single scoped 'age=...;tag=...' query, no standalone tag removals.""" +def test_remove_outputs_by_age_and_tags_stay_independent(): + """age + tags -> issue #309 only scoped 'limit' by tags; 'age' + 'tags' + together still run as two independent operations, unchanged from main.""" server = _make_server() client = _client(server) response = client.request( @@ -461,24 +462,7 @@ def test_remove_outputs_by_age_and_tags_builds_scoped_query(): ) assert response.status_code == 200 args = server.robotdashboard.remove_outputs.call_args[0][0] - assert args == ["age=10d;tag=nightly;tag=prod"] - assert not any(r == "tag=nightly" for r in args) - - -def test_remove_outputs_limit_age_tags_all_three_errors(): - """limit + age + tags together is rejected before any removal.""" - server = _make_server() - client = _client(server) - response = client.request( - "DELETE", - "/remove-outputs", - json={"limit": 5, "age": "10d", "tags": ["nightly"]}, - ) - assert response.status_code == 200 - body = response.json() - assert body["success"] == "0" - assert "Cannot combine 'limit' and 'age'" in body["message"] - server.robotdashboard.remove_outputs.assert_not_called() + assert args == ["tag=nightly", "tag=prod", "age=10d"] def test_remove_outputs_all_flag(): diff --git a/tests/robot/resources/cli_output/help.txt b/tests/robot/resources/cli_output/help.txt index 85c79bd9..96a04367 100644 --- a/tests/robot/resources/cli_output/help.txt +++ b/tests/robot/resources/cli_output/help.txt @@ -70,6 +70,7 @@ database: • '-r run_start=2024-07-30 15:27:20.184407' -> remove specified run • '-r alias=some_alias,tag=prod' • '-r limit=10' -> keep only the 10 most recent runs + • '-r limit=10,tag=nightly' -> keep 10 newest 'nightly' runs, leave others • '-r age=10d' -> remove runs older than 10 days • '-r age=-10d' -> remove runs younger than 10 days • (y)ear/(d)ay/(h)our/(m)inute/(s)econd supported From 1e041d4e1ea12a6e1f8cbb0f1bb8740147644d74 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 14 Sep 2026 00:44:07 +0200 Subject: [PATCH 3/3] Fix silent CLI failures in remove_runs (helps diagnose #319) remove_runs() caught every per-item failure with a bare except:, discarding the real exception and always printing the same generic "could not find" message regardless of cause. Now prints the actual exception. _remove_by_age had two paths that built a warning/error string but never printed it, so on the CLI (unlike the server API, which returns the console string) a no-op removal or an invalid age format produced zero visible output - indistinguishable from a successful no-op. Both now print, matching every sibling _remove_by_* method. Updated the removerun CLI fixture: the alias-not-found line now shows the real exception detail instead of the old generic wording. --- robotframework_dashboard/database.py | 14 +++++++++----- tests/robot/resources/cli_output/removerun.txt | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/robotframework_dashboard/database.py b/robotframework_dashboard/database.py index 2643695a..bb17c299 100644 --- a/robotframework_dashboard/database.py +++ b/robotframework_dashboard/database.py @@ -447,11 +447,11 @@ def remove_runs(self, remove_runs: list): f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions" ) console += f" ERROR: incorrect usage of the remove_run feature ({run}), check out robotdashboard --help for instructions\n" - except: + except Exception as error: print( - f" ERROR: Could not find run to remove from the database: {run}, check out robotdashboard --help for instructions" + f" ERROR: Could not remove run: {run}, reason: {error}, check out robotdashboard --help for instructions" ) - console += f" ERROR: Could not find run to remove from the database: {run}, check out robotdashboard --help for instructions\n" + console += f" ERROR: Could not remove run: {run}, reason: {error}, check out robotdashboard --help for instructions\n" return console def _remove_by_run_start(self, run: str, run_starts: list): @@ -565,7 +565,8 @@ def _remove_by_age(self, run_query: str, run_starts: list): clean_query = run_query.replace("age=", "") mod, delta = self.parse_time_range(clean_query) except ValueError as e: - return f" ERROR: {e}" + print(f" ERROR: {e}") + return f" ERROR: {e}\n" cutoff = datetime.now(timezone.utc)-delta targets = [] for r in run_starts: @@ -582,7 +583,10 @@ def _remove_by_age(self, run_query: str, run_starts: list): except ValueError as e: print(f" WARNING: Skipping invalid timestamp: '{r}' ({e})") if not targets: - console += f" WARNING: no runs were removed as no runs were within range {clean_query}" + print( + f" WARNING: no runs were removed as no runs were within range {clean_query}" + ) + console += f" WARNING: no runs were removed as no runs were within range {clean_query}\n" return console for run_to_remove in targets: self._remove_run(run_to_remove) diff --git a/tests/robot/resources/cli_output/removerun.txt b/tests/robot/resources/cli_output/removerun.txt index 0de8a38f..ce1dfbd5 100644 --- a/tests/robot/resources/cli_output/removerun.txt +++ b/tests/robot/resources/cli_output/removerun.txt @@ -37,7 +37,7 @@ Removed run from the database: index=-1, run_start=2025-03-13 00:30:06.726165+??:?? Removed run from the database: index=6, run_start=2025-03-13 00:24:31.912429+??:?? ERROR: Could not find run to remove from the database: run_start=2025-03-13 00:27:39.871333 - ERROR: Could not find run to remove from the database: alias=abc, check out robotdashboard --help for instructions + ERROR: Could not remove run: alias=abc, reason: 'abc' is not in list, check out robotdashboard --help for instructions WARNING: no runs were removed as no runs were found with tag: tag1 Vacuumed the database in * seconds ======================================================================================