Skip to content

feat(output): Implement incremental save functionality for packs (fixes #512) - #513

Open
Stoupy51 wants to merge 8 commits into
mcbeet:mainfrom
Stoupy51:main
Open

feat(output): Implement incremental save functionality for packs (fixes #512)#513
Stoupy51 wants to merge 8 commits into
mcbeet:mainfrom
Stoupy51:main

Conversation

@Stoupy51

@Stoupy51 Stoupy51 commented May 9, 2026

Copy link
Copy Markdown

Fixes #512

A new incremental option (default to false) can be given to the output plugin.
either OutputOptions or in beet.yml:

meta:
  output:
    incremental: true

When enabled:

  • Files no longer in the pack are deleted individually instead of wiping the whole directory
  • Possible empty directories left over are pruned bottom-up
  • Files already on disk and unchanged are skipped
  • Only new files (or modified) are written

File watchers will now be less in pain each build.
On my tests, I get faster build time, idk if you want to make it True by default so I put at False.

Bonus: Optimized the list_files() function that was building a Path to compute relative time for each file:
8f4d701

Copilot AI review requested due to automatic review settings May 9, 2026 16:05
@Stoupy51

Stoupy51 commented May 9, 2026

Copy link
Copy Markdown
Author

Damn, didn't thought copilot review would start automatically on another repo beside mines

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds an incremental output mode to the output contrib plugin to avoid wiping and rewriting entire pack directories on every build, addressing performance issues for large projects (issue #512).

Changes:

  • Add incremental: bool to OutputOptions (default False).
  • Implement incremental_save() to delete removed files, prune empty directories, and skip writing unchanged files.
  • Update output() to use incremental saving for non-zipped packs when enabled.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/beet/contrib/output.py
Comment thread src/beet/contrib/output.py
Comment thread src/beet/contrib/output.py Outdated
Comment thread src/beet/contrib/output.py
@Stoupy51

Stoupy51 commented Sep 1, 2026

Copy link
Copy Markdown
Author

should meta.output.incremental option be true by default?

@rx-dev

rx-dev commented Sep 1, 2026

Copy link
Copy Markdown
Member

should meta.output.incremental option be true by default?

Honestly, yes imo. it's an improvement.

@Stoupy51

Stoupy51 commented Sep 1, 2026

Copy link
Copy Markdown
Author

should meta.output.incremental option be true by default?

Honestly, yes imo. it's an improvement.

I was just on a call with airdox, and he says it's not worth it on linux but windows would be good.
We should test that. I can't personally now, I can try at work if I find time tho

@Stoupy51

Stoupy51 commented Sep 1, 2026

Copy link
Copy Markdown
Author

AirDox just tested from my branch on linux:

  • top: meta.output.incremental = False/None | 174ms
  • bottom: meta.output.incremental = True | 31ms
    (Tested multiple time to be sure and got even better (error margin))
image

@rx-dev rx-dev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some things were nitpicks, i realize the original code here used os than I realized though I really do think the pathlib apis are a bit cleaner and has better patterns which would cleanup more of the use-cases here.

There's a lot of functions written out here. Some is good organization, but some feels like over-doing it. Though, each one is described well so perhaps it would be good for future maintenance.

from beet.core.utils import FileSystemPath, log_time_scope
from beet.library.base import Pack, PackFile

ManifestEntry = tuple[int, int, str]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
ManifestEntry = tuple[int, int, str]
type ManifestEntry = tuple[int, int, str]

result: dict[str, tuple[int, int]] = {}

def walk(path: str, prefix: str) -> None:
with os.scandir(path) as entries:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should generally use pathlib to walk the tree.

for dirpath, _dirnames, _filenames in os.walk(directory, topdown=False):
if dirpath != str(directory):
try:
os.rmdir(dirpath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same here, this code pattern is a bit cleaner with pathlib



def content_signature(pack_file: PackFile) -> str:
""" Return a signature that changes whenever the bytes the pack file would write change.

@rx-dev rx-dev Sep 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the formatting of these docstrings should be consistent with the codebase (no leading spaces).

Args:
pack_file (PackFile): The file to sign.
Returns:
str: Either `source:<size>:<mtime>` or `sha1:<digest>`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we use this expanded style for docstrings in the beet repo.

encoding: str = getattr(pack_file, "encoding", None) or "utf-8"
return disk_path.read_text(encoding=encoding) != serialized
return disk_path.read_bytes() != serialized
except Exception:

@rx-dev rx-dev Sep 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This captures way to many states here, what specific exceptions are you trying to guard here?

i would also use with contextlib.suppress pattern for this and specify the specific Exception types that u want to guard

if len(value) == 3
}
except (OSError, ValueError, TypeError, KeyError, IndexError):
return {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same here, not a big fan of how this is laid out tbh. this is a big hard to read, i would refactor this even if it's a bit more verbose.

if incremental is None:
meta_opts = ctx.meta.get("output")
if isinstance(meta_opts, dict):
incremental = bool(meta_opts.get("incremental", False))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

hmmm, i don't think u should be reading ctx.meta here, the way that @configurable works in the first place should auto-fill the opts with stuff from meta.

Comment on lines +196 to +197
target: Path = Path(path) / pack.name
incremental_save(pack, target, cache.get_path(f"manifest:{target}"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
target: Path = Path(path) / pack.name
incremental_save(pack, target, cache.get_path(f"manifest:{target}"))
incremental_save(path / pack.name, target, cache.get_path(f"manifest:{target}"))

path should already be a Path

incremental = bool(meta_opts.get("incremental", False))

paths: list[Path] = [ctx.directory / path for path in opts.directory.entries()]
packs: list[Pack] = list(filter(None, ctx.packs))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
packs: list[Pack] = list(filter(None, ctx.packs))
packs: list[Pack] = [pack for pack in ctx.packs if pack is not None]

matches the symmetry of the above line a bit better imo. (nitpick)

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.

Big projects output is very slow due to rewrite of all files each build

3 participants