rekordbox-edit 0.4.0.dev21__tar.gz → 0.4.0.dev23__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/CHANGELOG.md +7 -1
  2. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/PKG-INFO +1 -1
  3. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/pyproject.toml +1 -1
  4. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/commands/edit.py +37 -11
  5. rekordbox_edit-0.4.0.dev23/tests/commands/test_edit.py +530 -0
  6. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/uv.lock +1 -1
  7. rekordbox_edit-0.4.0.dev21/tests/commands/test_edit.py +0 -248
  8. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.agent-style/RULES.md +0 -0
  9. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.agent-style/claude-code.md +0 -0
  10. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/actions/commitizen-bump/action.yml +0 -0
  11. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  12. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/actions/install/action.yml +0 -0
  13. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/actions/lint/action.yml +0 -0
  14. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/actions/test/action.yml +0 -0
  15. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/workflows/cd.yml +0 -0
  16. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/workflows/ci.yml +0 -0
  17. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/workflows/publish.yml +0 -0
  18. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.github/workflows/release.yml +0 -0
  19. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.gitignore +0 -0
  20. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/.pre-commit-config.yaml +0 -0
  21. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/AGENTS.md +0 -0
  22. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/CLAUDE.md +0 -0
  23. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/CONTRIBUTING.md +0 -0
  24. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/LICENSE +0 -0
  25. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/Makefile +0 -0
  26. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/README.md +0 -0
  27. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/codecov.yml +0 -0
  28. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/__init__.py +0 -0
  29. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/_click.py +0 -0
  30. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/cli.py +0 -0
  31. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/commands/__init__.py +0 -0
  32. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/commands/convert.py +0 -0
  33. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/commands/search.py +0 -0
  34. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/logger.py +0 -0
  35. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/query.py +0 -0
  36. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/rekordbox_edit/utils.py +0 -0
  37. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/renovate.json5 +0 -0
  38. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/ruff.toml +0 -0
  39. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/tests/__init__.py +0 -0
  40. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/tests/commands/__init__.py +0 -0
  41. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/tests/commands/test_convert.py +0 -0
  42. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/tests/commands/test_search.py +0 -0
  43. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/tests/conftest.py +0 -0
  44. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/tests/test_logger.py +0 -0
  45. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/tests/test_query.py +0 -0
  46. {rekordbox_edit-0.4.0.dev21 → rekordbox_edit-0.4.0.dev23}/tests/test_utils.py +0 -0
@@ -1,6 +1,12 @@
1
- ## v0.4.0.dev21 (2026-05-16)
1
+ ## v0.4.0.dev23 (2026-05-17)
2
2
 
3
3
 
4
+ - feat(edit): add --multi to allow batch edits past single-track guard
5
+ - feat(edit): add --match for literal find/replace within field value
6
+ - Introduces a _compute_new_value helper and --match option so that
7
+ rbe edit can replace a substring of the current field value instead of
8
+ performing a wholesale replacement; None current values and non-matching
9
+ patterns are treated as no-ops.
4
10
  - chore: add max-complexity lint rule
5
11
  - docs: update AGENTS.md
6
12
  - test: add coverage for --interactive + --yes skipping all confirms
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.4.0.dev21
3
+ Version: 0.4.0.dev23
4
4
  Summary: Tools for managing and modifying a RekordBox library en-masse
5
5
  Project-URL: Homepage, https://github.com/jviall/rekordbox-edit
6
6
  Project-URL: Repository, https://github.com/jviall/rekordbox-edit
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rekordbox-edit"
7
- version = "0.4.0.dev21"
7
+ version = "0.4.0.dev23"
8
8
  description = "Tools for managing and modifying a RekordBox library en-masse"
9
9
  authors = [{ name = "James Viall", email= "jamesviall@pm.me"}]
10
10
  license = "MIT"
@@ -26,6 +26,18 @@ FIELD_COLUMNS = {
26
26
  }
27
27
 
28
28
 
29
+ def _compute_new_value(
30
+ current: str | int | None,
31
+ match_pattern: str | None,
32
+ replace_value: str | int,
33
+ ) -> str | int | None:
34
+ """Derive the new field value."""
35
+ if current is None:
36
+ return None
37
+ if match_pattern is not None:
38
+ return str(current).replace(match_pattern, str(replace_value))
39
+ return replace_value
40
+
29
41
 
30
42
  @click.command(
31
43
  epilog=f"Debug logs for each run can be found at:\n{get_debug_file_path().parent}"
@@ -54,6 +66,18 @@ FIELD_COLUMNS = {
54
66
  required=True,
55
67
  help="The new value to write to the field",
56
68
  )
69
+ @click.option(
70
+ "--match",
71
+ "match_pattern",
72
+ default=None,
73
+ metavar="PATTERN",
74
+ help="Find this literal string within the field value and replace only that portion",
75
+ )
76
+ @click.option(
77
+ "--multi",
78
+ is_flag=True,
79
+ help="Allow editing more than one track (required when filters match multiple tracks)",
80
+ )
57
81
  @track_ids_argument
58
82
  @click.argument(
59
83
  "field",
@@ -62,10 +86,12 @@ FIELD_COLUMNS = {
62
86
  def edit_command(
63
87
  field: str,
64
88
  replace_value: str,
89
+ match_pattern: str | None,
90
+ multi: bool,
65
91
  dry_run: bool,
66
92
  yes: bool,
67
93
  interactive: bool,
68
- track_ids: tuple,
94
+ track_ids: List[str] | None,
69
95
  track_id: List[str] | None,
70
96
  playlist: List[str] | None,
71
97
  exact_playlist: List[str] | None,
@@ -99,9 +125,7 @@ def edit_command(
99
125
  )
100
126
 
101
127
  if piped_stdin and not (dry_run or yes):
102
- raise click.UsageError(
103
- "Piping track IDs into edit requires --dry-run or --yes"
104
- )
128
+ raise click.UsageError("Piping track IDs into edit requires --dry-run or --yes")
105
129
 
106
130
  db = Rekordbox6Database()
107
131
  if not db.session:
@@ -127,20 +151,22 @@ def edit_command(
127
151
  tracks = result.scalars().all()
128
152
 
129
153
  col_name = FIELD_COLUMNS[field]
130
- edits = [
131
- (track, replace_value)
132
- for track in tracks
133
- if getattr(track, col_name) != replace_value
134
- ]
154
+ edits = []
155
+ for track in tracks:
156
+ current = getattr(track, col_name)
157
+ new_value = _compute_new_value(current, match_pattern, replace_value)
158
+ if new_value is None or new_value == current:
159
+ continue
160
+ edits.append((track, new_value))
135
161
 
136
162
  if not edits:
137
163
  logger.info("No changes to make.")
138
164
  return
139
165
 
140
- if len(edits) > 1:
166
+ if len(edits) > 1 and not multi:
141
167
  raise click.UsageError(
142
168
  f"Found {len(edits)} tracks that would be edited. "
143
- "Refine your filters, or use --dry-run to inspect."
169
+ "Refine your filters, use --dry-run to inspect, or pass --multi to edit all."
144
170
  )
145
171
 
146
172
  print_track_info([t for t, _ in edits])
@@ -0,0 +1,530 @@
1
+ """Unit tests for edit command."""
2
+
3
+ from unittest.mock import Mock, patch
4
+
5
+ import pytest
6
+
7
+ from rekordbox_edit.commands.edit import edit_command
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def mock_logger():
12
+ with patch("rekordbox_edit.commands.edit.logger") as mock_log:
13
+ yield mock_log
14
+
15
+
16
+ def _make_db_and_result(tracks):
17
+ """Return (mock_db_class, mock_get_filtered_content) with given tracks."""
18
+ mock_db = Mock()
19
+ mock_db.session = Mock()
20
+ mock_result = Mock()
21
+ mock_result.scalars.return_value.all.return_value = tracks
22
+ return mock_db, mock_result
23
+
24
+
25
+ class TestEditCommandPhase1:
26
+ """Phase 1: Title field, wholesale replace, single-track guard, confirm flow."""
27
+
28
+ @patch("rekordbox_edit.commands.edit.confirm")
29
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
30
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
31
+ def test_sets_title_and_commits(
32
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
33
+ ):
34
+ """--replace sets the field on the matched track and commits the session."""
35
+ track = make_djmd_content_item(Title="Old Title")
36
+ mock_db, mock_result = _make_db_and_result([track])
37
+ mock_db_class.return_value = mock_db
38
+ mock_gfc.return_value = mock_result
39
+ mock_confirm.return_value = True
40
+
41
+ from click.testing import CliRunner
42
+ result = CliRunner().invoke(edit_command, ["Title", "--replace", "New Title"])
43
+
44
+ assert result.exit_code == 0
45
+ assert track.Title == "New Title"
46
+ mock_db.session.commit.assert_called_once()
47
+
48
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
49
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
50
+ def test_noop_tracks_are_skipped(
51
+ self, mock_db_class, mock_gfc, make_djmd_content_item
52
+ ):
53
+ """Tracks whose current value already equals --replace are not committed."""
54
+ track = make_djmd_content_item(Title="Same Title")
55
+ mock_db, mock_result = _make_db_and_result([track])
56
+ mock_db_class.return_value = mock_db
57
+ mock_gfc.return_value = mock_result
58
+
59
+ from click.testing import CliRunner
60
+ result = CliRunner().invoke(edit_command, ["Title", "--replace", "Same Title", "--yes"])
61
+
62
+ assert result.exit_code == 0
63
+ mock_db.session.commit.assert_not_called()
64
+
65
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
66
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
67
+ def test_single_track_guard_aborts_on_multiple_matches(
68
+ self, mock_db_class, mock_gfc, make_djmd_content_item
69
+ ):
70
+ """When >1 track would change and --multi is absent, exit with non-zero code."""
71
+ tracks = [
72
+ make_djmd_content_item(Title="Track A"),
73
+ make_djmd_content_item(Title="Track B"),
74
+ ]
75
+ mock_db, mock_result = _make_db_and_result(tracks)
76
+ mock_db_class.return_value = mock_db
77
+ mock_gfc.return_value = mock_result
78
+
79
+ from click.testing import CliRunner
80
+ result = CliRunner().invoke(edit_command, ["Title", "--replace", "New", "--yes"])
81
+
82
+ assert result.exit_code != 0
83
+ mock_db.session.commit.assert_not_called()
84
+
85
+ @patch("rekordbox_edit.commands.edit.confirm")
86
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
87
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
88
+ def test_dry_run_does_not_commit(
89
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
90
+ ):
91
+ """--dry-run shows preview but does not write to the database."""
92
+ track = make_djmd_content_item(Title="Old Title")
93
+ mock_db, mock_result = _make_db_and_result([track])
94
+ mock_db_class.return_value = mock_db
95
+ mock_gfc.return_value = mock_result
96
+
97
+ from click.testing import CliRunner
98
+ result = CliRunner().invoke(edit_command, ["Title", "--replace", "New Title", "--dry-run"])
99
+
100
+ assert result.exit_code == 0
101
+ mock_db.session.commit.assert_not_called()
102
+ mock_confirm.assert_not_called()
103
+
104
+ @patch("rekordbox_edit.commands.edit.confirm")
105
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
106
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
107
+ def test_yes_skips_confirm(
108
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
109
+ ):
110
+ """--yes applies changes without prompting."""
111
+ track = make_djmd_content_item(Title="Old Title")
112
+ mock_db, mock_result = _make_db_and_result([track])
113
+ mock_db_class.return_value = mock_db
114
+ mock_gfc.return_value = mock_result
115
+
116
+ from click.testing import CliRunner
117
+ result = CliRunner().invoke(edit_command, ["Title", "--replace", "New Title", "--yes"])
118
+
119
+ assert result.exit_code == 0
120
+ mock_confirm.assert_not_called()
121
+ mock_db.session.commit.assert_called_once()
122
+
123
+ @patch("rekordbox_edit.commands.edit.confirm")
124
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
125
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
126
+ def test_interactive_confirms_each_track(
127
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
128
+ ):
129
+ """--interactive prompts per track (skipping the batch prompt)."""
130
+ track = make_djmd_content_item(Title="Old Title")
131
+ mock_db, mock_result = _make_db_and_result([track])
132
+ mock_db_class.return_value = mock_db
133
+ mock_gfc.return_value = mock_result
134
+ mock_confirm.return_value = True
135
+
136
+ from click.testing import CliRunner
137
+ result = CliRunner().invoke(
138
+ edit_command, ["Title", "--replace", "New Title", "--interactive"]
139
+ )
140
+
141
+ assert result.exit_code == 0
142
+ mock_confirm.assert_called_once()
143
+ mock_db.session.commit.assert_called_once()
144
+
145
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
146
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
147
+ def test_piped_stdin_requires_yes_or_dry_run(self, mock_db_class, mock_gfc):
148
+ """Piping track IDs into edit without --yes or --dry-run is rejected."""
149
+ mock_db = Mock()
150
+ mock_db.session = Mock()
151
+ mock_db_class.return_value = mock_db
152
+
153
+ from click.testing import CliRunner
154
+ result = CliRunner().invoke(
155
+ edit_command, ["Title", "--replace", "New Title"], input="12345"
156
+ )
157
+
158
+ assert result.exit_code != 0
159
+
160
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
161
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
162
+ def test_scripting_mode_requires_yes_or_dry_run(self, mock_db_class, mock_gfc):
163
+ """--print=ids without --yes or --dry-run is rejected."""
164
+ mock_db = Mock()
165
+ mock_db.session = Mock()
166
+ mock_db_class.return_value = mock_db
167
+
168
+ from click.testing import CliRunner
169
+ result = CliRunner().invoke(
170
+ edit_command, ["Title", "--replace", "New Title", "--print", "ids"]
171
+ )
172
+
173
+ assert result.exit_code != 0
174
+
175
+ @patch("rekordbox_edit.commands.edit.confirm")
176
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
177
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
178
+ def test_filters_forwarded_to_get_filtered_content(
179
+ self, mock_db_class, mock_gfc, mock_confirm
180
+ ):
181
+ """All global filter flags are forwarded to get_filtered_content."""
182
+ mock_db = Mock()
183
+ mock_db.session = Mock()
184
+ mock_db_class.return_value = mock_db
185
+ mock_result = Mock()
186
+ mock_result.scalars.return_value.all.return_value = []
187
+ mock_gfc.return_value = mock_result
188
+
189
+ from click.testing import CliRunner
190
+ CliRunner().invoke(
191
+ edit_command,
192
+ [
193
+ "Title",
194
+ "--replace", "New Title",
195
+ "--artist", "Bicep",
196
+ "--format", "flac",
197
+ "--match-all",
198
+ "--yes",
199
+ ],
200
+ )
201
+
202
+ kwargs = mock_gfc.call_args.kwargs
203
+ assert kwargs["artists"] == ("Bicep",)
204
+ assert kwargs["formats"] == ("flac",)
205
+ assert kwargs["match_all"] is True
206
+
207
+ @patch("rekordbox_edit.commands.edit.confirm")
208
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
209
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
210
+ def test_interactive_and_yes_skips_all_confirms(
211
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
212
+ ):
213
+ """--interactive + --yes skips both batch and per-track confirms."""
214
+ track = make_djmd_content_item(Title="Old Title")
215
+ mock_db, mock_result = _make_db_and_result([track])
216
+ mock_db_class.return_value = mock_db
217
+ mock_gfc.return_value = mock_result
218
+
219
+ from click.testing import CliRunner
220
+ result = CliRunner().invoke(
221
+ edit_command,
222
+ ["Title", "--replace", "New Title", "--interactive", "--yes"],
223
+ )
224
+
225
+ assert result.exit_code == 0
226
+ mock_confirm.assert_not_called()
227
+ mock_db.session.commit.assert_called_once()
228
+
229
+ @patch("rekordbox_edit.commands.edit.confirm")
230
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
231
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
232
+ def test_print_ids_outputs_edited_track_ids(
233
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
234
+ ):
235
+ """--print=ids outputs the IDs of edited tracks after committing."""
236
+ track = make_djmd_content_item(ID="99999", Title="Old Title")
237
+ mock_db, mock_result = _make_db_and_result([track])
238
+ mock_db_class.return_value = mock_db
239
+ mock_gfc.return_value = mock_result
240
+
241
+ from click.testing import CliRunner
242
+ result = CliRunner().invoke(
243
+ edit_command,
244
+ ["Title", "--replace", "New Title", "--print", "ids", "--yes"],
245
+ )
246
+
247
+ assert result.exit_code == 0
248
+ assert "99999" in result.output
249
+
250
+
251
+ class TestEditCommandPhase3:
252
+ """Phase 3: --match flag for literal find/replace within field value."""
253
+
254
+ @patch("rekordbox_edit.commands.edit.confirm")
255
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
256
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
257
+ def test_match_replaces_substring(
258
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
259
+ ):
260
+ """--match substitutes the pattern within the current value."""
261
+ track = make_djmd_content_item(Title="Dark Matter (feat. Jane)")
262
+ mock_db, mock_result = _make_db_and_result([track])
263
+ mock_db_class.return_value = mock_db
264
+ mock_gfc.return_value = mock_result
265
+ mock_confirm.return_value = True
266
+
267
+ from click.testing import CliRunner
268
+ result = CliRunner().invoke(
269
+ edit_command,
270
+ ["Title", "--match", "feat.", "--replace", "ft."],
271
+ )
272
+
273
+ assert result.exit_code == 0
274
+ assert track.Title == "Dark Matter (ft. Jane)"
275
+ mock_db.session.commit.assert_called_once()
276
+
277
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
278
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
279
+ def test_match_no_match_is_noop(
280
+ self, mock_db_class, mock_gfc, make_djmd_content_item
281
+ ):
282
+ """When --match pattern is not found in current value, track is a no-op."""
283
+ track = make_djmd_content_item(Title="Dark Matter")
284
+ mock_db, mock_result = _make_db_and_result([track])
285
+ mock_db_class.return_value = mock_db
286
+ mock_gfc.return_value = mock_result
287
+
288
+ from click.testing import CliRunner
289
+ result = CliRunner().invoke(
290
+ edit_command,
291
+ ["Title", "--match", "feat.", "--replace", "ft.", "--yes"],
292
+ )
293
+
294
+ assert result.exit_code == 0
295
+ mock_db.session.commit.assert_not_called()
296
+
297
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
298
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
299
+ def test_match_with_none_current_value_is_noop(
300
+ self, mock_db_class, mock_gfc, make_djmd_content_item
301
+ ):
302
+ """--match on a track where the field is None is silently skipped."""
303
+ track = make_djmd_content_item(Title=None)
304
+ mock_db, mock_result = _make_db_and_result([track])
305
+ mock_db_class.return_value = mock_db
306
+ mock_gfc.return_value = mock_result
307
+
308
+ from click.testing import CliRunner
309
+ result = CliRunner().invoke(
310
+ edit_command,
311
+ ["Title", "--match", "feat.", "--replace", "ft.", "--yes"],
312
+ )
313
+
314
+ assert result.exit_code == 0
315
+ mock_db.session.commit.assert_not_called()
316
+
317
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
318
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
319
+ def test_match_treats_pattern_as_literal(
320
+ self, mock_db_class, mock_gfc, make_djmd_content_item
321
+ ):
322
+ """--match treats the pattern as a literal string, not a regex."""
323
+ # "1.0" as a regex would also match "1X0"; as a literal it should not
324
+ track = make_djmd_content_item(Title="Version 1X0")
325
+ mock_db, mock_result = _make_db_and_result([track])
326
+ mock_db_class.return_value = mock_db
327
+ mock_gfc.return_value = mock_result
328
+
329
+ from click.testing import CliRunner
330
+ result = CliRunner().invoke(
331
+ edit_command,
332
+ ["Title", "--match", "1.0", "--replace", "2.0", "--yes"],
333
+ )
334
+
335
+ assert result.exit_code == 0
336
+ mock_db.session.commit.assert_not_called()
337
+
338
+
339
+ class TestEditCommandUnicode:
340
+ """Unicode and multibyte character handling in edit command fields."""
341
+
342
+ @patch("rekordbox_edit.commands.edit.confirm")
343
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
344
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
345
+ def test_wholesale_replace_with_unicode_value(
346
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
347
+ ):
348
+ """--replace accepts and writes multibyte unicode titles correctly."""
349
+ track = make_djmd_content_item(Title="Original Title")
350
+ mock_db, mock_result = _make_db_and_result([track])
351
+ mock_db_class.return_value = mock_db
352
+ mock_gfc.return_value = mock_result
353
+ mock_confirm.return_value = True
354
+
355
+ from click.testing import CliRunner
356
+ result = CliRunner().invoke(
357
+ edit_command,
358
+ ["Title", "--replace", "日本語タイトル"],
359
+ )
360
+
361
+ assert result.exit_code == 0
362
+ assert track.Title == "日本語タイトル"
363
+ mock_db.session.commit.assert_called_once()
364
+
365
+ @patch("rekordbox_edit.commands.edit.confirm")
366
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
367
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
368
+ def test_wholesale_replace_of_unicode_current_value(
369
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
370
+ ):
371
+ """A track with a unicode title can be replaced wholesale."""
372
+ track = make_djmd_content_item(Title="Ü-Bahn Nights (feat. Ångström)")
373
+ mock_db, mock_result = _make_db_and_result([track])
374
+ mock_db_class.return_value = mock_db
375
+ mock_gfc.return_value = mock_result
376
+ mock_confirm.return_value = True
377
+
378
+ from click.testing import CliRunner
379
+ result = CliRunner().invoke(
380
+ edit_command,
381
+ ["Title", "--replace", "U-Bahn Nights (feat. Angstrom)"],
382
+ )
383
+
384
+ assert result.exit_code == 0
385
+ assert track.Title == "U-Bahn Nights (feat. Angstrom)"
386
+ mock_db.session.commit.assert_called_once()
387
+
388
+ @patch("rekordbox_edit.commands.edit.confirm")
389
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
390
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
391
+ def test_match_replace_within_unicode_title(
392
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
393
+ ):
394
+ """--match correctly finds and replaces a substring within a unicode title."""
395
+ track = make_djmd_content_item(Title="夜 feat. 山田太郎")
396
+ mock_db, mock_result = _make_db_and_result([track])
397
+ mock_db_class.return_value = mock_db
398
+ mock_gfc.return_value = mock_result
399
+ mock_confirm.return_value = True
400
+
401
+ from click.testing import CliRunner
402
+ result = CliRunner().invoke(
403
+ edit_command,
404
+ ["Title", "--match", "feat.", "--replace", "ft."],
405
+ )
406
+
407
+ assert result.exit_code == 0
408
+ assert track.Title == "夜 ft. 山田太郎"
409
+ mock_db.session.commit.assert_called_once()
410
+
411
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
412
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
413
+ def test_noop_when_unicode_values_are_equal(
414
+ self, mock_db_class, mock_gfc, make_djmd_content_item
415
+ ):
416
+ """No edit is made when the current unicode value already equals --replace."""
417
+ track = make_djmd_content_item(Title="Ø (Disambiguation)")
418
+ mock_db, mock_result = _make_db_and_result([track])
419
+ mock_db_class.return_value = mock_db
420
+ mock_gfc.return_value = mock_result
421
+
422
+ from click.testing import CliRunner
423
+ result = CliRunner().invoke(
424
+ edit_command,
425
+ ["Title", "--replace", "Ø (Disambiguation)", "--yes"],
426
+ )
427
+
428
+ assert result.exit_code == 0
429
+ mock_db.session.commit.assert_not_called()
430
+
431
+
432
+ class TestEditCommandPhase4:
433
+ """Phase 4: --multi flag to allow batch edits past the single-track guard."""
434
+
435
+ @patch("rekordbox_edit.commands.edit.confirm")
436
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
437
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
438
+ def test_multi_allows_editing_multiple_tracks(
439
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
440
+ ):
441
+ """--multi bypasses the single-track guard and edits all matched tracks."""
442
+ tracks = [
443
+ make_djmd_content_item(Title="Track A (feat. X)"),
444
+ make_djmd_content_item(Title="Track B (feat. Y)"),
445
+ ]
446
+ mock_db, mock_result = _make_db_and_result(tracks)
447
+ mock_db_class.return_value = mock_db
448
+ mock_gfc.return_value = mock_result
449
+ mock_confirm.return_value = True
450
+
451
+ from click.testing import CliRunner
452
+ result = CliRunner().invoke(
453
+ edit_command,
454
+ ["Title", "--match", "feat.", "--replace", "ft.", "--multi"],
455
+ )
456
+
457
+ assert result.exit_code == 0
458
+ assert tracks[0].Title == "Track A (ft. X)"
459
+ assert tracks[1].Title == "Track B (ft. Y)"
460
+ mock_db.session.commit.assert_called_once()
461
+
462
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
463
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
464
+ def test_without_multi_still_aborts_on_multiple(
465
+ self, mock_db_class, mock_gfc, make_djmd_content_item
466
+ ):
467
+ """Without --multi, the single-track guard still aborts on multiple matches."""
468
+ tracks = [
469
+ make_djmd_content_item(Title="Track A"),
470
+ make_djmd_content_item(Title="Track B"),
471
+ ]
472
+ mock_db, mock_result = _make_db_and_result(tracks)
473
+ mock_db_class.return_value = mock_db
474
+ mock_gfc.return_value = mock_result
475
+
476
+ from click.testing import CliRunner
477
+ result = CliRunner().invoke(
478
+ edit_command, ["Title", "--replace", "New", "--yes"]
479
+ )
480
+
481
+ assert result.exit_code != 0
482
+ mock_db.session.commit.assert_not_called()
483
+
484
+ @patch("rekordbox_edit.commands.edit.confirm")
485
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
486
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
487
+ def test_multi_with_yes_skips_confirm(
488
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
489
+ ):
490
+ """--multi --yes edits multiple tracks without prompting."""
491
+ tracks = [
492
+ make_djmd_content_item(Title="Track A"),
493
+ make_djmd_content_item(Title="Track B"),
494
+ ]
495
+ mock_db, mock_result = _make_db_and_result(tracks)
496
+ mock_db_class.return_value = mock_db
497
+ mock_gfc.return_value = mock_result
498
+
499
+ from click.testing import CliRunner
500
+ result = CliRunner().invoke(
501
+ edit_command,
502
+ ["Title", "--replace", "New", "--multi", "--yes"],
503
+ )
504
+
505
+ assert result.exit_code == 0
506
+ mock_confirm.assert_not_called()
507
+ mock_db.session.commit.assert_called_once()
508
+
509
+ @patch("rekordbox_edit.commands.edit.confirm")
510
+ @patch("rekordbox_edit.commands.edit.get_filtered_content")
511
+ @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
512
+ def test_multi_single_track_still_works(
513
+ self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
514
+ ):
515
+ """--multi with only one matching track works fine (guard is not inverted)."""
516
+ track = make_djmd_content_item(Title="Only Track")
517
+ mock_db, mock_result = _make_db_and_result([track])
518
+ mock_db_class.return_value = mock_db
519
+ mock_gfc.return_value = mock_result
520
+ mock_confirm.return_value = True
521
+
522
+ from click.testing import CliRunner
523
+ result = CliRunner().invoke(
524
+ edit_command,
525
+ ["Title", "--replace", "New Title", "--multi"],
526
+ )
527
+
528
+ assert result.exit_code == 0
529
+ assert track.Title == "New Title"
530
+ mock_db.session.commit.assert_called_once()
@@ -985,7 +985,7 @@ wheels = [
985
985
 
986
986
  [[package]]
987
987
  name = "rekordbox-edit"
988
- version = "0.4.0.dev21"
988
+ version = "0.4.0.dev23"
989
989
  source = { editable = "." }
990
990
  dependencies = [
991
991
  { name = "click" },
@@ -1,248 +0,0 @@
1
- """Unit tests for edit command."""
2
-
3
- from unittest.mock import Mock, patch
4
-
5
- import pytest
6
-
7
- from rekordbox_edit.commands.edit import edit_command
8
-
9
-
10
- @pytest.fixture(autouse=True)
11
- def mock_logger():
12
- with patch("rekordbox_edit.commands.edit.logger") as mock_log:
13
- yield mock_log
14
-
15
-
16
- def _make_db_and_result(tracks):
17
- """Return (mock_db_class, mock_get_filtered_content) with given tracks."""
18
- mock_db = Mock()
19
- mock_db.session = Mock()
20
- mock_result = Mock()
21
- mock_result.scalars.return_value.all.return_value = tracks
22
- return mock_db, mock_result
23
-
24
-
25
- class TestEditCommandPhase1:
26
- """Phase 1: Title field, wholesale replace, single-track guard, confirm flow."""
27
-
28
- @patch("rekordbox_edit.commands.edit.confirm")
29
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
30
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
31
- def test_sets_title_and_commits(
32
- self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
33
- ):
34
- """--replace sets the field on the matched track and commits the session."""
35
- track = make_djmd_content_item(Title="Old Title")
36
- mock_db, mock_result = _make_db_and_result([track])
37
- mock_db_class.return_value = mock_db
38
- mock_gfc.return_value = mock_result
39
- mock_confirm.return_value = True
40
-
41
- from click.testing import CliRunner
42
- result = CliRunner().invoke(edit_command, ["Title", "--replace", "New Title"])
43
-
44
- assert result.exit_code == 0
45
- assert track.Title == "New Title"
46
- mock_db.session.commit.assert_called_once()
47
-
48
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
49
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
50
- def test_noop_tracks_are_skipped(
51
- self, mock_db_class, mock_gfc, make_djmd_content_item
52
- ):
53
- """Tracks whose current value already equals --replace are not committed."""
54
- track = make_djmd_content_item(Title="Same Title")
55
- mock_db, mock_result = _make_db_and_result([track])
56
- mock_db_class.return_value = mock_db
57
- mock_gfc.return_value = mock_result
58
-
59
- from click.testing import CliRunner
60
- result = CliRunner().invoke(edit_command, ["Title", "--replace", "Same Title", "--yes"])
61
-
62
- assert result.exit_code == 0
63
- mock_db.session.commit.assert_not_called()
64
-
65
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
66
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
67
- def test_single_track_guard_aborts_on_multiple_matches(
68
- self, mock_db_class, mock_gfc, make_djmd_content_item
69
- ):
70
- """When >1 track would change and --multi is absent, exit with non-zero code."""
71
- tracks = [
72
- make_djmd_content_item(Title="Track A"),
73
- make_djmd_content_item(Title="Track B"),
74
- ]
75
- mock_db, mock_result = _make_db_and_result(tracks)
76
- mock_db_class.return_value = mock_db
77
- mock_gfc.return_value = mock_result
78
-
79
- from click.testing import CliRunner
80
- result = CliRunner().invoke(edit_command, ["Title", "--replace", "New", "--yes"])
81
-
82
- assert result.exit_code != 0
83
- mock_db.session.commit.assert_not_called()
84
-
85
- @patch("rekordbox_edit.commands.edit.confirm")
86
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
87
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
88
- def test_dry_run_does_not_commit(
89
- self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
90
- ):
91
- """--dry-run shows preview but does not write to the database."""
92
- track = make_djmd_content_item(Title="Old Title")
93
- mock_db, mock_result = _make_db_and_result([track])
94
- mock_db_class.return_value = mock_db
95
- mock_gfc.return_value = mock_result
96
-
97
- from click.testing import CliRunner
98
- result = CliRunner().invoke(edit_command, ["Title", "--replace", "New Title", "--dry-run"])
99
-
100
- assert result.exit_code == 0
101
- mock_db.session.commit.assert_not_called()
102
- mock_confirm.assert_not_called()
103
-
104
- @patch("rekordbox_edit.commands.edit.confirm")
105
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
106
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
107
- def test_yes_skips_confirm(
108
- self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
109
- ):
110
- """--yes applies changes without prompting."""
111
- track = make_djmd_content_item(Title="Old Title")
112
- mock_db, mock_result = _make_db_and_result([track])
113
- mock_db_class.return_value = mock_db
114
- mock_gfc.return_value = mock_result
115
-
116
- from click.testing import CliRunner
117
- result = CliRunner().invoke(edit_command, ["Title", "--replace", "New Title", "--yes"])
118
-
119
- assert result.exit_code == 0
120
- mock_confirm.assert_not_called()
121
- mock_db.session.commit.assert_called_once()
122
-
123
- @patch("rekordbox_edit.commands.edit.confirm")
124
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
125
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
126
- def test_interactive_confirms_each_track(
127
- self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
128
- ):
129
- """--interactive prompts per track (skipping the batch prompt)."""
130
- track = make_djmd_content_item(Title="Old Title")
131
- mock_db, mock_result = _make_db_and_result([track])
132
- mock_db_class.return_value = mock_db
133
- mock_gfc.return_value = mock_result
134
- mock_confirm.return_value = True
135
-
136
- from click.testing import CliRunner
137
- result = CliRunner().invoke(
138
- edit_command, ["Title", "--replace", "New Title", "--interactive"]
139
- )
140
-
141
- assert result.exit_code == 0
142
- mock_confirm.assert_called_once()
143
- mock_db.session.commit.assert_called_once()
144
-
145
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
146
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
147
- def test_piped_stdin_requires_yes_or_dry_run(self, mock_db_class, mock_gfc):
148
- """Piping track IDs into edit without --yes or --dry-run is rejected."""
149
- mock_db = Mock()
150
- mock_db.session = Mock()
151
- mock_db_class.return_value = mock_db
152
-
153
- from click.testing import CliRunner
154
- result = CliRunner().invoke(
155
- edit_command, ["Title", "--replace", "New Title"], input="12345"
156
- )
157
-
158
- assert result.exit_code != 0
159
-
160
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
161
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
162
- def test_scripting_mode_requires_yes_or_dry_run(self, mock_db_class, mock_gfc):
163
- """--print=ids without --yes or --dry-run is rejected."""
164
- mock_db = Mock()
165
- mock_db.session = Mock()
166
- mock_db_class.return_value = mock_db
167
-
168
- from click.testing import CliRunner
169
- result = CliRunner().invoke(
170
- edit_command, ["Title", "--replace", "New Title", "--print", "ids"]
171
- )
172
-
173
- assert result.exit_code != 0
174
-
175
- @patch("rekordbox_edit.commands.edit.confirm")
176
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
177
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
178
- def test_filters_forwarded_to_get_filtered_content(
179
- self, mock_db_class, mock_gfc, mock_confirm
180
- ):
181
- """All global filter flags are forwarded to get_filtered_content."""
182
- mock_db = Mock()
183
- mock_db.session = Mock()
184
- mock_db_class.return_value = mock_db
185
- mock_result = Mock()
186
- mock_result.scalars.return_value.all.return_value = []
187
- mock_gfc.return_value = mock_result
188
-
189
- from click.testing import CliRunner
190
- CliRunner().invoke(
191
- edit_command,
192
- [
193
- "Title",
194
- "--replace", "New Title",
195
- "--artist", "Bicep",
196
- "--format", "flac",
197
- "--match-all",
198
- "--yes",
199
- ],
200
- )
201
-
202
- kwargs = mock_gfc.call_args.kwargs
203
- assert kwargs["artists"] == ("Bicep",)
204
- assert kwargs["formats"] == ("flac",)
205
- assert kwargs["match_all"] is True
206
-
207
- @patch("rekordbox_edit.commands.edit.confirm")
208
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
209
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
210
- def test_interactive_and_yes_skips_all_confirms(
211
- self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
212
- ):
213
- """--interactive + --yes skips both batch and per-track confirms."""
214
- track = make_djmd_content_item(Title="Old Title")
215
- mock_db, mock_result = _make_db_and_result([track])
216
- mock_db_class.return_value = mock_db
217
- mock_gfc.return_value = mock_result
218
-
219
- from click.testing import CliRunner
220
- result = CliRunner().invoke(
221
- edit_command,
222
- ["Title", "--replace", "New Title", "--interactive", "--yes"],
223
- )
224
-
225
- assert result.exit_code == 0
226
- mock_confirm.assert_not_called()
227
- mock_db.session.commit.assert_called_once()
228
-
229
- @patch("rekordbox_edit.commands.edit.confirm")
230
- @patch("rekordbox_edit.commands.edit.get_filtered_content")
231
- @patch("rekordbox_edit.commands.edit.Rekordbox6Database")
232
- def test_print_ids_outputs_edited_track_ids(
233
- self, mock_db_class, mock_gfc, mock_confirm, make_djmd_content_item
234
- ):
235
- """--print=ids outputs the IDs of edited tracks after committing."""
236
- track = make_djmd_content_item(ID="99999", Title="Old Title")
237
- mock_db, mock_result = _make_db_and_result([track])
238
- mock_db_class.return_value = mock_db
239
- mock_gfc.return_value = mock_result
240
-
241
- from click.testing import CliRunner
242
- result = CliRunner().invoke(
243
- edit_command,
244
- ["Title", "--replace", "New Title", "--print", "ids", "--yes"],
245
- )
246
-
247
- assert result.exit_code == 0
248
- assert "99999" in result.output