multi-directory backup support#200
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #200 +/- ##
===========================================
+ Coverage 79.51% 79.60% +0.09%
===========================================
Files 147 148 +1
Lines 13871 13989 +118
===========================================
+ Hits 11029 11136 +107
- Misses 2842 2853 +11
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This pull request adds support for backing up multiple directories in a single backup operation. Instead of being limited to a single source path, users can now specify multiple source paths through a dynamic UI that allows adding/removing paths. The implementation stores multiple paths as a JSON array string in the existing source_path database field, maintaining backward compatibility with single-path legacy data.
Changes:
- Added utility functions to parse and serialize source paths as JSON arrays while maintaining backward compatibility with single-path strings
- Updated UI templates to support dynamic addition/removal of source path fields via HTMX
- Modified schema validators to convert multi-value form submissions into JSON array strings
- Updated backup task executor to parse and pass multiple paths to borg commands
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/borgitory/utils/source_paths.py | New utility module for parsing/serializing source paths with legacy single-path and new JSON array format support |
| tests/utils/test_source_paths.py | Comprehensive test suite for source path utility functions covering various edge cases |
| src/borgitory/templates/partials/schedules/source_paths_container.html | Container template for dynamically managing multiple source path input fields |
| src/borgitory/templates/partials/schedules/source_path_field.html | Individual source path field template with remove button |
| src/borgitory/templates/partials/schedules/edit_form.html | Updated to use new multi-path UI instead of single path input |
| src/borgitory/templates/partials/schedules/create_form.html | Updated to use new multi-path UI instead of single path input |
| src/borgitory/templates/partials/backups/manual_form.html | Updated to use new multi-path UI instead of single path input |
| src/borgitory/services/scheduling/schedule_service.py | Added logic to handle source_paths array in form data and serialize to JSON |
| src/borgitory/services/jobs/task_executors/backup_task_executor.py | Updated to parse source_path string and pass all paths to borg create command |
| src/borgitory/models/schemas.py | Added merge_source_paths validators to ScheduleCreate, ScheduleUpdate, and BackupRequest; removed ABSOLUTE_PATH_PATTERN validation from Schedule.source_path field |
| src/borgitory/api/schedules.py | Added HTMX endpoints for dynamically adding/removing source path fields; updated edit form to parse stored paths for display |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 41 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
src/borgitory/services/task_definition_builder.py:46
patterns: List[str] = []uses a mutable default value, which can leak state between calls if the list is ever mutated. UseNoneas the default and normalize to an empty list inside the function.
ignore_lock: bool = False,
patterns: List[str] = [],
) -> TaskDefinition:
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 43 out of 43 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Accepts a JSON array string, a plain list of strings, None, or empty string. | ||
| """ | ||
| if v is None or (isinstance(v, str) and not v.strip()): | ||
| return [] | ||
| if isinstance(v, list): | ||
| return [p.strip() for p in v if isinstance(p, str) and p.strip()] | ||
| if isinstance(v, str): | ||
| return parse_source_paths(v) |
There was a problem hiding this comment.
_coerce_source_paths_to_list currently treats a non-empty non-JSON string as [] (because it delegates to parse_source_paths, which returns [] unless the string is a JSON array). That means callers sending a legacy single path string (or mistakenly sending source_path instead of source_paths) can silently end up with no source paths and create empty/invalid backup tasks. Consider supporting a plain absolute path string as a single-item list (or explicitly rejecting non-JSON strings) and mapping legacy source_path into source_paths for backward compatibility.
| Accepts a JSON array string, a plain list of strings, None, or empty string. | |
| """ | |
| if v is None or (isinstance(v, str) and not v.strip()): | |
| return [] | |
| if isinstance(v, list): | |
| return [p.strip() for p in v if isinstance(p, str) and p.strip()] | |
| if isinstance(v, str): | |
| return parse_source_paths(v) | |
| Accepts: | |
| - None or empty string: returns [] | |
| - Plain list of strings: normalized and filtered | |
| - JSON array string: parsed via parse_source_paths | |
| - Single absolute path string: treated as a one-item list | |
| """ | |
| # Treat None and empty/whitespace-only strings as no source paths | |
| if v is None or (isinstance(v, str) and not v.strip()): | |
| return [] | |
| # Already a list: normalize and filter to non-empty strings | |
| if isinstance(v, list): | |
| return [p.strip() for p in v if isinstance(p, str) and p.strip()] | |
| # String input: support legacy single absolute path and JSON array strings | |
| if isinstance(v, str): | |
| s = v.strip() | |
| if not s: | |
| return [] | |
| # Legacy behavior: accept a single absolute path string | |
| if re.match(ABSOLUTE_PATH_PATTERN, s): | |
| return [s] | |
| # Otherwise, expect a JSON array string | |
| paths = parse_source_paths(s) | |
| if not paths: | |
| raise ValueError( | |
| "source_paths must be either a JSON array of strings or a single absolute path string." | |
| ) | |
| return paths | |
| # Unsupported type: no valid source paths |
| source_paths_value = backup_params.get("source_paths") or [] | ||
| if isinstance(source_paths_value, list): | ||
| source_paths = source_paths_value | ||
| else: | ||
| source_paths = [] |
There was a problem hiding this comment.
build_task_list currently drops backup_params['source_paths'] unless it is already a Python list. If any caller passes a JSON-encoded string (as the UI does in hidden fields) or a legacy single-path string, this will silently produce source_paths = [] and the backup task will run with no paths. Consider accepting a JSON array string (and/or a single string) here and coercing it to a list (e.g., via the shared parse_source_paths helper) to make this path more robust and backward-compatible.
| paths = _string_to_paths_list(value) | ||
| conn_val = json.dumps(paths) if paths else "[]" | ||
| connection.execute( | ||
| sa.text("UPDATE schedules SET source_paths_json = :val WHERE id = :id"), | ||
| {"val": conn_val, "id": row_id}, | ||
| ) |
There was a problem hiding this comment.
This migration stores conn_val as a JSON-encoded string (via json.dumps) into a sa.JSON() column. That will persist a JSON string instead of a JSON array in some DBs/drivers, causing schedule.source_paths to come back as a string (breaking code that expects a list, e.g. list(schedule.source_paths) in the edit form context). Write the Python list directly to the JSON column (bind param should be the list), not a pre-dumped string.
No description provided.