splifft 0.0.2__py3-none-any.whl
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.
- splifft/__init__.py +12 -0
- splifft/__main__.py +197 -0
- splifft/config.py +265 -0
- splifft/core.py +551 -0
- splifft/inference.py +137 -0
- splifft/io.py +70 -0
- splifft/models/__init__.py +140 -0
- splifft/models/bs_roformer.py +718 -0
- splifft/models/utils/__init__.py +13 -0
- splifft/models/utils/attend.py +107 -0
- splifft/models/utils/attend_sage.py +136 -0
- splifft/training.py +68 -0
- splifft/utils/mil.py +608 -0
- splifft-0.0.2.dist-info/METADATA +266 -0
- splifft-0.0.2.dist-info/RECORD +18 -0
- splifft-0.0.2.dist-info/WHEEL +4 -0
- splifft-0.0.2.dist-info/entry_points.txt +2 -0
- splifft-0.0.2.dist-info/licenses/LICENSE +21 -0
splifft/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Lightweight utilities for music source separation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# TODO: this should be in the io module and we should use user cache paths to store models
|
|
6
|
+
PATH_MODULE = Path(__file__).parent.parent
|
|
7
|
+
PATH_BASE = PATH_MODULE.parent
|
|
8
|
+
PATH_DATA = PATH_BASE / "data"
|
|
9
|
+
PATH_CONFIG = PATH_DATA / "config"
|
|
10
|
+
PATH_MODELS = PATH_DATA / "models"
|
|
11
|
+
|
|
12
|
+
# NOTE: not re-exporting because our structure is simple enough.
|
splifft/__main__.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.logging import RichHandler
|
|
7
|
+
|
|
8
|
+
logging.basicConfig(level="INFO", format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
pretty_exceptions_show_locals=False,
|
|
13
|
+
help="A CLI for source separation.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# TODO: migrate away from hardcoding.
|
|
19
|
+
_DEFAULT_MODULE_NAME = "splifft.models.bs_roformer"
|
|
20
|
+
_DEFAULT_CLASS_NAME = "BSRoformer"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
def separate(
|
|
25
|
+
mixture_path: Annotated[
|
|
26
|
+
Path,
|
|
27
|
+
typer.Argument(
|
|
28
|
+
...,
|
|
29
|
+
exists=True,
|
|
30
|
+
file_okay=True,
|
|
31
|
+
dir_okay=True,
|
|
32
|
+
readable=True,
|
|
33
|
+
help="Path to the audio file to be separated.",
|
|
34
|
+
),
|
|
35
|
+
],
|
|
36
|
+
config_path: Annotated[
|
|
37
|
+
Path,
|
|
38
|
+
typer.Option(
|
|
39
|
+
"--config",
|
|
40
|
+
exists=True,
|
|
41
|
+
file_okay=True,
|
|
42
|
+
dir_okay=False,
|
|
43
|
+
readable=True,
|
|
44
|
+
help="Path to the model's JSON configuration file.",
|
|
45
|
+
),
|
|
46
|
+
],
|
|
47
|
+
checkpoint_path: Annotated[
|
|
48
|
+
Path,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--checkpoint",
|
|
51
|
+
exists=True,
|
|
52
|
+
file_okay=True,
|
|
53
|
+
dir_okay=False,
|
|
54
|
+
readable=True,
|
|
55
|
+
help="Path to the model's `.pt` or `.ckpt` checkpoint file.",
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
module_name: Annotated[
|
|
59
|
+
str,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--module",
|
|
62
|
+
help="Python module containing the model and configuration class.",
|
|
63
|
+
),
|
|
64
|
+
] = _DEFAULT_MODULE_NAME,
|
|
65
|
+
class_name: Annotated[
|
|
66
|
+
str,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--class",
|
|
69
|
+
help="Name of the model class to load from the module.",
|
|
70
|
+
),
|
|
71
|
+
] = _DEFAULT_CLASS_NAME,
|
|
72
|
+
package_name: Annotated[
|
|
73
|
+
Optional[str],
|
|
74
|
+
typer.Option(
|
|
75
|
+
"--package",
|
|
76
|
+
help=(
|
|
77
|
+
"The package to use as the anchor point from which to resolve the relative import "
|
|
78
|
+
"to an absolute import. This is only required when performing a relative import."
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
] = None,
|
|
82
|
+
output_dir: Annotated[
|
|
83
|
+
Optional[Path],
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--output",
|
|
86
|
+
"-o",
|
|
87
|
+
file_okay=False,
|
|
88
|
+
dir_okay=True,
|
|
89
|
+
writable=True,
|
|
90
|
+
help="Directory to save the separated audio stems.",
|
|
91
|
+
),
|
|
92
|
+
] = None,
|
|
93
|
+
cpu: Annotated[
|
|
94
|
+
bool, typer.Option("--cpu", help="Force processing on CPU, even if CUDA is available.")
|
|
95
|
+
] = False,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Separates an audio file into its constituent stems."""
|
|
98
|
+
import torch
|
|
99
|
+
import torchaudio
|
|
100
|
+
|
|
101
|
+
from .config import Config
|
|
102
|
+
from .core import get_dtype
|
|
103
|
+
from .inference import run_inference_on_file
|
|
104
|
+
from .io import load_weights, read_audio
|
|
105
|
+
from .models import ModelMetadata
|
|
106
|
+
|
|
107
|
+
use_cuda = not cpu and torch.cuda.is_available()
|
|
108
|
+
device = torch.device("cuda" if use_cuda else "cpu")
|
|
109
|
+
logger.info(f"using {device=}")
|
|
110
|
+
|
|
111
|
+
logger.info(f"loading configuration from {config_path=}")
|
|
112
|
+
config = Config.from_file(config_path)
|
|
113
|
+
|
|
114
|
+
logger.info(f"loading model metadata `{class_name}` from module `{module_name}`")
|
|
115
|
+
model_metadata = ModelMetadata.from_module(
|
|
116
|
+
module_name=module_name,
|
|
117
|
+
model_cls_name=class_name,
|
|
118
|
+
model_type=config.model_type,
|
|
119
|
+
package=package_name,
|
|
120
|
+
)
|
|
121
|
+
config_model_concrete = config.model.to_concrete(model_metadata.config)
|
|
122
|
+
model = model_metadata.model(config_model_concrete)
|
|
123
|
+
if config.inference.force_weights_dtype:
|
|
124
|
+
model = model.to(get_dtype(config.inference.force_weights_dtype))
|
|
125
|
+
logger.info(f"loading weights from {checkpoint_path=}")
|
|
126
|
+
model = load_weights(model, checkpoint_path, device).eval()
|
|
127
|
+
if (c := config.inference.compile_model) is not None:
|
|
128
|
+
logger.info("enabled torch compilation")
|
|
129
|
+
model = torch.compile(model, fullgraph=c.fullgraph, dynamic=c.dynamic, mode=c.mode) # type: ignore
|
|
130
|
+
|
|
131
|
+
mixture_paths = mixture_path.glob("*") if mixture_path.is_dir() else [mixture_path]
|
|
132
|
+
for mixture_path in mixture_paths:
|
|
133
|
+
logger.info(f"processing audio file: {mixture_path=}")
|
|
134
|
+
mixture = read_audio(
|
|
135
|
+
mixture_path,
|
|
136
|
+
config.audio_io.target_sample_rate,
|
|
137
|
+
config.audio_io.force_channels,
|
|
138
|
+
device=device,
|
|
139
|
+
)
|
|
140
|
+
output_stems = run_inference_on_file(
|
|
141
|
+
mixture,
|
|
142
|
+
config=config,
|
|
143
|
+
model=model,
|
|
144
|
+
)
|
|
145
|
+
if not config.output:
|
|
146
|
+
return
|
|
147
|
+
curr_output_dir = output_dir or Path("./data/audio/output") / mixture_path.stem
|
|
148
|
+
curr_output_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
for stem_name, stem_data in output_stems.items():
|
|
150
|
+
if config.output.stem_names != "all" and stem_name not in config.output.stem_names:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
output_file = (curr_output_dir / stem_name).with_suffix(f".{config.output.file_format}")
|
|
154
|
+
torchaudio.save(
|
|
155
|
+
output_file,
|
|
156
|
+
stem_data.cpu(),
|
|
157
|
+
mixture.sample_rate,
|
|
158
|
+
format=config.output.file_format,
|
|
159
|
+
encoding=config.output.audio_encoding,
|
|
160
|
+
bits_per_sample=config.output.bit_depth,
|
|
161
|
+
# TODO: compression, backend
|
|
162
|
+
)
|
|
163
|
+
logger.info(f"wrote stem `{stem_name}` to {output_file}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command()
|
|
167
|
+
def debug() -> None:
|
|
168
|
+
"""Prints detailed information about the environment, dependencies, and hardware
|
|
169
|
+
for debugging purposes."""
|
|
170
|
+
import sys
|
|
171
|
+
|
|
172
|
+
logger.info(f"{sys.version=}")
|
|
173
|
+
logger.info(f"{sys.executable=}")
|
|
174
|
+
logger.info(f"{sys.platform=}")
|
|
175
|
+
import platform
|
|
176
|
+
|
|
177
|
+
logger.info(f"{platform.system()=} ({platform.release()})")
|
|
178
|
+
logger.info(f"{platform.machine()=}")
|
|
179
|
+
import torch
|
|
180
|
+
|
|
181
|
+
logger.info(f"{torch.__version__=}")
|
|
182
|
+
logger.info(f"{torch.cuda.is_available()=}")
|
|
183
|
+
if torch.cuda.is_available():
|
|
184
|
+
logger.info(f"{torch.cuda.device_count()=}")
|
|
185
|
+
logger.info(f"{torch.cuda.current_device()=}")
|
|
186
|
+
device = torch.cuda.current_device()
|
|
187
|
+
logger.info(f"{torch.cuda.get_device_name(device)=}")
|
|
188
|
+
logger.info(f"{torch.cuda.get_device_capability(device)=}")
|
|
189
|
+
logger.info(f"{torch.cuda.get_device_properties(device)=}")
|
|
190
|
+
import torchaudio
|
|
191
|
+
|
|
192
|
+
logger.info(f"{torchaudio.__version__=}")
|
|
193
|
+
logger.info(f"{torchaudio.list_audio_backends()=}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
app()
|
splifft/config.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Configuration"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import (
|
|
8
|
+
Annotated,
|
|
9
|
+
Any,
|
|
10
|
+
Hashable,
|
|
11
|
+
Literal,
|
|
12
|
+
Sequence,
|
|
13
|
+
TypeAlias,
|
|
14
|
+
TypeVar,
|
|
15
|
+
Union,
|
|
16
|
+
get_args,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from annotated_types import Len
|
|
20
|
+
from pydantic import (
|
|
21
|
+
AfterValidator,
|
|
22
|
+
BaseModel,
|
|
23
|
+
BeforeValidator,
|
|
24
|
+
ConfigDict,
|
|
25
|
+
Discriminator,
|
|
26
|
+
Field,
|
|
27
|
+
StringConstraints,
|
|
28
|
+
TypeAdapter,
|
|
29
|
+
model_validator,
|
|
30
|
+
)
|
|
31
|
+
from pydantic_core import PydanticCustomError
|
|
32
|
+
from typing_extensions import Self
|
|
33
|
+
|
|
34
|
+
from .core import (
|
|
35
|
+
AudioEncoding,
|
|
36
|
+
BatchSize,
|
|
37
|
+
BitDepth,
|
|
38
|
+
Channels,
|
|
39
|
+
ChunkSize,
|
|
40
|
+
Dtype,
|
|
41
|
+
FileFormat,
|
|
42
|
+
OverlapRatio,
|
|
43
|
+
PaddingMode,
|
|
44
|
+
SampleRate,
|
|
45
|
+
WindowShape,
|
|
46
|
+
)
|
|
47
|
+
from .models import ModelConfigLikeT, ModelType
|
|
48
|
+
from .models import ModelOutputStemName as _ModelOutputStemName
|
|
49
|
+
|
|
50
|
+
# NOTE: we are not using typing.TYPE_CHECKING because pydantic relies on that
|
|
51
|
+
|
|
52
|
+
_PYDANTIC_STRICT_CONFIG = ConfigDict(strict=True, extra="forbid")
|
|
53
|
+
|
|
54
|
+
_Item = TypeVar("_Item", bound=Hashable)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _to_tuple(sequence: Sequence[_Item]) -> tuple[_Item, ...]:
|
|
58
|
+
# this is so json arrays are converted to tuples
|
|
59
|
+
return tuple(sequence)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Tuple = Annotated[tuple[_Item, ...], BeforeValidator(_to_tuple)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validate_unique_sequence(sequence: Sequence[_Item]) -> Sequence[_Item]:
|
|
66
|
+
# e.g. to ensure there are no duplicate stem names
|
|
67
|
+
if len(sequence) != len(set(sequence)):
|
|
68
|
+
raise PydanticCustomError("unique_sequence", "Sequence must contain unique items")
|
|
69
|
+
return sequence
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_S = TypeVar("_S")
|
|
73
|
+
NonEmptyUnique = Annotated[
|
|
74
|
+
_S,
|
|
75
|
+
Len(min_length=1),
|
|
76
|
+
AfterValidator(_validate_unique_sequence),
|
|
77
|
+
Field(json_schema_extra={"unique_items": True}),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
ModelInputStemName: TypeAlias = Literal["mixture"]
|
|
81
|
+
ModelOutputStemName: TypeAlias = Annotated[_ModelOutputStemName, StringConstraints(min_length=1)]
|
|
82
|
+
_INPUT_STEM_NAMES = get_args(ModelInputStemName)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# NOTE: the ideal case would be to use an ADT whose variants are known at "compile" time, e.g. in Rust:
|
|
86
|
+
# enum ModelConfig {
|
|
87
|
+
# BsRoformer { param_x: ..., param_y: ... },
|
|
88
|
+
# Demucs { param_x: ..., params_z: ... },
|
|
89
|
+
# }
|
|
90
|
+
# but downstream users may want to register their own models with different configurations,
|
|
91
|
+
# so a discriminated enum wouldn't work here.
|
|
92
|
+
# so, we effectively let Config.model_config be dyn ModelConfigLike (i.e. dict[str, Any])
|
|
93
|
+
# and defer the validation of the model configuration until it is actually needed instead of doing it eagerly.
|
|
94
|
+
class LazyModelConfig(BaseModel):
|
|
95
|
+
"""A lazily validated model configuration.
|
|
96
|
+
|
|
97
|
+
Note that it is not guaranteed to be fully valid until `to_concrete` is called.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
chunk_size: ChunkSize
|
|
101
|
+
output_stem_names: NonEmptyUnique[Tuple[ModelOutputStemName]]
|
|
102
|
+
|
|
103
|
+
def to_concrete(
|
|
104
|
+
self,
|
|
105
|
+
model_config: type[ModelConfigLikeT],
|
|
106
|
+
*,
|
|
107
|
+
pydantic_config: ConfigDict = ConfigDict(extra="forbid"),
|
|
108
|
+
) -> ModelConfigLikeT:
|
|
109
|
+
"""Validate against a real model configuration and convert to it.
|
|
110
|
+
|
|
111
|
+
:raises pydantic.ValidationError: if extra fields are present in the model configuration
|
|
112
|
+
that doesn't exist in the concrete model configuration.
|
|
113
|
+
"""
|
|
114
|
+
model_config_concrete: ModelConfigLikeT = TypeAdapter(
|
|
115
|
+
type(
|
|
116
|
+
f"{model_config.__name__}Validator",
|
|
117
|
+
(model_config,),
|
|
118
|
+
{"__pydantic_config__": pydantic_config},
|
|
119
|
+
) # needed for https://docs.pydantic.dev/latest/errors/usage_errors/#type-adapter-config-unused
|
|
120
|
+
).validate_python(self.model_dump()) # type: ignore
|
|
121
|
+
return model_config_concrete
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def stem_names(self) -> tuple[ModelInputStemName | ModelOutputStemName, ...]:
|
|
125
|
+
"""Returns the model's input and output stem names."""
|
|
126
|
+
return (*_INPUT_STEM_NAMES, *self.output_stem_names)
|
|
127
|
+
|
|
128
|
+
model_config = ConfigDict(
|
|
129
|
+
strict=True, extra="allow"
|
|
130
|
+
) # extra fields are not validated until `to_concrete`
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AudioIOConfig(BaseModel):
|
|
134
|
+
target_sample_rate: SampleRate = 44100
|
|
135
|
+
force_channels: Channels | None = 2
|
|
136
|
+
"""Whether to force mono or stereo audio input. If None, keep original."""
|
|
137
|
+
|
|
138
|
+
model_config = _PYDANTIC_STRICT_CONFIG
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TorchCompileConfig(BaseModel):
|
|
142
|
+
fullgraph: bool = True
|
|
143
|
+
dynamic: bool = True
|
|
144
|
+
mode: Literal["default", "reduce-overhead", "max-autotune", "max-autotune-no-cudagraphs"] = (
|
|
145
|
+
"reduce-overhead"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class InferenceConfig(BaseModel):
|
|
150
|
+
normalize_input_audio: bool = False
|
|
151
|
+
batch_size: BatchSize = 8
|
|
152
|
+
force_weights_dtype: Dtype | None = None
|
|
153
|
+
use_autocast_dtype: Dtype | None = None
|
|
154
|
+
compile_model: TorchCompileConfig | None = None
|
|
155
|
+
apply_tta: bool = False
|
|
156
|
+
|
|
157
|
+
model_config = _PYDANTIC_STRICT_CONFIG
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ChunkingConfig(BaseModel):
|
|
161
|
+
method: Literal["overlap_add_windowed"] = "overlap_add_windowed"
|
|
162
|
+
overlap_ratio: OverlapRatio = 0.5
|
|
163
|
+
window_shape: WindowShape = "hann"
|
|
164
|
+
padding_mode: PaddingMode = "reflect"
|
|
165
|
+
|
|
166
|
+
model_config = _PYDANTIC_STRICT_CONFIG
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
DerivedStemName: TypeAlias = Annotated[str, StringConstraints(min_length=1)]
|
|
170
|
+
"""The name of a derived stem, e.g. `vocals_minus_drums`."""
|
|
171
|
+
StemName: TypeAlias = Union[ModelOutputStemName, DerivedStemName]
|
|
172
|
+
"""A name of a stem, either a model output stem or a derived stem."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SubtractConfig(BaseModel):
|
|
176
|
+
operation: Literal["subtract"]
|
|
177
|
+
stem_name: StemName
|
|
178
|
+
by_stem_name: StemName
|
|
179
|
+
|
|
180
|
+
model_config = _PYDANTIC_STRICT_CONFIG
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class SumConfig(BaseModel):
|
|
184
|
+
operation: Literal["sum"]
|
|
185
|
+
stem_names: NonEmptyUnique[Tuple[StemName]]
|
|
186
|
+
|
|
187
|
+
model_config = _PYDANTIC_STRICT_CONFIG
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
DerivedStemRule: TypeAlias = Annotated[Union[SubtractConfig, SumConfig], Discriminator("operation")]
|
|
191
|
+
DerivedStemsConfig: TypeAlias = dict[DerivedStemName, DerivedStemRule]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class OutputConfig(BaseModel):
|
|
195
|
+
stem_names: Literal["all"] | NonEmptyUnique[Tuple[StemName]] = "all"
|
|
196
|
+
file_format: FileFormat = "wav"
|
|
197
|
+
audio_encoding: AudioEncoding = "PCM_F"
|
|
198
|
+
bit_depth: BitDepth = 32
|
|
199
|
+
|
|
200
|
+
model_config = _PYDANTIC_STRICT_CONFIG
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# if we were to implement a model registry (which we shouldn't need)
|
|
204
|
+
# heavily consider https://peps.python.org/pep-0487/#subclass-registration
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Config(BaseModel):
|
|
208
|
+
identifier: str
|
|
209
|
+
"""Unique identifier for this configuration"""
|
|
210
|
+
model_type: ModelType
|
|
211
|
+
model: LazyModelConfig
|
|
212
|
+
audio_io: AudioIOConfig = Field(default_factory=AudioIOConfig)
|
|
213
|
+
inference: InferenceConfig = Field(default_factory=InferenceConfig)
|
|
214
|
+
chunking: ChunkingConfig = Field(default_factory=ChunkingConfig)
|
|
215
|
+
derived_stems: DerivedStemsConfig | None = None
|
|
216
|
+
output: OutputConfig = Field(default_factory=OutputConfig)
|
|
217
|
+
experimental: dict[str, Any] | None = None
|
|
218
|
+
"""Any extra experimental configurations outside of the `splifft` core."""
|
|
219
|
+
|
|
220
|
+
@model_validator(mode="after")
|
|
221
|
+
def check_derived_stems(self) -> Self:
|
|
222
|
+
if self.derived_stems is None:
|
|
223
|
+
return self
|
|
224
|
+
# accumulate valid stem names
|
|
225
|
+
existing_stem_names: list[StemName] = list(self.model.stem_names)
|
|
226
|
+
for derived_stem_name, definition in self.derived_stems.items():
|
|
227
|
+
if derived_stem_name in existing_stem_names:
|
|
228
|
+
raise PydanticCustomError(
|
|
229
|
+
"derived_stem_name_conflict",
|
|
230
|
+
"Derived stem `{derived_stem_name}` must not conflict with existing stem names: `{existing_stem_names}`",
|
|
231
|
+
{
|
|
232
|
+
"derived_stem_name": derived_stem_name,
|
|
233
|
+
"existing_stem_names": existing_stem_names,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
required_stems: tuple[StemName, ...] = tuple()
|
|
237
|
+
if isinstance(definition, SubtractConfig):
|
|
238
|
+
required_stems = (definition.stem_name, definition.by_stem_name)
|
|
239
|
+
elif isinstance(definition, SumConfig):
|
|
240
|
+
required_stems = definition.stem_names
|
|
241
|
+
for stem_name in required_stems:
|
|
242
|
+
if stem_name not in existing_stem_names:
|
|
243
|
+
raise PydanticCustomError(
|
|
244
|
+
"invalid_derived_stem",
|
|
245
|
+
"Derived stem `{derived_stem_name}` requires stem `{stem_name}` but is not found in `{existing_stem_names}`",
|
|
246
|
+
{
|
|
247
|
+
"derived_stem_name": derived_stem_name,
|
|
248
|
+
"stem_name": stem_name,
|
|
249
|
+
"existing_stem_names": existing_stem_names,
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
existing_stem_names.append(derived_stem_name)
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def from_file(cls, path: Path) -> Config:
|
|
257
|
+
with open(path, "r") as f:
|
|
258
|
+
config_data = json.load(f)
|
|
259
|
+
return Config.model_validate(config_data)
|
|
260
|
+
|
|
261
|
+
model_config = ConfigDict(
|
|
262
|
+
arbitrary_types_allowed=True, # for .model
|
|
263
|
+
strict=True,
|
|
264
|
+
extra="forbid",
|
|
265
|
+
)
|