rekordbox-edit 0.4.0.dev20__tar.gz → 0.4.0.dev22__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 (45) hide show
  1. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/AGENTS.md +16 -2
  2. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/CHANGELOG.md +20 -3
  3. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/PKG-INFO +1 -1
  4. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/pyproject.toml +1 -1
  5. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/cli.py +3 -3
  6. rekordbox_edit-0.4.0.dev22/rekordbox_edit/commands/edit.py +195 -0
  7. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/ruff.toml +6 -2
  8. rekordbox_edit-0.4.0.dev22/tests/commands/test_edit.py +429 -0
  9. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/uv.lock +1 -1
  10. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.agent-style/RULES.md +0 -0
  11. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.agent-style/claude-code.md +0 -0
  12. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/actions/commitizen-bump/action.yml +0 -0
  13. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/actions/commitizen-bump/commitizen-bump.sh +0 -0
  14. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/actions/install/action.yml +0 -0
  15. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/actions/lint/action.yml +0 -0
  16. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/actions/test/action.yml +0 -0
  17. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/workflows/cd.yml +0 -0
  18. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/workflows/ci.yml +0 -0
  19. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/workflows/publish.yml +0 -0
  20. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.github/workflows/release.yml +0 -0
  21. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.gitignore +0 -0
  22. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/.pre-commit-config.yaml +0 -0
  23. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/CLAUDE.md +0 -0
  24. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/CONTRIBUTING.md +0 -0
  25. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/LICENSE +0 -0
  26. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/Makefile +0 -0
  27. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/README.md +0 -0
  28. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/codecov.yml +0 -0
  29. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/__init__.py +0 -0
  30. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/_click.py +0 -0
  31. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/commands/__init__.py +0 -0
  32. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/commands/convert.py +0 -0
  33. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/commands/search.py +0 -0
  34. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/logger.py +0 -0
  35. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/query.py +0 -0
  36. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/rekordbox_edit/utils.py +0 -0
  37. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/renovate.json5 +0 -0
  38. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/tests/__init__.py +0 -0
  39. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/tests/commands/__init__.py +0 -0
  40. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/tests/commands/test_convert.py +0 -0
  41. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/tests/commands/test_search.py +0 -0
  42. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/tests/conftest.py +0 -0
  43. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/tests/test_logger.py +0 -0
  44. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/tests/test_query.py +0 -0
  45. {rekordbox_edit-0.4.0.dev20 → rekordbox_edit-0.4.0.dev22}/tests/test_utils.py +0 -0
@@ -60,11 +60,23 @@ Full directive text, BAD/GOOD example pairs, and rationale per rule: see `.agent
60
60
  ## Code style
61
61
 
62
62
  - Only comment non-obvious code — hidden constraints, subtle invariants, workarounds. If a future reader would not be confused by the comment's absence, leave it out.
63
- - Do not record conversation history, decisions, or task context in comments. That belongs in the commit message or PR description.
63
+ - Do not record conversation history, decisions, or task context in comments.
64
64
  - Keep function and class header comments terse. No more than a paragraph.
65
65
  - Name identifiers so the code explains itself; rename rather than annotate.
66
+ - Follow the "Rule of Three" as a guidepost for factoring out shared logic.
67
+ - Break out long functions in to multiple smaller testable units once they exceed a few hundred lines of code. Command entry points can be a bit longer than other functions.
66
68
  - Emphasis on consistency of patterns across the codebase, except where clarity for other devs would be improved by diverging from a pattern.
67
69
 
70
+ ### Logging and Exceptions
71
+
72
+ Every module should get and use its own `logger` for all logging purposes and follow semantic best practices of log levels:
73
+
74
+ - `debug` logs should be used to record relevant application state at significant logic branches and points of execution. These are not printed to the user unless the `--print debug` option is provided.
75
+ - `info` logs are the primary means of showing output to the user, and should be used sparingly/as necessary.
76
+ - `warning` logs communicate potential issues with application state or dangerous/unexpected circumstances that do not prevent execution from continuing.
77
+ - `error` logs communicate that invalid application or system state was encountered and needs attention, or invalid input was given, and execution cannot continue.
78
+ - `critical` logs communicate that an unexpected/unstable state was reached and execution was interrupted.
79
+
68
80
  ## Testing
69
81
 
70
82
  - Cover user-visible behavior and code paths that pose risk to the database or user file system. Skip tests that exist only to raise coverage.
@@ -76,7 +88,9 @@ Full directive text, BAD/GOOD example pairs, and rationale per rule: see `.agent
76
88
 
77
89
  - Implement the smallest functional slice first, then layer features in follow-up commits. Each commit should be "green" and independently deployable.
78
90
  - Follow Conventional Commits (see `CONTRIBUTING.md` and the schema in `pyproject.toml`). No `Co-Authored-By` trailer.
79
- - Claude commits. The user pushes, opens PRs, and rebases after merges.
91
+ - Commit descriptions should be terse and use active tenses (e.g. "add feature")
92
+ - Commit bodies should describe changes, but should not include agent conversation context, decisions, or plan notes.
93
+ - The agent may commit. But the user always handles pushes, PR creation, and rebases after merges.
80
94
  - Do not commit spec, brainstorming, or design documents (e.g. anything under `docs/superpowers/specs/`).
81
95
 
82
96
  ## Tooling
@@ -1,6 +1,23 @@
1
- ## v0.4.0.dev20 (2026-05-16)
2
-
3
-
1
+ ## v0.4.0.dev22 (2026-05-17)
2
+
3
+
4
+ - feat(edit): add --match for literal find/replace within field value
5
+ - Introduces a _compute_new_value helper and --match option so that
6
+ rbe edit can replace a substring of the current field value instead of
7
+ performing a wholesale replacement; None current values and non-matching
8
+ patterns are treated as no-ops.
9
+ - chore: add max-complexity lint rule
10
+ - docs: update AGENTS.md
11
+ - test: add coverage for --interactive + --yes skipping all confirms
12
+ - feat: adds edit command
13
+ - Adds an `edit` subcommand to the CLI with:
14
+ - Title field support via a required FIELD argument and --replace option
15
+ - Single-track safety guard that aborts when >1 track would be modified
16
+ - --dry-run mode that previews changes without writing to the database
17
+ - --yes flag to skip confirmation, --interactive to confirm per-track
18
+ - Scripting mode (--print=ids) requiring --yes or --dry-run
19
+ - Piped stdin rejection without --yes or --dry-run
20
+ - All global filter flags forwarded to get_filtered_content
4
21
  - docs: add AGENTS.md with project conventions and CLAUDE.md symlink
5
22
  - chore: add renovate config
6
23
  - feat: Add --path and --exact-path search filters
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rekordbox-edit
3
- Version: 0.4.0.dev20
3
+ Version: 0.4.0.dev22
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.dev20"
7
+ version = "0.4.0.dev22"
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"
@@ -7,6 +7,7 @@ import sys
7
7
  import click
8
8
 
9
9
  from rekordbox_edit.commands.convert import convert_command
10
+ from rekordbox_edit.commands.edit import edit_command
10
11
  from rekordbox_edit.commands.search import search_command
11
12
  from rekordbox_edit.logger import get_debug_file_path, setup_logging
12
13
 
@@ -23,9 +24,8 @@ def cli():
23
24
 
24
25
 
25
26
  cli.add_command(search_command)
26
- cli.add_command(
27
- convert_command,
28
- )
27
+ cli.add_command(convert_command)
28
+ cli.add_command(edit_command)
29
29
 
30
30
 
31
31
  def main():
@@ -0,0 +1,195 @@
1
+ """Edit command for rekordbox-edit."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import List
6
+
7
+ import click
8
+ from pyrekordbox import Rekordbox6Database
9
+
10
+ from rekordbox_edit._click import (
11
+ PrintChoice,
12
+ add_click_options,
13
+ global_click_filters,
14
+ print_option,
15
+ track_ids_argument,
16
+ )
17
+ from rekordbox_edit.logger import get_debug_file_path, set_level
18
+ from rekordbox_edit.query import get_filtered_content
19
+ from rekordbox_edit.utils import UserQuit, confirm, print_track_info
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Maps CLI field names to DjmdContent column attribute names.
24
+ FIELD_COLUMNS = {
25
+ "Title": "Title",
26
+ }
27
+
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
+
41
+
42
+ @click.command(
43
+ epilog=f"Debug logs for each run can be found at:\n{get_debug_file_path().parent}"
44
+ )
45
+ @add_click_options([*global_click_filters, print_option])
46
+ @click.option(
47
+ "--interactive",
48
+ "-i",
49
+ is_flag=True,
50
+ help="Confirm each track individually before editing",
51
+ )
52
+ @click.option(
53
+ "--yes",
54
+ "-y",
55
+ is_flag=True,
56
+ help="Skip confirmation prompt",
57
+ )
58
+ @click.option(
59
+ "--dry-run",
60
+ is_flag=True,
61
+ help="Show what would change without writing to the database",
62
+ )
63
+ @click.option(
64
+ "--replace",
65
+ "replace_value",
66
+ required=True,
67
+ help="The new value to write to the field",
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
+ @track_ids_argument
77
+ @click.argument(
78
+ "field",
79
+ type=click.Choice(list(FIELD_COLUMNS.keys()), case_sensitive=False),
80
+ )
81
+ def edit_command(
82
+ field: str,
83
+ replace_value: str,
84
+ match_pattern: str | None,
85
+ dry_run: bool,
86
+ yes: bool,
87
+ interactive: bool,
88
+ track_ids: List[str] | None,
89
+ track_id: List[str] | None,
90
+ playlist: List[str] | None,
91
+ exact_playlist: List[str] | None,
92
+ album: List[str] | None,
93
+ exact_album: List[str] | None,
94
+ artist: List[str] | None,
95
+ exact_artist: List[str] | None,
96
+ title: List[str] | None,
97
+ exact_title: List[str] | None,
98
+ path: List[str] | None,
99
+ exact_path: List[str] | None,
100
+ format: List[str] | None,
101
+ match_all: bool,
102
+ print_opt: PrintChoice | None,
103
+ ):
104
+ """Edit a metadata field on tracks in the RekordBox database."""
105
+
106
+ set_level(print_opt)
107
+
108
+ piped_stdin = False
109
+ if not sys.stdin.isatty():
110
+ stdin_data = sys.stdin.read().strip()
111
+ if stdin_data:
112
+ piped_stdin = True
113
+ track_ids = list(track_ids or []) + stdin_data.split()
114
+
115
+ scripting_mode = print_opt in (PrintChoice.IDS, PrintChoice.SILENT)
116
+ if scripting_mode and not (dry_run or yes):
117
+ raise click.UsageError(
118
+ "--print=ids or --print=silent requires --dry-run or --yes to skip confirmation"
119
+ )
120
+
121
+ if piped_stdin and not (dry_run or yes):
122
+ raise click.UsageError("Piping track IDs into edit requires --dry-run or --yes")
123
+
124
+ db = Rekordbox6Database()
125
+ if not db.session:
126
+ raise RuntimeError("Failed to connect to Rekordbox Database: No Session.")
127
+
128
+ result = get_filtered_content(
129
+ db,
130
+ track_id_args=track_ids,
131
+ track_ids=track_id,
132
+ playlists=playlist,
133
+ exact_playlists=exact_playlist,
134
+ artists=artist,
135
+ exact_artists=exact_artist,
136
+ albums=album,
137
+ exact_albums=exact_album,
138
+ titles=title,
139
+ exact_titles=exact_title,
140
+ paths=path,
141
+ exact_paths=exact_path,
142
+ formats=format,
143
+ match_all=match_all,
144
+ )
145
+ tracks = result.scalars().all()
146
+
147
+ col_name = FIELD_COLUMNS[field]
148
+ edits = []
149
+ for track in tracks:
150
+ current = getattr(track, col_name)
151
+ new_value = _compute_new_value(current, match_pattern, replace_value)
152
+ if new_value is None or new_value == current:
153
+ continue
154
+ edits.append((track, new_value))
155
+
156
+ if not edits:
157
+ logger.info("No changes to make.")
158
+ return
159
+
160
+ if len(edits) > 1:
161
+ raise click.UsageError(
162
+ f"Found {len(edits)} tracks that would be edited. "
163
+ "Refine your filters, or use --dry-run to inspect."
164
+ )
165
+
166
+ print_track_info([t for t, _ in edits])
167
+
168
+ if dry_run:
169
+ if print_opt is PrintChoice.IDS:
170
+ print(" ".join(str(t.ID) for t, _ in edits))
171
+ return
172
+
173
+ if not yes and not interactive:
174
+ try:
175
+ if not confirm(f"Apply {len(edits)} edit(s)?", default=True):
176
+ logger.info("Cancelled.")
177
+ return
178
+ except UserQuit:
179
+ return
180
+
181
+ for track, new_value in edits:
182
+ if interactive and not yes:
183
+ try:
184
+ if not confirm(f" Edit {track.ID}?", default=True):
185
+ continue
186
+ except UserQuit:
187
+ logger.info("Cancelled.")
188
+ return
189
+ setattr(track, col_name, new_value)
190
+
191
+ db.session.commit()
192
+ logger.info(f"Applied {len(edits)} edit(s).")
193
+
194
+ if print_opt is PrintChoice.IDS:
195
+ print(" ".join(str(t.ID) for t, _ in edits))
@@ -44,9 +44,10 @@ select = [
44
44
  "E7",
45
45
  "E9",
46
46
  "F",
47
- "W" ,
48
- # "C901"
47
+ "W",
48
+ "C90",
49
49
  ]
50
+
50
51
  ignore = []
51
52
 
52
53
  # Allow fix for all enabled rules (when `--fix`) is provided.
@@ -56,6 +57,9 @@ unfixable = []
56
57
  # Allow unused variables when underscore-prefixed.
57
58
  dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
58
59
 
60
+ [lint.mccabe]
61
+ max-complexity = 50
62
+
59
63
  [format]
60
64
  # Like Black, use double quotes for strings.
61
65
  quote-style = "double"
@@ -0,0 +1,429 @@
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()
@@ -985,7 +985,7 @@ wheels = [
985
985
 
986
986
  [[package]]
987
987
  name = "rekordbox-edit"
988
- version = "0.4.0.dev20"
988
+ version = "0.4.0.dev22"
989
989
  source = { editable = "." }
990
990
  dependencies = [
991
991
  { name = "click" },