Skip to content

Add -l/--limit option to all commands that accept QUERY - #7003

Open
snejus wants to merge 4 commits into
masterfrom
fix-list-option
Open

snejus wants to merge 4 commits into
masterfrom
fix-list-option

Conversation

@snejus

@snejus snejus commented Sep 8, 2026

Copy link
Copy Markdown
Member
  • Added a shared CommonOptionsParser.add_limit_option() in beets.ui so limit parsing and validation now live in one place instead of being reimplemented per command.

  • Wired limit=opts.limit through core query-based commands and many plugins by passing it directly into lib.items() and lib.albums(). This makes --limit a consistent cross-command capability, not something mostly tied to list.

  • Kept the CLI shape compatible by using --limit only on commands where -l was already taken for another meaning, avoiding flag conflicts without adding new command-specific behavior.

  • Updated docs and changelog to reflect the broader architecture change: query-based commands now support built-in limiting, and the old limit plugin guidance is reframed around that built-in behavior.

  • Added parser and integration-oriented test coverage, plus a small ipfs follow-up fix, to make sure the new shared option is passed through safely and behaves consistently across commands.

High-level impact

  • Reviewers can think of this as a consistency pass on the query execution layer: more commands now share the same "match query, optionally cap results, then act" flow.
  • User-facing effect is straightforward: expensive or destructive commands can now be safely scoped with --limit, which makes them easier to preview and use incrementally.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

docs has broken reST inline literal wrapping and limit validation error text is inconsistent for long-only --limit usage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

grug see PR make --limit work everywhere query happen. grug like: one place parse/validate limit, then pass limit=opts.limit down into lib.items() / lib.albums(), so commands + plugins all behave same.

Changes:

  • Add shared CommonOptionsParser.add_limit_option() with validation for non-negative integer limits.
  • Wire limit=opts.limit through core query commands and many plugins (items + albums paths).
  • Update tests + docs/changelog so CLI help and plugin docs match new behavior.
File summaries
File Description
test/ui/test_ui.py add parser-level tests for --limit validation + custom flags
test/ui/commands/test_list.py remove per-command negative-limit test now covered centrally
test/plugins/test_tidal.py assert tidal plugin passes limit through to library queries
docs/reference/cli.rst document --limit across query commands and flag conflict guidance
docs/plugins/zero.rst document zero supports -l/--limit
docs/plugins/tidal.rst document tidalsync supports -l/--limit
docs/plugins/thumbnails.rst document thumbnails query limiting
docs/plugins/spotify.rst document spotifysync query limiting
docs/plugins/replaygain.rst document replaygain query limiting
docs/plugins/replace.rst document replace query limiting
docs/plugins/random.rst document random plugin limit option
docs/plugins/play.rst document play plugin limit option
docs/plugins/missing.rst document missing plugin limit behavior
docs/plugins/metasync.rst document metasync supports limiting
docs/plugins/mbsync.rst document mbsync supports limiting
docs/plugins/mbsubmit.rst document mbsubmit supports limiting
docs/plugins/lyrics.rst document lyrics uses --limit (no -l conflict)
docs/plugins/limit.rst update deprecation guidance now that more commands have built-in limit
docs/plugins/keyfinder.rst document keyfinder supports limiting
docs/plugins/ipfs.rst document ipfs uses --limit due to -l/--list conflict
docs/plugins/info.rst document info uses --limit due to -l/--library conflict
docs/plugins/ftintitle.rst document ftintitle supports limiting
docs/plugins/fetchart.rst document fetchart supports limiting
docs/plugins/export.rst document export uses --limit due to -l/--library conflict
docs/plugins/embedart.rst document embedart/extractart/clearart support limiting
docs/plugins/duplicates.rst document duplicates supports limiting
docs/plugins/deezer.rst document deezerupdate supports limiting
docs/plugins/convert.rst document convert uses --limit due to -l/--link conflict
docs/plugins/chroma.rst document chroma commands support limiting
docs/plugins/bpsync.rst document bpsync supports limiting
docs/plugins/bpm.rst document bpm supports limiting
docs/plugins/bareasc.rst document bareasc supports limiting
docs/plugins/badfiles.rst document badfiles supports limiting
docs/plugins/autobpm.rst document autobpm supports limiting
docs/plugins/acousticbrainz.rst document acousticbrainz supports limiting
docs/plugins/absubmit.rst document absubmit supports limiting
docs/dev/plugins/commands.rst document new CommonOptionsParser --limit helper for plugin devs
docs/changelog.rst add unreleased changelog entry for expanded --limit support
beetsplug/zero.py add limit option + pass limit into lib.items()
beetsplug/titlecase.py add limit option + pass limit into lib.items()
beetsplug/tidal/init.py add limit option + pass limit into lib.items() / lib.albums()
beetsplug/thumbnails.py add limit option + pass limit into lib.albums()
beetsplug/spotify.py add limit option + pass limit into lib.items()
beetsplug/scrub.py add limit option + pass limit into lib.items()
beetsplug/replaygain.py add limit option + pass limit into lib.items() / lib.albums()
beetsplug/replace.py add limit option + pass limit into lib.items()
beetsplug/random.py add limit option + limit query before random selection
beetsplug/play.py add limit option + pass limit into lib.items() / lib.albums()
beetsplug/parentwork.py add limit option + pass limit into lib.items()
beetsplug/missing.py add limit option + thread limit through missing helpers
beetsplug/metasync/init.py add limit option + pass limit into lib.items()
beetsplug/mbsync.py add limit option + pass limit into singleton/album queries
beetsplug/mbsubmit.py add limit option + pass limit into lib.items()
beetsplug/lyrics.py add --limit only + pass limit into lib.items()
beetsplug/limit.py add limit option + pass limit into lib.items() / lib.albums()
beetsplug/keyfinder.py add limit option + pass limit into lib.items()
beetsplug/ipfs.py add --limit only + pass limit into remote album queries + play opts
beetsplug/info.py add --limit only + thread limit into collectors
beetsplug/ftintitle.py add limit option + pass limit into lib.items()
beetsplug/fetchart.py add limit option + pass limit into lib.albums()
beetsplug/export.py add --limit only + thread limit into collectors
beetsplug/embedart.py add limit option + pass limit into lib.items() / lib.albums() + art clear
beetsplug/duplicates.py add limit option + pass limit into lib.items() / lib.albums()
beetsplug/deezer.py add limit option + pass limit into lib.items()
beetsplug/convert.py add --limit only + pass limit into lib.items() / lib.albums()
beetsplug/chroma.py add limit option + pass limit into lib.items() across subcommands
beetsplug/bpsync.py add limit option + pass limit into singleton/album queries
beetsplug/bpm.py add limit option + pass limit into lib.items()
beetsplug/bench.py add limit option + limit album pick used for benchmark seed
beetsplug/bareasc.py add limit option + pass limit into lib.items() / lib.albums()
beetsplug/badfiles.py add limit option + pass limit into lib.items()
beetsplug/autobpm.py add limit option + pass limit into lib.items()
beetsplug/acousticbrainz.py add limit option + pass limit into lib.items()
beetsplug/absubmit.py add limit option + pass limit into lib.items()
beetsplug/_utils/art.py add limit param to art.clear() and apply to query
beets/ui/commands/write.py add limit option + pass limit into write query
beets/ui/commands/update.py add limit option + pass limit into update selection
beets/ui/commands/stats.py add limit option + pass limit into stats query
beets/ui/commands/remove.py add limit option + apply to item/album selection
beets/ui/commands/move.py add limit option + apply to item/album selection
beets/ui/commands/modify.py add limit option + apply to item/album selection
beets/ui/commands/list.py use shared limit option + pass limit into queries
beets/ui/init.py implement shared add_limit_option() + central validation
Review details
  • Files reviewed: 83/83 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread beets/ui/__init__.py
Comment thread docs/plugins/autobpm.rst
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.82927% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.42%. Comparing base (294b15c) to head (7b2127f).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beetsplug/bpsync.py 16.66% 5 Missing ⚠️
beetsplug/ipfs.py 44.44% 5 Missing ⚠️
beetsplug/bareasc.py 25.00% 3 Missing ⚠️
beetsplug/absubmit.py 33.33% 2 Missing ⚠️
beetsplug/acousticbrainz.py 33.33% 2 Missing ⚠️
beetsplug/badfiles.py 33.33% 2 Missing ⚠️
beetsplug/bench.py 33.33% 2 Missing ⚠️
beetsplug/bpm.py 0.00% 2 Missing ⚠️
beetsplug/chroma.py 71.42% 2 Missing ⚠️
beetsplug/deezer.py 0.00% 2 Missing ⚠️
... and 8 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #7003      +/-   ##
==========================================
+ Coverage   77.24%   77.42%   +0.17%     
==========================================
  Files         163      163              
  Lines       21840    21923      +83     
  Branches     3370     3370              
==========================================
+ Hits        16871    16973     +102     
+ Misses       4149     4125      -24     
- Partials      820      825       +5     
Files with missing lines Coverage Δ
beets/ui/__init__.py 83.14% <100.00%> (+0.27%) ⬆️
beets/ui/commands/list.py 100.00% <100.00%> (ø)
beets/ui/commands/modify.py 94.49% <100.00%> (+0.10%) ⬆️
beets/ui/commands/move.py 83.14% <100.00%> (+0.38%) ⬆️
beets/ui/commands/remove.py 96.42% <100.00%> (+0.13%) ⬆️
beets/ui/commands/stats.py 80.48% <100.00%> (+1.00%) ⬆️
beets/ui/commands/write.py 76.47% <100.00%> (+1.47%) ⬆️
beetsplug/_utils/art.py 59.37% <100.00%> (ø)
beetsplug/autobpm.py 87.93% <100.00%> (+0.43%) ⬆️
beetsplug/convert.py 76.19% <100.00%> (+0.13%) ⬆️
... and 35 more
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@semohr semohr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started with the review and think we should probably be a bit more selective about where we add the limit option.

For some of these commands, a limit doesn’t really make sense imo, especially for commands that write/update based on a query, since there’s currently no way to continue the incremental update.

For example, why would I only update/recompute BPM values for the first 100 items matching a query and skip everything else? Without an offset or some other pagination mechanism, the limit feels a bit arbitrary and not particularly useful in some cases.

I’d probably add the limit option only to queries where we’re actually displaying/returning information, or where limiting the results genuinely makes sense. 😅

@beetbox/maintainers What do you think?

dest="exclude_fields",
help="list of fields to exclude from updates",
)
update_cmd.parser.add_limit_option()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think we should have a limit option for update. Seems a bit strange as I'm not sure how to even interpret this 😅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is useful. Update albums added in the last week.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You wouldn't do this using a limit tho but with a query based on added 🤔

Doesnt something like this work?

beet update 'added:-1w..'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with @arsaboo, I do often want to update just a couple of tracks, and often use a query like path::aaa

default=False,
help="write tags even if the existing tags match the database",
)
write_cmd.parser.add_limit_option()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally I don't think we should have limit options for commands that write or update based on a query. This seems a bit like a footgun for users to me.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, this command accepts a query, so why not provide a limit option here?

help="comma-separated list of sources to sync",
)
cmd.parser.add_format_option()
cmd.parser.add_limit_option()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think we should not add the limit option here either.

class TidalSyncCLIOpts(Protocol):
album: bool
force: bool
limit: int | None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here as this updates the library a limit seems like a strange choice.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use this routinely (though with Spotify). Limit spotifysync to 3000 to avoid exhausting API limits.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already handle API rate limits in TIDAL, rate limiting should not be a concern here 🤔

Regardless, using limit as a workaround feels like a hack. If incremental updates are truly necessary, we should implement this as proper pagination. Otherwise, using a fixed limit means that, as long as my library doesn’t change, we’ll always update the same n albums. Here this could result in some albums/items never being updated or am I missing something crucial?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The full command can exclude the recently updated ones, e.g., beet spotifysync -f 'spotify_updated:..-30d' ^genres:"Bollywood Instrumental" ^genres:"Bollywood Unwind" year- '<3000'

I use this daily to keep my popularity information updated while still leaving some daily API calls for imports and other operations.

@semohr semohr Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don’t see why this specifically needs a --limit parameter, feels pretty arbitrary.

If we can trust the spotify docs, rate limits apply at the app level, so if you hit them, everyone using that app (i.e., every beets user) will do too. If this is a real badly documented limit on the ip address level, adding a “max daily requests” option to the spotifysync command/spotify plugin seems like a better, more explicit solution than repurposing --limit as a workaround.

Comment thread beetsplug/absubmit.py
)
# Get items from arguments
items = lib.items(args)
items = lib.items(args, limit=opts.limit)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would skip here too as the plugin is deprecated.

opts.force_refetch or self.config["force"].get(bool),
)

cmd.parser.add_limit_option()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same :)

Comment thread beetsplug/autobpm.py
quiet = self.config["quiet"].get(bool) or opts.quiet
self.calculate_bpm(
list(lib.items(args)),
list(lib.items(args, limit=opts.limit)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a limit would make sense here if we had an offset parameter too for running the bpm update incrementally. I would not add limit for now here tho.

@arsaboo

arsaboo commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

I’d probably add the limit option only to queries where we’re actually displaying/returning information, or where limiting the results genuinely makes sense. 😅

Instead of guessing how the limit option will be used, I think unless we are absolutely sure the liimit option isn't relevant, it doesn't hurt to add it.

@semohr

semohr commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Instead of guessing how the limit option will be used, I think unless we are absolutely sure the liimit option isn't relevant, it doesn't hurt to add it.

I genuinely think we should avoid adding features/options unless we’ve identified at least one concrete use case for them.

The fact that the limit option could be useful doesn’t necessarily mean we should add it now. Once we expose an cli arg, we’re effectively committing to supporting and maintaining it going forward, so I think we should be conservative about expanding the API without a clear need.

If we have a use case for limit, then absolutely, let’s add it for that command. But I don’t think we should add it to every one were it's possible just because we are able to do so.

@snejus

snejus commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

I think accepting QUERY is itself the relevant use case and the clearest boundary for this option. Wherever a command lets users select an arbitrary result set, they should also be able to cap that set with --limit. This also aligns with where the limit plugin was relevant: it applied to commands that accepted a query.

Adding it only to selected query-based commands would make the CLI inconsistent and require users to remember which commands support the cap. The maintenance cost should remain small because parsing and validation are centralized, and the library query API already supports the limit.

My preference is therefore to add --limit everywhere QUERY is accepted, unless a specific command cannot honor it meaningfully.

@semohr

semohr commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

There’s a big difference between using --limit to cap results for read/display/export commands, where it’s a natural and complete operation, and using it on mutation commands like update and write, where it can silently leave your library half‑updated.

Before this change, limiting behavior lived in an opt‑in plugin, so users who wanted that footgun explicitly enabled it. Now it becomes a built‑in flag on core commands, which changes the risk profile.


If you all agree that you really want/need this everywhere, I guess I’m overruled, it still feels like a strange choice to me.

@arsaboo

arsaboo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

My preference is therefore to add --limit everywhere QUERY is accepted, unless a specific command cannot honor it meaningfully.

I also agree, because that would be the true replacement for the current limit plugin. Otherwise, the new implementation only partially replaces the limit function.

There’s a big difference between using --limit to cap results for read/display/export commands, where it’s a natural and complete operation, and using it on mutation commands like update and write, where it can silently leave your library half‑updated.

Users already know this if/when they use the limit option (it is still an opt-in), so nothing happens silently. In that sense, the behavior is unchanged - users don't have to use the limit plugin.

@semohr

semohr commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Explicit doesn't necessarily mean safe. You're right that --limit isn't silent. But let me reframed as: is a generic result cap the right abstraction for a command whose job is to mutate every matching object?

@arsaboo

arsaboo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

But let me reframed as: is a generic result cap the right abstraction for a command whose job is to mutate every matching object?

I would say yes, if that is what the user explicitly wants. Honestly, how is this any different than, say, beet update 'added:-1w..' where the user explicitly chooses to limit the update? limit is just another option in my mind where the user limits by the number.

@semohr

semohr commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

The key difference is that added:-1w.. explicitly defines what gets mutated, while --limit arbitrarily truncates the set of items selected by the database. For reports, the user can inspect the results; for mutations, this silently affects unintended items and makes the operation unpredictable.


Edit:
As a side note, basically every SQL engine warns that using LIMIT in mutations needs care and can be truly unpredictable without an ORDER BY clause. I know we are updating one item at a time currently but the same care should apply imo.

To quote the sqlite docs:

If the UPDATE statement has no ORDER BY clause, then all rows that would be updated in the absence of the LIMIT clause are assembled in an arbitrary order before applying the LIMIT and OFFSET clauses to determine which are actually updated. see
https://www.sqlite.org/lang_update.html

@arsaboo

arsaboo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

This is exactly why I have year- '<3000'. We can just add a line in the docs to explain this.

@semohr

semohr commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

I understand that it works in this specific case. But the issue is that it’s a workaround for a deeper-seated issue. My point is that limits on mutations are generally considered a footgun in SQL systems, and the fact that you need or want a limit here itself points to broader issues (see my opinion on this here)

@snejus

snejus commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

I agree that predictable ordering matters when a limit is used with a mutating command. However, we also want to preserve the existing <10 query syntax. Since that syntax can already be used anywhere QUERY is accepted, excluding --limit from mutation commands would not prevent limited mutations - it would only make the two ways of expressing the same operation inconsistent.

For that reason, I still think --limit should be available everywhere QUERY is accepted. If the current ordering is not sufficiently deterministic, we should define and fix that centrally for both <10 and --limit, rather than varying support command by command.

The SQLite UPDATE ... LIMIT example is not quite equivalent because beets selects library objects and processes them individually, but I agree with the underlying point: the selected subset should be predictable. We can document that limiting operates on the effective query sort order and ensure there is a stable tie-breaker where needed.

@snejus

snejus commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

I don't think the SQLite comparison applies here. Beets does not execute UPDATE ... LIMIT: it selects the limited result set first, then commands update each selected object individually by ID. This is not the unpredictable SQL mutation described in the quoted documentation.

We also intend to preserve the existing <10 query syntax, which can already limit any command accepting QUERY, including update and write. Excluding --limit from those commands would not prevent limited mutations; it would only leave the same behavior available through a less obvious syntax and make the two interfaces inconsistent.

I agree that --limit is not pagination or a precise API request budget, but it does not need to be either. It lets the user intentionally cap one invocation. If ordering needs stronger guarantees, that should be addressed once in the query layer for both <10 and --limit, rather than by selectively omitting the option from commands.

@semohr

semohr commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

I don't think the SQLite comparison applies here. Beets does not execute UPDATE ... LIMIT: it selects the limited result set first, then commands update each selected object individually by ID. This is not the unpredictable SQL mutation described in the quoted documentation.

Yeah that is what I meant by my remark earlier ("... updating one item at a time ...").

If a user applies a limit without an explicit sort, they need to know what the default sort is. That default must be consistent and always the same, so that --limit (and <N) operate on a well‑defined, predictable subset.

Excluding --limit from those commands would not prevent limited mutations; it would only leave the same behavior available through a less obvious syntax and make the two interfaces inconsistent.

From a UX perspective, one could argue that a less obvious syntax here is actually preferable: most (if not all) users shouldn’t be using limits on mutations at all. Allowing it through the general query syntax (<N) is fine as part of our query language, but we don’t need to prominently expose it as a first‑class option on every mutating command.


Again, if you’re really convinced this is the right direction, don’t let my review block you. I just wanted to offer my perspective here.

@arsaboo

arsaboo commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

I do want to note that beets does not use arbitrary ordering. lib.items()/albums() apply a documented default sort (sort_item: artist+ album+ disc+ track+, sort_album: albumartist+ album+) before applying the limit — so the N mutated items are deterministic and consistent.

@snejus

snejus commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

I do need an approval here either way 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants