Cleanup
ci / checks (push) Failing after 23m22s

This commit is contained in:
khodowany
2026-10-08 02:13:45 -05:00
parent 300097a37e
commit bc7baea7c4
88 changed files with 18844 additions and 1335 deletions
+10 -34
View File
@@ -9,12 +9,12 @@ import pathlib
import platform
import re
import shutil
import tempfile
import PIL.Image
import PIL.ImageChops
import PIL.features
import generator.cache
import generator.engraving.validation
import generator.theme
@@ -22,9 +22,10 @@ def renderer_identity(fonts, style) -> dict:
root = pathlib.Path(__file__).resolve().parents[1]
versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')}
versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()})
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'performance.py', root / 'harmony.py', root / 'theme.py', root / 'palette.json']
sources = generator.cache.source_identity(('generator.engraving.pipeline', 'generator.printing'))
sources['palette.json'] = hashlib.sha256((root / 'palette.json').read_bytes()).hexdigest()
return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions,
'sources': {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(sources)}}
'sources': sources}
def renderer_fingerprint(fonts, style) -> str:
return hashlib.sha256(json.dumps(renderer_identity(fonts, style), sort_keys=True).encode()).hexdigest()
@@ -46,12 +47,9 @@ def band_fingerprint(notation: str, page, band) -> str:
context = [notation, page.width, page.height, page.supersampling, dataclasses.asdict(band)]
return hashlib.sha256(json.dumps(context, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def has_symlink(path: pathlib.Path) -> bool:
return any(parent.is_symlink() for parent in (path, *path.parents))
def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_pages: set[str] | None = None) -> list[dict] | None:
try:
if has_symlink(entry) or has_symlink(destination):
if generator.cache.has_symlink(entry) or generator.cache.has_symlink(destination):
return None
manifest = entry / 'pages.json'
if manifest.is_symlink():
@@ -76,28 +74,17 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
return None
def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict]) -> None:
if has_symlink(entry.parent):
raise ValueError('engraving cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink(missing_ok=True)
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.engraving_', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
with generator.cache.stage_entry(entry, '.engraving_') as staged:
for page in pages:
shutil.copyfile(destination / page['path'], staged / page['path'])
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
# Identical keys have identical pages; publish complete files, then the manifest.
for name in list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json']:
target = entry / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target, ignore_errors=True)
(staged / name).replace(target)
generator.cache.publish_files(entry, staged, list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json'])
def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.Image.Image | None:
try:
manifest, asset = entry / 'band.json', entry / 'band.png'
if has_symlink(entry) or manifest.is_symlink() or asset.is_symlink():
if generator.cache.has_symlink(entry) or manifest.is_symlink() or asset.is_symlink():
return None
metadata = json.loads(manifest.read_text(encoding='utf-8'))
if not isinstance(metadata, dict) or set(metadata) != {'context', 'width', 'height', 'sha256'}:
@@ -122,19 +109,8 @@ def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.
return None
def store_band(entry: pathlib.Path, key: str, image: PIL.Image.Image) -> None:
if has_symlink(entry.parent):
raise ValueError('engraving cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink()
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.engraving_', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
with generator.cache.stage_entry(entry, '.engraving_') as staged:
image.save(staged / 'band.png', format='PNG')
metadata = {'context': key, 'width': image.width, 'height': image.height, 'sha256': hashlib.sha256((staged / 'band.png').read_bytes()).hexdigest()}
(staged / 'band.json').write_text(json.dumps(metadata), encoding='utf-8')
for name in ('band.png', 'band.json'):
target = entry / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
(staged / name).replace(target)
generator.cache.publish_files(entry, staged, ('band.png', 'band.json'))
+3 -3
View File
@@ -16,7 +16,7 @@ import generator.engraving.spans
import generator.engraving.tablature
import generator.engraving.types
import generator.harmony
import generator.model
import generator.validation
def lyric_text(verse: dict) -> str:
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
@@ -250,7 +250,7 @@ def paginate(systems, document, fonts, style, *, index=None):
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
if index is None:
generator.model.validate_document(document)
generator.validation.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
if not index.measures:
raise generator.engraving.types.EngravingError('score has no measures')
@@ -276,7 +276,7 @@ def layout_systems(document: dict, style: generator.engraving.types.Style, fonts
return tuple(systems)
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
generator.model.validate_document(document)
generator.validation.validate_document(document)
style = generator.engraving.types.score_style(document, style)
index = generator.engraving.notation.ScoreIndex(document)
systems = layout_systems(document, style, fonts, index=index)
+9 -187
View File
@@ -1,195 +1,17 @@
from __future__ import annotations
import collections
import concurrent.futures
import dataclasses
import hashlib
import json
import multiprocessing
import pathlib
import shutil
import tempfile
import time
import PIL.Image
import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
import generator.model
import generator.performance
@dataclasses.dataclass
class GroupResult:
scores: list = dataclasses.field(default_factory=list)
fullscore: int = 0
bodyhits: int = 0
bodies: int = 0
renderedpages: int = 0
rejected: int = 0
timings: dict = dataclasses.field(default_factory=lambda: dict.fromkeys(('layout', 'pagination', 'raster', 'cache'), 0.0))
class BodyBands:
def __init__(self, root, persistent, result, limit=16 * 1024 * 1024):
self.root, self.persistent, self.result, self.limit = root, persistent, result, limit
self.images = collections.OrderedDict()
self.size = 0
self.known = {}
self.root.mkdir(parents=True, exist_ok=True)
def get(self, notation, page, band, fonts):
key = generator.engraving.cache.band_fingerprint(notation, page, band)
if key in self.images:
self.images.move_to_end(key)
self.result.bodyhits += 1
return self.images[key]
started = time.monotonic()
entry = self.root / (key + '.rgb')
image = None
if key in self.known:
if entry.is_symlink() or entry.stat().st_size != page.width * (band.bottom - band.top) * 3:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
data = entry.read_bytes()
if hashlib.sha256(data).hexdigest() != self.known[key]:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
image = PIL.Image.frombytes('RGB', (page.width, band.bottom - band.top), data)
elif self.persistent:
cached = self.persistent / 'notation' / key
image = generator.engraving.cache.restore_band(cached, key, page.width, band.bottom - band.top)
self.result.rejected += int(image is None and (cached.exists() or cached.is_symlink()))
self.result.timings['cache'] += time.monotonic() - started
if image is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_band(page, band.primitives, band.top, band.bottom, fonts)
self.result.timings['raster'] += time.monotonic() - started
self.result.bodies += 1
if self.persistent:
started = time.monotonic()
generator.engraving.cache.store_band(self.persistent / 'notation' / key, key, image)
self.result.timings['cache'] += time.monotonic() - started
else:
self.result.bodyhits += 1
if key not in self.known:
started = time.monotonic()
# Spill finalized pixels, so eviction never repeats rasterization or PNG decoding.
data = image.tobytes()
entry.write_bytes(data)
self.result.timings['cache'] += time.monotonic() - started
self.known[key] = hashlib.sha256(data).hexdigest()
size = image.width * image.height * 3
while self.images and self.size + size > self.limit:
_, old = self.images.popitem(last=False)
self.size -= old.width * old.height * 3
old.close()
self.images[key] = image
self.size += size
return image
def close(self):
for image in self.images.values():
image.close()
self.images.clear()
self.size = 0
def clear_pages(destination):
if destination.is_symlink() or destination.is_file():
destination.unlink()
elif destination.exists():
shutil.rmtree(destination)
def write_bundle(path, document, pages, fingerprint):
entries = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
playback = [entry for entry, page in zip(entries, pages) if page['playback']]
music = document
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': normal, 'playback_pages': playback}}
validate_cached_views(document, pages)
bundle['performance'] = generator.performance.timing(document)
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_cached_views(document, pages):
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style()
systems = None
index = None
scores = {}
audited = set()
result = GroupResult()
with tempfile.TemporaryDirectory(prefix='vamp_notation_') as temporary:
bodies = BodyBands(pathlib.Path(temporary), cache_dir, result)
try:
for path, score_key in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
style = generator.engraving.types.score_style(document, generator.engraving.types.Style())
destination = path.with_suffix('.pages')
clear_pages(destination)
destination.mkdir()
if score_key in scores:
source, pages = scores[score_key]
for page in pages:
shutil.copyfile(source / page['path'], destination / page['path'])
result.fullscore += 1
else:
if systems is None:
started = time.monotonic()
generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
result.timings['layout'] += time.monotonic() - started
started = time.monotonic()
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
generator.engraving.audit.audit_layout(document, layout, family)
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
audited.add(placement)
else:
for page in pages_layout + playback_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
result.timings['pagination'] += time.monotonic() - started
pages = []
rendered = {}
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
if page.primitives in rendered:
name, digest = rendered[page.primitives]
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
continue
bands = generator.engraving.raster.page_bands(page)
if bands is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_page(page, family)
else:
body = bodies.get(notation, page, bands[1], family)
started = time.monotonic()
image = generator.engraving.raster.compose_page(page, family, bands, body)
target = destination / (('playback-' if is_playback else '') + f'{page.number:04}.png')
with image:
image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1
digest = hashlib.sha256(target.read_bytes()).hexdigest()
rendered[page.primitives] = target.name, digest
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages))
if progress and len(result.scores) % 50 == 0:
print(f'Engraving body {notation[:12]}: {len(result.scores)}/{len(paths)} scores, fullscore {result.fullscore}, bodyhits {result.bodyhits}, bodies {result.bodies}, renderedpages {result.renderedpages}', flush=True)
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
finally:
bodies.close()
return result
import generator.engraving.worker
import generator.validation
def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.Path | None = None, jobs: int = 1, progress: bool = False) -> set[str]:
if type(jobs) is not int or not 1 <= jobs <= 16:
@@ -211,22 +33,22 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True)
elif progress:
print('Engraving cache disabled', flush=True)
totals = GroupResult()
totals = generator.engraving.worker.GroupResult()
missing, rejected, completed = 0, 0, 0
groups = {}
for path in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
generator.model.validate_document(document)
generator.validation.validate_document(document)
score_key = generator.engraving.cache.score_fingerprint(document, renderer)
destination = path.with_suffix('.pages')
clear_pages(destination)
generator.engraving.worker.clear_pages(destination)
entry = cache_dir / 'fullscore' / score_key if cache_dir else None
present = entry is not None and (entry.exists() or entry.is_symlink())
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
if pages is not None:
try:
validate_cached_views(document, pages)
generator.engraving.worker.validate_cached_views(document, pages)
except (ValueError, TypeError, KeyError):
pages = None
if pages is None:
@@ -235,7 +57,7 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
notation = generator.engraving.cache.notation_fingerprint(document, renderer)
groups.setdefault(notation, []).append((path, score_key))
else:
write_bundle(path, document, pages, fingerprint)
generator.engraving.worker.write_bundle(path, document, pages, fingerprint)
totals.fullscore += 1
completed += 1
except ValueError as error:
@@ -272,10 +94,10 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
jobs = min(jobs, len(groups))
if jobs <= 1:
for notation, group in groups.items():
finish(engrave_group(notation, group, root, fonts, cache_dir, fingerprint, progress))
finish(generator.engraving.worker.engrave_group(notation, group, root, fonts, cache_dir, fingerprint, progress))
else:
with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = [pool.submit(engrave_group, notation, group, root, fonts, cache_dir, fingerprint, progress) for notation, group in groups.items()]
futures = [pool.submit(generator.engraving.worker.engrave_group, notation, group, root, fonts, cache_dir, fingerprint, progress) for notation, group in groups.items()]
for future in concurrent.futures.as_completed(futures):
finish(future.result())
if progress:
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import collections
import dataclasses
import hashlib
import json
import pathlib
import shutil
import tempfile
import time
import PIL.Image
import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
import generator.performance
import generator.validation
@dataclasses.dataclass
class GroupResult:
scores: list = dataclasses.field(default_factory=list)
fullscore: int = 0
bodyhits: int = 0
bodies: int = 0
renderedpages: int = 0
rejected: int = 0
timings: dict = dataclasses.field(default_factory=lambda: dict.fromkeys(('layout', 'pagination', 'raster', 'cache'), 0.0))
class BodyBands:
def __init__(self, root, persistent, result, limit=16 * 1024 * 1024):
self.root, self.persistent, self.result, self.limit = root, persistent, result, limit
self.images = collections.OrderedDict()
self.size = 0
self.known = {}
self.root.mkdir(parents=True, exist_ok=True)
def get(self, notation, page, band, fonts):
key = generator.engraving.cache.band_fingerprint(notation, page, band)
if key in self.images:
self.images.move_to_end(key)
self.result.bodyhits += 1
return self.images[key]
started = time.monotonic()
entry = self.root / (key + '.rgb')
image = None
if key in self.known:
if entry.is_symlink() or entry.stat().st_size != page.width * (band.bottom - band.top) * 3:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
data = entry.read_bytes()
if hashlib.sha256(data).hexdigest() != self.known[key]:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
image = PIL.Image.frombytes('RGB', (page.width, band.bottom - band.top), data)
elif self.persistent:
cached = self.persistent / 'notation' / key
image = generator.engraving.cache.restore_band(cached, key, page.width, band.bottom - band.top)
self.result.rejected += int(image is None and (cached.exists() or cached.is_symlink()))
self.result.timings['cache'] += time.monotonic() - started
if image is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_band(page, band.primitives, band.top, band.bottom, fonts)
self.result.timings['raster'] += time.monotonic() - started
self.result.bodies += 1
if self.persistent:
started = time.monotonic()
generator.engraving.cache.store_band(self.persistent / 'notation' / key, key, image)
self.result.timings['cache'] += time.monotonic() - started
else:
self.result.bodyhits += 1
if key not in self.known:
started = time.monotonic()
# Spill finalized pixels, so eviction never repeats rasterization or PNG decoding.
data = image.tobytes()
entry.write_bytes(data)
self.result.timings['cache'] += time.monotonic() - started
self.known[key] = hashlib.sha256(data).hexdigest()
size = image.width * image.height * 3
while self.images and self.size + size > self.limit:
_, old = self.images.popitem(last=False)
self.size -= old.width * old.height * 3
old.close()
self.images[key] = image
self.size += size
return image
def close(self):
for image in self.images.values():
image.close()
self.images.clear()
self.size = 0
def clear_pages(destination):
if destination.is_symlink() or destination.is_file():
destination.unlink()
elif destination.exists():
shutil.rmtree(destination)
def write_bundle(path, document, pages, fingerprint):
entries = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
playback = [entry for entry, page in zip(entries, pages) if page['playback']]
music = document
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': normal, 'playback_pages': playback}}
validate_cached_views(document, pages)
bundle['performance'] = generator.performance.timing(document)
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_cached_views(document, pages):
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style()
systems = None
index = None
scores = {}
audited = set()
result = GroupResult()
with tempfile.TemporaryDirectory(prefix='vamp_notation_') as temporary:
bodies = BodyBands(pathlib.Path(temporary), cache_dir, result)
try:
for path, score_key in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
style = generator.engraving.types.score_style(document, generator.engraving.types.Style())
destination = path.with_suffix('.pages')
clear_pages(destination)
destination.mkdir()
if score_key in scores:
source, pages = scores[score_key]
for page in pages:
shutil.copyfile(source / page['path'], destination / page['path'])
result.fullscore += 1
else:
if systems is None:
started = time.monotonic()
generator.validation.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
result.timings['layout'] += time.monotonic() - started
started = time.monotonic()
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
generator.engraving.audit.audit_layout(document, layout, family)
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
audited.add(placement)
else:
for page in pages_layout + playback_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
result.timings['pagination'] += time.monotonic() - started
pages = []
rendered = {}
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
if page.primitives in rendered:
name, digest = rendered[page.primitives]
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
continue
bands = generator.engraving.raster.page_bands(page)
if bands is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_page(page, family)
else:
body = bodies.get(notation, page, bands[1], family)
started = time.monotonic()
image = generator.engraving.raster.compose_page(page, family, bands, body)
target = destination / (('playback-' if is_playback else '') + f'{page.number:04}.png')
with image:
image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1
digest = hashlib.sha256(target.read_bytes()).hexdigest()
rendered[page.primitives] = target.name, digest
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages))
if progress and len(result.scores) % 50 == 0:
print(f'Engraving body {notation[:12]}: {len(result.scores)}/{len(paths)} scores, fullscore {result.fullscore}, bodyhits {result.bodyhits}, bodies {result.bodies}, renderedpages {result.renderedpages}', flush=True)
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
finally:
bodies.close()
return result