@@ -15,7 +15,6 @@ import generator.engraving.types
|
||||
import generator.parts
|
||||
import generator.pico_artwork
|
||||
import generator.playback
|
||||
import generator.practice.generate
|
||||
import generator.rhythm
|
||||
import generator.score
|
||||
import generator.summary
|
||||
@@ -63,10 +62,9 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
for source in sorted(data_dir.rglob('*.xml')):
|
||||
if source.is_relative_to(data_dir / 'rhythms'):
|
||||
continue
|
||||
if source.parent not in (data_dir / 'practice', data_dir / 'songs'):
|
||||
raise ValueError(f'{source}: MusicXML sources belong directly under data/practice or data/songs')
|
||||
if source.parent != data_dir / 'songs':
|
||||
raise ValueError(f'{source}: MusicXML sources belong directly under data/songs')
|
||||
generator.parts.source_paths(data_dir / 'songs')
|
||||
generator.parts.source_paths(data_dir / 'practice', practice=True)
|
||||
report('loading rhythm snippets')
|
||||
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -74,13 +72,11 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
staged = pathlib.Path(temporary) / 'assets'
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
report('generating performance and practice parts')
|
||||
report('generating performance parts')
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms)
|
||||
practice_count = generator.practice.generate.generate_practice_scores(data_dir / 'practice', staged / 'practice', rhythms=rhythms)
|
||||
generator.score.validate_generated_tree(staged)
|
||||
if args.check:
|
||||
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||
generator.summary.validate_practice_parts(staged / 'practice')
|
||||
report('compiling playback')
|
||||
generator.playback.compile_tree(staged)
|
||||
report('engraving pages')
|
||||
@@ -101,8 +97,7 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
backup.rename(output_dir)
|
||||
raise
|
||||
report('publishing complete')
|
||||
print(f'Generated Product Vamp assets: {performance_count} performance scores, '
|
||||
f'{practice_count} practice parts, {generator.score.count_json_files(output_dir)} total JSON files.')
|
||||
print(f'Generated Product Vamp assets: {performance_count} performance scores.')
|
||||
if args.check:
|
||||
print('Generator check passed.')
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
if section.role == 'accompaniment':
|
||||
plan = plans[section.section_index]
|
||||
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)]
|
||||
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm, melody_octave=True)
|
||||
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm)
|
||||
segment.append(result)
|
||||
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
|
||||
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
|
||||
|
||||
+15
-34
@@ -8,7 +8,7 @@ import sqlite3
|
||||
import tempfile
|
||||
|
||||
APPLICATION_ID = 0x56414D50
|
||||
COMMON_COLUMNS = """
|
||||
SONG_COLUMNS = """
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL,
|
||||
@@ -48,43 +48,24 @@ def metadata(path: pathlib.Path, root: pathlib.Path) -> dict:
|
||||
def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None:
|
||||
if not root.is_dir():
|
||||
raise ValueError(f'{root}: score asset directory not found')
|
||||
connection.executescript(f'''
|
||||
PRAGMA application_id = {APPLICATION_ID};
|
||||
CREATE TABLE songs ({COMMON_COLUMNS}, part TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE practice_exercises ({COMMON_COLUMNS}, exercise_id TEXT NOT NULL, part TEXT NOT NULL) STRICT;
|
||||
CREATE INDEX songs_menu ON songs(title COLLATE NOCASE, composer COLLATE NOCASE, part COLLATE NOCASE);
|
||||
CREATE INDEX practice_menu ON practice_exercises(exercise_id, id);
|
||||
''')
|
||||
practice_parts = {}
|
||||
for category, table, depth in (('songs', 'songs', 1), ('practice', 'practice_exercises', 2)):
|
||||
folder = root / category
|
||||
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
|
||||
raise ValueError(f'{folder}: score asset directory not found')
|
||||
for path in sorted((root / category).rglob('*.json')):
|
||||
folder = root / 'songs'
|
||||
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
|
||||
raise ValueError(f'{folder}: score asset directory not found')
|
||||
with connection:
|
||||
connection.execute('BEGIN')
|
||||
connection.execute(f'PRAGMA application_id = {APPLICATION_ID}')
|
||||
connection.execute(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL) STRICT')
|
||||
connection.execute('CREATE INDEX songs_menu ON songs(title, composer, part, id)')
|
||||
for path in sorted(folder.rglob('*.json')):
|
||||
try:
|
||||
parts = path.relative_to(root / category).parts
|
||||
if len(parts) != depth:
|
||||
raise ValueError(f'unexpected {category} score path')
|
||||
if path.parent != folder:
|
||||
raise ValueError('unexpected songs score path')
|
||||
row = metadata(path, root)
|
||||
if category == 'practice':
|
||||
identity = parts[0]
|
||||
if path.stem not in ('acoustic', 'electric', 'vocal') or row['part'] != path.stem.capitalize():
|
||||
raise ValueError('practice requires an acoustic, electric, or vocal part')
|
||||
identity_metadata = (row['title'], row['composer'], row['key_signature'])
|
||||
group = practice_parts.setdefault(identity, (identity_metadata, set()))
|
||||
if group[0] != identity_metadata:
|
||||
raise ValueError(f'practice parts disagree on metadata: {identity}')
|
||||
group[1].add(path.stem)
|
||||
row.update(id=identity + '-' + path.stem, exercise_id=identity)
|
||||
connection.execute(f'INSERT INTO {table} ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values()))
|
||||
connection.execute(f'INSERT INTO songs ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values()))
|
||||
except (ValueError, KeyError, TypeError, sqlite3.Error) as error:
|
||||
raise ValueError(f'{path}: {error}') from error
|
||||
for identity, (_, parts) in practice_parts.items():
|
||||
if parts != {'acoustic', 'electric', 'vocal'}:
|
||||
raise ValueError(f'practice exercise requires all three parts: {identity}')
|
||||
connection.commit()
|
||||
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
|
||||
raise ValueError('catalog integrity check failed')
|
||||
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
|
||||
raise ValueError('catalog integrity check failed')
|
||||
|
||||
def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None) -> pathlib.Path:
|
||||
if not root.is_dir():
|
||||
|
||||
+16
-20
@@ -12,28 +12,25 @@ import generator.score
|
||||
|
||||
PART_TYPES = ('acoustic', 'electric', 'vocal')
|
||||
|
||||
def source_paths(directory: pathlib.Path, *, practice: bool = False) -> list[pathlib.Path]:
|
||||
def source_paths(directory: pathlib.Path) -> list[pathlib.Path]:
|
||||
if not directory.is_dir():
|
||||
raise ValueError(f'{"practice" if practice else "performance"} source directory not found: {directory}')
|
||||
raise ValueError(f'performance source directory not found: {directory}')
|
||||
paths = sorted(directory.rglob('*.xml'))
|
||||
identities = {}
|
||||
for path in paths:
|
||||
if practice:
|
||||
if path.parent != directory or len(path.stem) != 4 or not path.stem.isascii() or not path.stem.isdecimal():
|
||||
raise ValueError(f'{path}: practice sources require a four-digit filename directly under data/practice')
|
||||
elif path.parent != directory:
|
||||
if path.parent != directory:
|
||||
raise ValueError(f'{path}: performance sources belong directly under data/songs')
|
||||
identity = path.stem if practice else generator.score.slugify(path.stem)
|
||||
identity = generator.score.slugify(path.stem)
|
||||
if identity in identities:
|
||||
raise ValueError(f'{identities[identity]} and {path}: conflicting output path {identity}-acoustic.json')
|
||||
identities[identity] = path
|
||||
return paths
|
||||
|
||||
def prepare_document(document: dict, *, practice: bool = False) -> dict:
|
||||
validate_source(document, practice=practice)
|
||||
def prepare_document(document: dict) -> dict:
|
||||
validate_source(document)
|
||||
return generator.pitch.concert_document(document, octave_transposing=True)
|
||||
|
||||
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
|
||||
def generated_voice(measure: dict, role: str) -> list[dict]:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
if role == 'rest':
|
||||
return [generator.model.make_event('rest', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
|
||||
@@ -55,8 +52,7 @@ def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dic
|
||||
beat_index += 1
|
||||
return events
|
||||
|
||||
def validate_source(document: dict, *, practice: bool = False) -> None:
|
||||
voices = set()
|
||||
def validate_source(document: dict) -> None:
|
||||
for measure in generator.score.source_staff(document)['measures']:
|
||||
for state in [measure, *measure.get('attributes', [])]:
|
||||
if state.get('clef', {}).get('sign') in ('TAB', 'percussion'):
|
||||
@@ -64,26 +60,26 @@ def validate_source(document: dict, *, practice: bool = False) -> None:
|
||||
if 'staff_details' in state:
|
||||
generator.guitar.validate_tuning(state, measure['id'])
|
||||
for voice in measure['voices']:
|
||||
voices.add(voice['id'])
|
||||
for note in voice['notes']:
|
||||
if practice and len(note['pitches']) > 1:
|
||||
raise ValueError(f'{measure["id"]}: practice requires a monophonic melody, not chord stacks')
|
||||
if any('tablature' in p or 'unpitched' in p for p in note['pitches']):
|
||||
raise ValueError(f'{measure["id"]}: authored TAB and percussion are unsupported')
|
||||
if practice and len(voices) > 1:
|
||||
raise ValueError('practice requires one monophonic voice')
|
||||
|
||||
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | tuple[generator.rhythm.Bar, generator.rhythm.Bar] | None = None, *, melody_octave: bool = False) -> dict:
|
||||
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | tuple[generator.rhythm.Bar, generator.rhythm.Bar] | None = None) -> dict:
|
||||
voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
|
||||
if role == 'accompaniment' and part == 'acoustic':
|
||||
voices = [{'id': lane, 'notes': generator.accompaniment.realize(source, bar, previous, lane=lane)} for lane, bar in zip(('upper', 'bass'), (rhythm[1], rhythm[0]))]
|
||||
for voice in voices:
|
||||
for note in voice['notes']:
|
||||
note.update(voice=voice['id'], stem='down' if voice['id'] == 'bass' else 'up')
|
||||
notes = [n for v in voices for n in v['notes']] if voices else generator.accompaniment.realize(source, rhythm, previous, lane='electric') if rhythm is not None else generated_voice(source, role, previous)
|
||||
if voices:
|
||||
notes = [note for voice in voices for note in voice['notes']]
|
||||
elif rhythm is not None:
|
||||
notes = generator.accompaniment.realize(source, rhythm, previous, lane='electric')
|
||||
else:
|
||||
notes = generated_voice(source, role)
|
||||
if role in ('melody', 'vocal'):
|
||||
for note in notes:
|
||||
octaves = 2 if melody_octave and role == 'melody' else 1
|
||||
octaves = 2 if role == 'melody' else 1
|
||||
note['pitches'] = [generator.pitch.shift_pitch(p, 7 * octaves, 12 * octaves) for p in note['pitches']]
|
||||
if role == 'melody':
|
||||
note['_melody'] = True
|
||||
|
||||
+11
-12
@@ -125,18 +125,17 @@ def generate_titles(root: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_R
|
||||
destination = root / 'pico' / 'titles'
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
titles = {}
|
||||
for category in ('songs', 'practice'):
|
||||
for source in sorted((root / category).rglob('*.json')):
|
||||
document = json.loads(source.read_text(encoding='utf-8'))
|
||||
title = document['music']['metadata']['title']
|
||||
if title not in titles:
|
||||
if len(titles) >= MAX_TITLES:
|
||||
raise ValueError('Too many Pico titles')
|
||||
image = renderer.title(title)
|
||||
titles[title] = hashlib.sha256(image).hexdigest()
|
||||
(destination / f'{titles[title]}.mask').write_bytes(image)
|
||||
document['presentation']['title_image'] = titles[title]
|
||||
source.write_text(json.dumps(document, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8')
|
||||
for source in sorted((root / 'songs').rglob('*.json')):
|
||||
document = json.loads(source.read_text(encoding='utf-8'))
|
||||
title = document['music']['metadata']['title']
|
||||
if title not in titles:
|
||||
if len(titles) >= MAX_TITLES:
|
||||
raise ValueError('Too many Pico titles')
|
||||
image = renderer.title(title)
|
||||
titles[title] = hashlib.sha256(image).hexdigest()
|
||||
(destination / f'{titles[title]}.mask').write_bytes(image)
|
||||
document['presentation']['title_image'] = titles[title]
|
||||
source.write_text(json.dumps(document, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8')
|
||||
|
||||
def validate_title(root: pathlib.Path, identifier: str) -> None:
|
||||
if not isinstance(identifier, str) or len(identifier) != 64 or any(character not in '0123456789abcdef' for character in identifier):
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Practice score generation."""
|
||||
@@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.guitar
|
||||
import generator.melody
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.rhythm
|
||||
import generator.scat
|
||||
import generator.score
|
||||
|
||||
def practice_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library) -> dict:
|
||||
document = generator.parts.prepare_document(document, practice=True)
|
||||
plan = generator.accompaniment.plan_section(generator.score.source_staff(document)['measures'], rhythms, electric=False) if part_type == 'acoustic' else None
|
||||
return _practice_document(document, part_type, plan)
|
||||
|
||||
def _practice_document(document: dict, part_type: str, plan: generator.accompaniment.Plan | None) -> dict:
|
||||
if part_type not in generator.parts.PART_TYPES:
|
||||
raise ValueError(f'unknown practice part: {part_type}')
|
||||
staff = generator.score.source_staff(document)
|
||||
previous = generator.accompaniment.carried_chords(staff['measures'])
|
||||
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
|
||||
if part_type == 'acoustic' and plan is None:
|
||||
raise ValueError('acoustic practice requires a rhythm plan')
|
||||
measures = [generator.parts.build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', ''), (plan.acoustic_bass[i], plan.acoustic_upper[i]) if part_type == 'acoustic' else None) for i, m in enumerate(staff['measures'])]
|
||||
for measure in measures:
|
||||
for chord in measure['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
tuplets = generator.score.reidentify(measures, staff.get('tuplets', []))
|
||||
result = generator.score.make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else [])
|
||||
if part_type == 'vocal':
|
||||
generator.scat.add_scat(result)
|
||||
else:
|
||||
generator.acoustic.prepare(result)
|
||||
generator.melody.harmonize(result)
|
||||
generator.guitar.place_guitar(result)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
generator.guitar.place_guitar(result)
|
||||
generator.acoustic.combine(result)
|
||||
return result
|
||||
|
||||
def generate_practice_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int:
|
||||
sources = generator.parts.source_paths(source_dir, practice=True)
|
||||
for path in sources:
|
||||
try:
|
||||
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path), practice=True)
|
||||
plan = generator.accompaniment.plan_section(generator.score.source_staff(document)['measures'], rhythms, electric=False)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}: {error}') from error
|
||||
for part in generator.parts.PART_TYPES:
|
||||
try:
|
||||
generator.score.write_json(output_dir / path.stem / (part + '.json'), _practice_document(document, part, plan))
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}, part {part}: {error}') from error
|
||||
return len(sources) * len(generator.parts.PART_TYPES)
|
||||
@@ -1,76 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
|
||||
def pulse(measure: dict, onset: fractions.Fraction) -> tuple[fractions.Fraction | None, bool]:
|
||||
start, meter = fractions.Fraction(), measure['meter']
|
||||
for change in measure.get('attributes', []):
|
||||
if generator.model.fraction(change['onset']) <= onset and 'meter' in change:
|
||||
start, meter = generator.model.fraction(change['onset']), change['meter']
|
||||
beats = generator.model.beat_lengths(meter)
|
||||
if not beats:
|
||||
return None, False
|
||||
# Pickups occupy the final beats of their meter.
|
||||
phase = onset - start
|
||||
if measure.get('implicit') and not start:
|
||||
phase += max(fractions.Fraction(), sum(beats) - generator.model.fraction(measure['duration']))
|
||||
phase %= sum(beats)
|
||||
for beat in beats:
|
||||
if phase < beat:
|
||||
return beat, phase == 0
|
||||
phase -= beat
|
||||
raise ValueError('invalid meter pulse')
|
||||
|
||||
def syllable(duration: fractions.Fraction, beat: fractions.Fraction | None, onbeat: bool, final: bool, previous: str) -> str:
|
||||
if duration >= (beat if beat is not None else 1):
|
||||
return 'doo'
|
||||
if final:
|
||||
return 'dat'
|
||||
result = 'dit' if beat is not None and not onbeat else 'ba'
|
||||
return 'da' if result == previous else result
|
||||
|
||||
def add_scat(document: dict) -> None:
|
||||
events, pitches = [], {}
|
||||
absolute = fractions.Fraction()
|
||||
boundaries = set()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if measure.get('section_label'):
|
||||
boundaries.add(absolute)
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
for field in ('lyrics', 'lyric', 'lyric_notation'):
|
||||
note.pop(field, None)
|
||||
onset = absolute + generator.model.fraction(note['onset'])
|
||||
for tone in note['pitches']:
|
||||
pitches[tone['id']] = (note, tone, onset)
|
||||
events.append((note, measure, onset))
|
||||
absolute += generator.model.fraction(measure['duration'])
|
||||
attacks = [(n, m, t) for n, m, t in events if n['pitches'] and not n.get('cue') and not n['pitches'][0].get('cue') and not n['pitches'][0].get('tie_from')]
|
||||
previous, previous_end = '', None
|
||||
for index, (note, measure, onset) in enumerate(attacks):
|
||||
tone = note['pitches'][0]
|
||||
end = onset + generator.model.fraction(tone['duration'])
|
||||
chain = []
|
||||
while target := tone.get('tie_to'):
|
||||
continuation, tone, time = pitches[target]
|
||||
chain.append(continuation)
|
||||
end = time + generator.model.fraction(tone['duration'])
|
||||
following = attacks[index + 1][2] if index + 1 < len(attacks) else None
|
||||
final = following is None or following > end or any(onset < b <= following for b in boundaries)
|
||||
if previous_end is None or onset > previous_end or onset in boundaries:
|
||||
previous = ''
|
||||
beat, onbeat = pulse(measure, generator.model.fraction(note['onset']))
|
||||
text = syllable(end - onset, beat, onbeat, final, previous)
|
||||
values = [{'kind': 'syllabic', 'text': 'single', 'attributes': {}}, {'kind': 'text', 'text': text, 'attributes': {}}]
|
||||
verse = {'number': '1', 'syllables': values}
|
||||
if chain:
|
||||
values.append({'kind': 'extend', 'text': '', 'attributes': {'type': 'start'}})
|
||||
verse['extend_to'] = generator.model.rational(end)
|
||||
for position, continuation in enumerate(chain):
|
||||
continuation['lyrics'] = [{'number': '1', 'syllables': [{'kind': 'extend', 'text': '', 'attributes': {'type': 'stop' if position == len(chain) - 1 else 'continue'}}], 'extend_to': generator.model.rational(end)}]
|
||||
note['lyrics'] = [verse]
|
||||
note['lyric'] = text
|
||||
note['lyric_notation'] = {'text': text, 'syllabic': 'single', 'extend': 'start' if chain else ''}
|
||||
previous, previous_end = text, end
|
||||
@@ -5,7 +5,6 @@ import pathlib
|
||||
import typing
|
||||
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
|
||||
def _arranged_staff(document: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
@@ -84,12 +83,3 @@ def validate_expected_performance_summary(expected_path: pathlib.Path, generated
|
||||
if actual[name] != expected[name]:
|
||||
raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally")
|
||||
raise ValueError("performance generator drift")
|
||||
|
||||
def validate_practice_parts(generated_practice_dir: pathlib.Path) -> None:
|
||||
if not generated_practice_dir.exists():
|
||||
return
|
||||
expected_names = {part + '.json' for part in generator.parts.PART_TYPES}
|
||||
for exercise_dir in sorted(path for path in generated_practice_dir.iterdir() if path.is_dir()):
|
||||
json_names = {path.name for path in exercise_dir.glob("*.json")}
|
||||
if json_names != expected_names:
|
||||
raise ValueError(f"practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}")
|
||||
|
||||
Reference in New Issue
Block a user