diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c8772a4..8a9cc876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Run every pre-commit hook run: uv run pre-commit run --all-files --show-diff-on-failure --color always + - name: Check the committed icons match the mark + run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always + tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 3cdb1383..f94d5553 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -126,7 +126,7 @@ jobs: venv_python=.venv-build/bin/python fi "$venv_python" -m pip install --upgrade pip - "$venv_python" -m pip install ".[build]" + "$venv_python" -m pip install ".[build]" --group assets - name: Build the bundle (Linux) if: runner.os == 'Linux' diff --git a/.gitignore b/.gitignore index 88d53808..585ea73d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,25 +4,20 @@ __pycache__/ .ipynb_checkpoints/ .mypy_cache/ .pytest_cache/ +.ruff_cache/ .venv/ .venv-build/ .vscode/ -bin/ -build/ dist/ wheels/ -!scripts/**/build/ +/bin/ +/build/ sampletones !src/sampletones !tests/sampletones -**/*.idea -**/*.vscode/** -**/*.ipynb_checkpoints/** -**/*__pycache__/** - *.pyc *.pyo *.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 570b4496..0f67e871 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -91,6 +91,16 @@ repos: require_serial: true exclude: ^(tests/) + - id: icons + name: icons + entry: uv run python scripts/assets/icons.py + language: system + files: ^src/sampletones_assets/(icons|mark)/ + pass_filenames: false + verbose: true + stages: + - pre-push + - id: pytest name: pytest entry: make test diff --git a/CHANGELOG.md b/CHANGELOG.md index 7114660c..70f9edbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Improved Sequencer module playback. * Added song export to WAV/MP3. * Added tracker selection operations. +* Added a _SampleToNES_ logo. ## v0.3.0 [2026-07-31] diff --git a/LICENSE b/LICENSE index aa541094..5918803c 100644 --- a/LICENSE +++ b/LICENSE @@ -22,7 +22,9 @@ SOFTWARE. --- -The MIT license above covers the SampleToNES source code only. +The MIT license above covers the SampleToNES source code and the application +icons under `src/sampletones_assets/icons/`, which are drawn from the mark +declared in `src/sampletones_assets/mark/`. Font files bundled under `src/sampletones_assets/fonts/` are the work of third parties and remain under their own licenses (SIL Open Font License 1.1 and the diff --git a/Makefile b/Makefile index 9997def7..7ab6b93d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test \ - ftm-samples check-import-boundary check-tag-names check-unused-tags \ + ftm-samples icons check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -70,6 +70,7 @@ help: @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q) @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) + @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @echo $(Q) make lint - Run linting (pylint, mypy)$(Q) @@ -78,6 +79,7 @@ help: setup: $(SETUP_ENV) uv sync --group dev $(if $(GPU_EXTRA),--extra $(GPU_EXTRA),) + $(MAKE) icons $(SETUP_ENV) uv tool install --force $(if $(GPU_EXTRA),".[$(GPU_EXTRA)]",.) install: @@ -109,6 +111,9 @@ ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm ftm-samples: uv run python -m pytest tests/integration/famitracker +icons: + uv run --group assets python scripts/assets/icons.py + check-import-boundary: uv run scripts/checks/import_boundary.py --all diff --git a/README.md b/README.md index 9688052a..9d505bfb 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ [](https://pypi.org/project/sampletones/) [](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) +
+
The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**.
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 65d37c0f..99cd24b3 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -109,3 +109,18 @@ The published bundles are **CPU-only**: CuPy, the CUDA runtime and the NVIDIA li
are proprietary, and their EULA reserves redistribution to NVIDIA. GPU acceleration comes
from installing _SampleToNES_ from PyPI with the `gpu` extra, which fetches CuPy and the
CUDA components from their publishers straight to your machine.
+
+## Build-time tooling
+
+The application icons are drawn by `sampletones_assets.mark` and rasterized with
+[Pillow](https://pypi.org/project/Pillow/), which is under the
+[MIT-CMU license](https://github.com/python-pillow/Pillow/blob/main/LICENSE). Pillow belongs
+to the `assets` dependency group alone, so `pip`/`uv` installs it on the machine that
+generates the icons: it stays out of the wheel's dependency set, and PyInstaller is told to
+leave it out of the bundles. Both distributions carry the finished icon files, so Pillow's
+attribution clause — a condition on redistributing Pillow itself — rests with the build
+environment.
+
+The icons (`sampletones.svg`, `sampletones.png` and the multi-resolution `sampletones.ico`)
+are original _SampleToNES_ artwork and fall under the MIT License together with the rest of
+the source.
diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md
index 7d553cb0..e0de8283 100644
--- a/docs/development/dependencies.md
+++ b/docs/development/dependencies.md
@@ -51,6 +51,27 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser
`jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them.
+## Application icon
+
+The icon suite in `src/sampletones_assets/icons` is generated from the mark declared beside it in
+`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colours and rasterization
+settings, validated as a `Mark`, and `template.svg` is the vector the rendered geometry fills. The
+package writes the whole suite — the vector `sampletones.svg` and the rasters the application
+ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py`
+points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the
+`assets` dependency group.
+
+The whole suite is committed, so a plain checkout carries the icons the application opens its window
+with, and every wheel, bundle and test run finds them where they lie. `make icons` writes them again
+from the mark, and the `icons` pre-push hook writes them for a push that touches either directory,
+holding the committed files to what the mark describes. CI runs that same hook.
+
+Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that:
+`pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is
+installed, and PyInstaller follows that import into the bundle. The application reads its icons as
+files, so the exclusion spares every bundle Pillow's extension modules and the imaging libraries
+that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to it.
+
## Linux (standalone executable)
Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list.
diff --git a/pyproject.toml b/pyproject.toml
index 99ca6af6..ac47fab5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -75,7 +75,9 @@ gpu-cuda11 = [
]
[dependency-groups]
+assets = ["pillow>=11,<13"]
dev = [
+ { include-group = "assets" },
"black==26.5.1",
"isort==8.0.1",
"mypy==2.1.0",
@@ -129,6 +131,7 @@ addopts = "--import-mode=importlib"
[tool.coverage.run]
source = [
"sampletones_application",
+ "sampletones_assets",
"sampletones_core",
"sampletones_shared",
"sampletones_synthesis",
@@ -143,6 +146,7 @@ python_version = "3.12"
files = [
"src/sampletones",
"src/sampletones_application",
+ "src/sampletones_assets",
"src/sampletones_core",
"src/sampletones_shared",
"src/sampletones_synthesis",
diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py
new file mode 100755
index 00000000..c6484eb9
--- /dev/null
+++ b/scripts/assets/icons.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+
+"""
+Writes the application icon suite from the packaged mark definition.
+
+The mark, its template and the code drawing them live in `sampletones_assets/mark`; this
+script points them at the directory the icons are shipped from.
+
+Usage:
+ python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons
+"""
+
+import argparse
+import sys
+from pathlib import Path
+from typing import Final, Sequence
+
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.suite import write_icon_suite
+
+REPOSITORY_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
+ICONS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_assets" / "icons"
+
+
+def main(argv: Sequence[str]) -> int:
+ """Writes the icon suite and reports each file it produced."""
+
+ parser = argparse.ArgumentParser(
+ description="Write the application icon suite from the mark definition.",
+ )
+ parser.add_argument(
+ "--directory",
+ type=Path,
+ default=ICONS_DIRECTORY,
+ help="directory receiving the icon files",
+ )
+ arguments = parser.parse_args(list(argv))
+
+ for path in write_icon_suite(arguments.directory, Mark.load()):
+ print(f"Wrote {path}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/calibration.py b/scripts/calibration.py
index 870735ea..c129d180 100644
--- a/scripts/calibration.py
+++ b/scripts/calibration.py
@@ -15,8 +15,8 @@
GeneratorName,
SpectrumMethod,
)
-from sampletones_core.paths import USER_PATH_DOCUMENTS
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import USER_PATH_DOCUMENTS
DEFAULT_OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "calibration"
DEFAULT_METHODS: Final[str] = f"{SpectrumMethod.FFT.value},{SpectrumMethod.CQT.value}"
diff --git a/scripts/checks/language_keys.py b/scripts/checks/language_keys.py
index 89a5f9e6..d6a55601 100755
--- a/scripts/checks/language_keys.py
+++ b/scripts/checks/language_keys.py
@@ -39,7 +39,7 @@
from sampletones_shared.meta.source.modules import discover_modules, module_name
from sampletones_shared.meta.source.packages import package_directory
from sampletones_shared.meta.source.values import EnumMembers, EnumTable
-from sampletones_shared.paths import SOURCE_ROOT
+from sampletones_shared.paths.source import SOURCE_ROOT
EnumPredicate = Callable[[object], bool]
diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py
index 32f6f8c5..002c9bf8 100755
--- a/scripts/checks/palette_colors.py
+++ b/scripts/checks/palette_colors.py
@@ -28,7 +28,7 @@
from sampletones_shared.meta.source.modules import SourceModule, discover_modules
from sampletones_shared.meta.source.nodes import terminal_name
from sampletones_shared.meta.source.packages import package_directory
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application")
diff --git a/scripts/checks/unused_tags.py b/scripts/checks/unused_tags.py
index 14931ae4..c2a87b5c 100755
--- a/scripts/checks/unused_tags.py
+++ b/scripts/checks/unused_tags.py
@@ -22,7 +22,7 @@
from sampletones_shared.meta.source.modules import SourceModule, discover_modules
from sampletones_shared.meta.source.packages import package_directory
from sampletones_shared.meta.source.references import count_identifier_loads
-from sampletones_shared.paths import REPOSITORY_ROOT, SOURCE_ROOT
+from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT
TAGS_PACKAGE: Final[Path] = package_directory("sampletones_application", "tags")
REFERENCE_ROOTS: Final[Tuple[Path, ...]] = (
diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py
index 843e3efd..08fd64db 100644
--- a/scripts/ci/checks/bundle.py
+++ b/scripts/ci/checks/bundle.py
@@ -16,6 +16,9 @@
"THIRD-PARTY-LICENSES.txt",
)
+INTERNAL_DIRECTORY: Final[str] = "_internal"
+BUILD_TOOLS: Final[Sequence[str]] = ("PIL",)
+
def launcher_path(bundle: Path, *, system: str) -> Path:
"""The executable a built bundle offers on the platform it was built for."""
@@ -28,8 +31,19 @@ def missing_notices(bundle: Path) -> List[str]:
return [name for name in REQUIRED_NOTICES if not (bundle / name).is_file()]
+def carried_build_tools(bundle: Path) -> List[str]:
+ """The build-time packages found in a bundle, which the notices place on the build machine.
+
+ A bundle carries the application and its runtime dependencies. Tooling that only draws the
+ assets belongs to the machine that builds it, so finding it here means the notices describe
+ a different set of components than the bundle ships.
+ """
+ directories = (bundle, bundle / INTERNAL_DIRECTORY)
+ return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)]
+
+
def main(argv: Sequence[str]) -> int:
- """Confirm a built bundle ships its notices and that its launcher starts."""
+ """Confirm a built bundle ships its notices, holds to them, and that its launcher starts."""
parser = argparse.ArgumentParser(
description="Verify a built bundle before it is archived.",
)
@@ -46,6 +60,11 @@ def main(argv: Sequence[str]) -> int:
print(f"::error::Bundle {bundle} is missing {', '.join(absent)}")
return 1
+ carried = carried_build_tools(bundle)
+ if carried:
+ print(f"::error::Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}")
+ return 1
+
launcher = launcher_path(bundle, system=platform.system())
if not launcher.is_file():
print(f"::error::Bundle {bundle} offers no launcher at {launcher}")
diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh
index 75629a66..3fd6327e 100755
--- a/scripts/linux/build/build.sh
+++ b/scripts/linux/build/build.sh
@@ -28,6 +28,7 @@ else
fi
bash "$SCRIPT_DIR/preflight.sh" "$@"
+bash "$SCRIPT_DIR/icons.sh"
if [[ -e "${PROJECT_DIR}/bin/sampletones" ]]; then
echo "Removing the previous artifact: ./bin/sampletones"
@@ -44,6 +45,7 @@ echo "Building executable..."
--add-data "src/sampletones_assets/fonts:assets/fonts" \
--add-data "src/sampletones_config:config" \
--copy-metadata sampletones \
+ --exclude-module PIL \
"${RELEASE_HOOK_ARGS[@]}" \
"src/sampletones/__main__.py"
diff --git a/scripts/linux/build/icons.sh b/scripts/linux/build/icons.sh
new file mode 100644
index 00000000..df7bf35b
--- /dev/null
+++ b/scripts/linux/build/icons.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+set -e
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+. "$SCRIPT_DIR/../lib/root.sh"
+
+PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
+VENV_PY="$PROJECT_DIR/.venv-build/bin/python"
+
+echo "Generating the icon suite..."
+"$VENV_PY" scripts/assets/icons.py
diff --git a/scripts/linux/build/sampletones.sh b/scripts/linux/build/sampletones.sh
index 7b829235..d5accc98 100755
--- a/scripts/linux/build/sampletones.sh
+++ b/scripts/linux/build/sampletones.sh
@@ -23,6 +23,6 @@ echo "Installing dependencies..."
EXTRAS_STR=$(IFS=,; echo "${EXTRAS[*]}")
echo "Installing with extras: $EXTRAS_STR"
-"$VENV_PY" -m pip install ".[$EXTRAS_STR]"
+"$VENV_PY" -m pip install ".[$EXTRAS_STR]" --group assets
echo "sampletones Python package installed successfully."
diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat
index 9b8137d7..915dc471 100644
--- a/scripts/windows/build/build.bat
+++ b/scripts/windows/build/build.bat
@@ -30,6 +30,7 @@ if "%RELEASE%"=="1" (
)
call "%SCRIPT_DIR%preflight.bat" %* || exit /b 1
+call "%SCRIPT_DIR%icons.bat" || exit /b 1
if exist "bin\sampletones.exe" (
echo Removing the previous artifact: bin\sampletones.exe
@@ -51,6 +52,7 @@ echo Building executable...
--add-data "src\sampletones_assets\fonts;assets\fonts" ^
--add-data "src\sampletones_config;config" ^
--copy-metadata sampletones ^
+ --exclude-module PIL ^
%RELEASE_HOOK% ^
"src\sampletones\__main__.py" || exit /b
diff --git a/scripts/windows/build/icons.bat b/scripts/windows/build/icons.bat
new file mode 100644
index 00000000..d4ee7f14
--- /dev/null
+++ b/scripts/windows/build/icons.bat
@@ -0,0 +1,14 @@
+@echo off
+setlocal EnableExtensions
+
+set "SCRIPT_DIR=%~dp0"
+call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1
+
+set "PROJECT_DIR=%SCRIPT_DIR%..\..\.."
+set "VENV_DIR=%PROJECT_DIR%\.venv-build"
+set "VENV_PY=%VENV_DIR%\Scripts\python.exe"
+
+echo Generating the icon suite...
+"%VENV_PY%" scripts\assets\icons.py || exit /b 1
+
+exit /b 0
diff --git a/scripts/windows/build/sampletones.bat b/scripts/windows/build/sampletones.bat
index 35fed651..902ea372 100644
--- a/scripts/windows/build/sampletones.bat
+++ b/scripts/windows/build/sampletones.bat
@@ -22,7 +22,7 @@ echo Installing dependencies...
"%VENV_PY%" -m pip install --upgrade pip
echo Installing with extras: !EXTRAS!
-"%VENV_PY%" -m pip install ".[!EXTRAS!]" || exit /b 1
+"%VENV_PY%" -m pip install ".[!EXTRAS!]" --group assets || exit /b 1
echo sampletones Python package installed successfully.
exit /b 0
diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py
index 9592c1e9..b7625bb2 100644
--- a/src/sampletones/__main__.py
+++ b/src/sampletones/__main__.py
@@ -5,7 +5,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Optional
-from sampletones_core.paths import EXT_FILES_AUDIO
+from sampletones_shared.paths.extensions import EXT_FILES_AUDIO
if TYPE_CHECKING:
from sampletones_core.configs import Config
@@ -119,7 +119,7 @@ def main() -> None:
config_path = Path(args.config) if args.config else None
output_path = Path(args.output) if args.output else None
- from sampletones_core.paths import (
+ from sampletones_shared.paths.extensions import (
EXT_FILE_LIBRARY,
EXT_FILE_PROJECT,
EXT_FILE_RECONSTRUCTION,
diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py
index 079ae036..6a266a36 100644
--- a/src/sampletones_application/application.py
+++ b/src/sampletones_application/application.py
@@ -141,7 +141,6 @@
from sampletones_core.constants.audio import BufferSize, SampleRate
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import Features
-from sampletones_core.paths import EXT_FILES_AUDIO
from sampletones_core.project.instruments.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import FileSystemNode
@@ -156,6 +155,7 @@
)
from sampletones_shared.exceptions import PlaybackError
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILES_AUDIO
from sampletones_shared.types.application import Sender
SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}"
diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py
index bd2fadd7..cd703831 100644
--- a/src/sampletones_application/config/managers/config.py
+++ b/src/sampletones_application/config/managers/config.py
@@ -20,9 +20,9 @@
from sampletones_core.data.metadata import Metadata
from sampletones_core.fft import Window
from sampletones_core.library import InstructionLibraryKey
-from sampletones_core.paths import CONFIG_PATH, LIBRARY_DIRECTORY
from sampletones_shared.constants.project import RECONSTRUCTIONS_DIRECTORY
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.serialization import load_json
from sampletones_shared.utils.validation import validate_with_recovery
diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py
index b6fcd9c7..239bfeaf 100644
--- a/src/sampletones_application/config/profile.py
+++ b/src/sampletones_application/config/profile.py
@@ -4,7 +4,7 @@
from pathlib import Path
from sampletones_application.paths import APPLICATION_STATE_PATH
-from sampletones_core.paths import APPLICATION_CONFIG_PATH
+from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH
@dataclass(frozen=True)
diff --git a/src/sampletones_application/config/session/state/paths.py b/src/sampletones_application/config/session/state/paths.py
index 3d8d300c..885909d6 100644
--- a/src/sampletones_application/config/session/state/paths.py
+++ b/src/sampletones_application/config/session/state/paths.py
@@ -2,7 +2,7 @@
from pydantic import BaseModel, Field, field_serializer
-from sampletones_core.paths import (
+from sampletones_shared.paths.user import (
CONFIG_PATH,
LIBRARY_DIRECTORY,
PROJECTS_DIRECTORY,
diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py
index 92567fc8..282f1df8 100644
--- a/src/sampletones_application/coordinators/config.py
+++ b/src/sampletones_application/coordinators/config.py
@@ -26,9 +26,9 @@
from sampletones_application.utils.file_dialogs.filter import FileFilter
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
-from sampletones_core.paths import EXT_FILE_JSON
from sampletones_shared.application import SAMPLETONES_VERSION
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_JSON
from sampletones_shared.utils.validation import flatten_location
_LOAD_FAILURE_MESSAGES: Dict[ConfigLoadFailureReason, GlobalMessageElements] = {
diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py
index 295d790b..63f755e3 100644
--- a/src/sampletones_application/coordinators/project.py
+++ b/src/sampletones_application/coordinators/project.py
@@ -31,7 +31,6 @@
from sampletones_application.utils.file_dialogs.filter import FileFilter
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
-from sampletones_core.paths import EXT_FILE_PROJECT
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.scope import ExportScope
@@ -45,6 +44,7 @@
SerializationError,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_PROJECT
from sampletones_shared.types.callback import Callback, VoidCallback
from sampletones_shared.utils.system.paths import get_directory, get_filename
diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py
index f63b21e2..d2e5222a 100644
--- a/src/sampletones_application/coordinators/reconstruction.py
+++ b/src/sampletones_application/coordinators/reconstruction.py
@@ -30,10 +30,10 @@
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import Features
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.types.feature import FeatureValue
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from sampletones_shared.types.callback import Callback, VoidCallback
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py
index 10f80539..bfdfd0f3 100644
--- a/src/sampletones_application/coordinators/tabs/reconstruction.py
+++ b/src/sampletones_application/coordinators/tabs/reconstruction.py
@@ -81,7 +81,6 @@
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.exporters.truncation import EnvelopeTruncation
-from sampletones_core.paths import EXT_FILE_WAVE
from sampletones_core.structures.tree import FileSystemNode
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.format import TrackerFormat
@@ -95,6 +94,7 @@
LoadReconstructionError,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_WAVE
from sampletones_shared.types.callback import PathCallback, VoidCallback
_LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_RECONSTRUCTION, SUF_PANEL_LEFT)
diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py
index 05ca9155..def2e397 100644
--- a/src/sampletones_application/logic/instruction/library_manager.py
+++ b/src/sampletones_application/logic/instruction/library_manager.py
@@ -18,7 +18,6 @@
from sampletones_core.library.creator import InstructionsLibraryCreator
from sampletones_core.library.filename.fields import InstructionsFilenameFields
from sampletones_core.parallelization import TaskProgress, TaskStatus
-from sampletones_core.paths import EXT_FILE_LIBRARY
from sampletones_core.structures.tree import (
GeneratorNode,
LibraryNode,
@@ -27,6 +26,7 @@
TreeNode,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
from sampletones_shared.utils.system.paths import to_path
diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py
index 5a66fe40..178d8456 100644
--- a/src/sampletones_application/logic/main/explorer_manager.py
+++ b/src/sampletones_application/logic/main/explorer_manager.py
@@ -3,11 +3,6 @@
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.config import ConfigManager
-from sampletones_core.paths import (
- EXT_FILE_LIBRARY,
- EXT_FILE_RECONSTRUCTION,
- EXT_FILES_AUDIO,
-)
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree import (
FileSystemNode,
@@ -16,6 +11,11 @@
TreeNode,
create_directory_node,
)
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_LIBRARY,
+ EXT_FILE_RECONSTRUCTION,
+ EXT_FILES_AUDIO,
+)
from sampletones_shared.utils.system.system import System
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py
index cd34400e..56a4bb4c 100644
--- a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py
@@ -11,8 +11,8 @@
from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
ReconstructionScan,
)
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
def scan_reconstructions(directory: Path) -> ReconstructionScan:
diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py
index e10c4b83..204c7fe9 100644
--- a/src/sampletones_application/logic/shared/tree.py
+++ b/src/sampletones_application/logic/shared/tree.py
@@ -5,12 +5,12 @@
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
from sampletones_application.utils.callbacks.queue import CallbackQueue
-from sampletones_core import paths
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
+from sampletones_shared.paths import extensions
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -103,7 +103,7 @@ def is_playable_file(self, node: TreeNode) -> bool:
return False
suffix = node.filepath.suffix.lower()
- return suffix == paths.EXT_FILE_RECONSTRUCTION or suffix in paths.EXT_FILES_AUDIO
+ return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO
def _execute_autoplay(self) -> None:
if self._pending_autoplay_node is not None:
@@ -119,7 +119,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None:
return
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
try:
reconstruction = Reconstruction.load(node.filepath)
self._audio_device_manager.play(
@@ -133,7 +133,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None:
f"Failed to play reconstruction file: {node.filepath}",
)
self.call(self.on_autoplay_error, exception)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self._audio_device_manager.play_file(
node.filepath,
update=False,
diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py
index 4ecbdcfc..0ae44e42 100644
--- a/src/sampletones_application/paths.py
+++ b/src/sampletones_application/paths.py
@@ -1,8 +1,8 @@
from pathlib import Path
from typing import Final
-from sampletones_core.paths import USER_PATH_CONFIG
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
+from sampletones_shared.paths.user import USER_PATH_CONFIG
APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application"
BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior"
diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py
index 59730542..1c9360cc 100644
--- a/src/sampletones_application/ui/elements/tree/tree.py
+++ b/src/sampletones_application/ui/elements/tree/tree.py
@@ -65,7 +65,6 @@
BackgroundWorkCancelled,
SingleThreadExecutor,
)
-from sampletones_core import paths
from sampletones_core.configs.display import (
format_nes_frequency,
format_sample_rate,
@@ -82,6 +81,7 @@
Tree,
TreeNode,
)
+from sampletones_shared.paths import extensions
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import (
Callback,
@@ -812,11 +812,11 @@ def _resolve_file_theme_tag(
return TAG_GLOBAL_THEME_FAVORITE
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return TAG_GLOBAL_THEME_FILE_RECONSTRUCTION
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return TAG_GLOBAL_THEME_FILE_LIBRARY
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
return TAG_GLOBAL_THEME_FILE_WAVE
case _:
if has_favorite_ancestor:
diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py
index c20fbd94..84aaa74b 100644
--- a/src/sampletones_application/ui/panels/main/explorer.py
+++ b/src/sampletones_application/ui/panels/main/explorer.py
@@ -30,7 +30,6 @@
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.dpg import dpg_configure_item
from sampletones_application.utils.parallelization.thread import concurrent
-from sampletones_core import paths
from sampletones_core.structures.tree import (
FileSystemNode,
NodeType,
@@ -39,6 +38,7 @@
TreeTraversal,
traverse,
)
+from sampletones_shared.paths import extensions
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import MessageCallback, PathCallback
@@ -300,19 +300,19 @@ def message_function(
node, _ = user_data
suffix = node.filepath.suffix.lower()
match suffix:
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return reconstruction_message_function(
*args,
user_data=user_data,
**kwargs,
)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return library_message_function(
*args,
user_data=user_data,
**kwargs,
)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
return audio_message_function(
*args,
user_data=user_data,
@@ -333,9 +333,9 @@ def _on_file_node_clicked(
node, _ = user_data
if mouse_button == dpg.mvMouseButton_Left:
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return self._logic.request_autoplay(node)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self.call(self.on_wave_file_clicked, node.filepath)
return self._logic.request_autoplay(node)
@@ -354,12 +354,12 @@ def _on_file_node_double_clicked(
node, _ = user_data
if mouse_button == dpg.mvMouseButton_Left:
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
self._load_reconstruction(node)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self._logic.cancel_autoplay()
return self._reconstruct_file(node)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return self._load_library(node)
return None
@@ -452,17 +452,17 @@ def _add_context_menu_file_actions(self, node: FileSystemNode) -> None:
dpg.add_separator()
suffix = node.filepath.suffix.lower()
match suffix:
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_load_reconstruction"],
callback=lambda: self._load_reconstruction(node),
)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_load_library"],
callback=lambda: self._load_library(node),
)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_reconstruct_file"],
callback=lambda: self._context_reconstruct_file(node),
diff --git a/src/sampletones_application/ui/resources/items.py b/src/sampletones_application/ui/resources/items.py
index d4a8c2ba..d85b185f 100644
--- a/src/sampletones_application/ui/resources/items.py
+++ b/src/sampletones_application/ui/resources/items.py
@@ -1,6 +1,6 @@
from enum import Enum
-from sampletones_core.paths import (
+from sampletones_shared.paths.resources import (
FONT_ICON,
FONT_MONO_BOLD,
FONT_MONO_REGULAR,
diff --git a/src/sampletones_application/ui/resources/resources.py b/src/sampletones_application/ui/resources/resources.py
index e644d7de..b841967f 100644
--- a/src/sampletones_application/ui/resources/resources.py
+++ b/src/sampletones_application/ui/resources/resources.py
@@ -3,7 +3,7 @@
IconResource,
)
from sampletones_application.ui.resources.loader import ResourceLoader
-from sampletones_core.paths import FONT_DIRECTORY, ICON_DIRECTORY
+from sampletones_shared.paths.resources import FONT_DIRECTORY, ICON_DIRECTORY
icon_loader = ResourceLoader(ICON_DIRECTORY)
font_loader = ResourceLoader(FONT_DIRECTORY)
diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py
index e24eb06e..61ed7573 100644
--- a/src/sampletones_application/ui/themes/loader.py
+++ b/src/sampletones_application/ui/themes/loader.py
@@ -29,7 +29,7 @@
from sampletones_application.ui.themes.theme import Theme
from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
from sampletones_application.utils.palette.source import PaletteSource
-from sampletones_core.paths import EXT_FILE_YAML
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
from sampletones_shared.utils.serialization import load_yaml
_BASE_THEME_NAME: Final[str] = "default"
diff --git a/src/sampletones_application/utils/gui/shortcuts/catalog.py b/src/sampletones_application/utils/gui/shortcuts/catalog.py
index 6018b4f3..73f503a8 100644
--- a/src/sampletones_application/utils/gui/shortcuts/catalog.py
+++ b/src/sampletones_application/utils/gui/shortcuts/catalog.py
@@ -6,8 +6,8 @@
from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME
from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
-from sampletones_core.paths import EXT_FILE_YAML
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
@dataclass(frozen=True)
diff --git a/src/sampletones_application/utils/palette/catalog.py b/src/sampletones_application/utils/palette/catalog.py
index ce04fbb4..7b1d7c99 100644
--- a/src/sampletones_application/utils/palette/catalog.py
+++ b/src/sampletones_application/utils/palette/catalog.py
@@ -5,8 +5,8 @@
from typing import Dict, Final, Tuple
from sampletones_application.utils.palette.palette import Palette
-from sampletones_core.paths import EXT_FILE_YAML
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
DEFAULT_PALETTE_NAME: Final[str] = "studio"
diff --git a/src/sampletones_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico
index 7a82dace..5d37a4a2 100644
Binary files a/src/sampletones_assets/icons/sampletones.ico and b/src/sampletones_assets/icons/sampletones.ico differ
diff --git a/src/sampletones_assets/icons/sampletones.png b/src/sampletones_assets/icons/sampletones.png
index 431bd262..3f4bf28f 100644
Binary files a/src/sampletones_assets/icons/sampletones.png and b/src/sampletones_assets/icons/sampletones.png differ
diff --git a/src/sampletones_assets/icons/sampletones.svg b/src/sampletones_assets/icons/sampletones.svg
new file mode 100644
index 00000000..631ddf7d
--- /dev/null
+++ b/src/sampletones_assets/icons/sampletones.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/sampletones_assets/mark/__init__.py b/src/sampletones_assets/mark/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_assets/mark/geometry.py b/src/sampletones_assets/mark/geometry.py
new file mode 100644
index 00000000..5fd855bd
--- /dev/null
+++ b/src/sampletones_assets/mark/geometry.py
@@ -0,0 +1,128 @@
+import itertools
+from dataclasses import dataclass
+from typing import List
+
+from sampletones_assets.mark.specification.point import CubicCurve, Point
+from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare
+
+
+@dataclass(frozen=True)
+class Rectangle:
+ """An axis-aligned box in grid units, the shape one segment of the stepped half fills."""
+
+ left: float
+ top: float
+ right: float
+ bottom: float
+
+
+def _cubic_coordinate(
+ start: float,
+ control_start: float,
+ control_end: float,
+ end: float,
+ progress: float,
+) -> float:
+ remainder = 1.0 - progress
+ return (
+ remainder**3 * start
+ + 3 * remainder**2 * progress * control_start
+ + 3 * remainder * progress**2 * control_end
+ + progress**3 * end
+ )
+
+
+def _cubic_point(
+ start: Point,
+ curve: CubicCurve,
+ progress: float,
+) -> Point:
+ return Point(
+ x=_cubic_coordinate(start.x, curve.control_start.x, curve.control_end.x, curve.end.x, progress),
+ y=_cubic_coordinate(start.y, curve.control_start.y, curve.control_end.y, curve.end.y, progress),
+ )
+
+
+def sine_points(sine: MarkSine, samples: int) -> List[Point]:
+ """The smooth half as a polyline, sampled evenly along every segment.
+
+ Each segment contributes ``samples`` points, ending on its own end point, so the next
+ segment starts where the previous one arrived and the polyline runs unbroken from the
+ wave's start to its handover.
+ """
+ points = [sine.start]
+ position = sine.start
+ for curve in sine.curves:
+ for step in range(1, samples + 1):
+ points.append(_cubic_point(position, curve, step / samples))
+
+ position = curve.end
+
+ return points
+
+
+def _direction(delta: float) -> float:
+ if delta > 0:
+ return 1.0
+
+ if delta < 0:
+ return -1.0
+
+ return 0.0
+
+
+def _segment_rectangle(
+ start: Point,
+ end: Point,
+ *,
+ half_width: float,
+ joined_start: bool,
+ joined_end: bool,
+) -> Rectangle:
+ """The stroke rectangle of one axis-aligned segment.
+
+ A joined end reaches half the stroke width past its corner, so consecutive rectangles
+ fill their right-angle miter; an open end keeps a butt cap.
+ """
+ direction_x = _direction(end.x - start.x)
+ direction_y = _direction(end.y - start.y)
+ start_reach = half_width if joined_start else 0.0
+ end_reach = half_width if joined_end else 0.0
+
+ reached_start = (
+ start.x - direction_x * start_reach,
+ start.y - direction_y * start_reach,
+ )
+ reached_end = (
+ end.x + direction_x * end_reach,
+ end.y + direction_y * end_reach,
+ )
+ across_x = half_width * abs(direction_y)
+ across_y = half_width * abs(direction_x)
+
+ return Rectangle(
+ left=min(reached_start[0], reached_end[0]) - across_x,
+ top=min(reached_start[1], reached_end[1]) - across_y,
+ right=max(reached_start[0], reached_end[0]) + across_x,
+ bottom=max(reached_start[1], reached_end[1]) + across_y,
+ )
+
+
+def square_rectangles(square: MarkSquare, width: float) -> List[Rectangle]:
+ """The stepped half as filled rectangles, one per segment between its corners.
+
+ The rectangles meet at every corner the wave turns at, so the sequence covers the
+ stroke a vector renderer draws with square joins.
+ """
+ segments = list(itertools.pairwise(square.points))
+ final_segment = len(segments) - 1
+ return [
+ _segment_rectangle(
+ start,
+ end,
+ half_width=width / 2,
+ joined_start=index > 0,
+ joined_end=index < final_segment,
+ )
+ for index, (start, end) in enumerate(segments)
+ ]
diff --git a/src/sampletones_assets/mark/mark.yaml b/src/sampletones_assets/mark/mark.yaml
new file mode 100644
index 00000000..34c72068
--- /dev/null
+++ b/src/sampletones_assets/mark/mark.yaml
@@ -0,0 +1,43 @@
+frame:
+ grid: 64
+ corner_radius: 14
+ rim:
+ inset: 1
+ width: 2
+ opacity: 0.14
+
+colors:
+ background:
+ top: "#3a3650"
+ bottom: "#211d30"
+ sine: "#64c8ff"
+ square: "#ffc864"
+ rim: "#cdb6ff"
+
+waves:
+ width: 4
+ sine:
+ start: {x: 8, y: 32}
+ curves:
+ - control_start: {x: 11, y: 16}
+ control_end: {x: 15, y: 16}
+ end: {x: 18, y: 32}
+ - control_start: {x: 21, y: 48}
+ control_end: {x: 25, y: 48}
+ end: {x: 28, y: 32}
+ square:
+ points:
+ - {x: 28, y: 32}
+ - {x: 28, y: 20}
+ - {x: 38, y: 20}
+ - {x: 38, y: 44}
+ - {x: 48, y: 44}
+ - {x: 48, y: 20}
+ - {x: 56, y: 20}
+ - {x: 56, y: 32}
+
+render:
+ supersample: 16
+ curve_samples: 96
+ raster_size: 256
+ windows_sizes: [256, 128, 64, 48, 32, 24, 16]
diff --git a/src/sampletones_assets/mark/paths.py b/src/sampletones_assets/mark/paths.py
new file mode 100644
index 00000000..4b9decd9
--- /dev/null
+++ b/src/sampletones_assets/mark/paths.py
@@ -0,0 +1,7 @@
+from importlib.resources import files
+from pathlib import Path
+from typing import Final
+
+MARK_DIRECTORY: Final[Path] = Path(str(files("sampletones_assets.mark")))
+MARK_PATH: Final[Path] = MARK_DIRECTORY / "mark.yaml"
+TEMPLATE_PATH: Final[Path] = MARK_DIRECTORY / "template.svg"
diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py
new file mode 100644
index 00000000..7c437fe0
--- /dev/null
+++ b/src/sampletones_assets/mark/raster.py
@@ -0,0 +1,116 @@
+from typing import Final, Tuple
+
+from PIL import Image, ImageDraw
+
+from sampletones_assets.mark.geometry import sine_points, square_rectangles
+from sampletones_assets.mark.specification import Mark
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import parse_hex_color, with_alpha_fraction
+
+TRANSPARENT: Final[ColorRGBA] = (0, 0, 0, 0)
+OPAQUE: Final[int] = 255
+
+
+class MarkRaster:
+ """Draws the mark into one supersampled image, ready to scale down to each shipped size.
+
+ Drawing happens at the render factor times the design grid and the result is resampled
+ down, which is what keeps the curve edges and the rounded corners smooth at 16 px.
+ """
+
+ def __init__(self, mark: Mark) -> None:
+ self.mark = mark
+
+ @property
+ def scale(self) -> int:
+ """Factor the design grid is drawn at."""
+ return self.mark.render.supersample
+
+ @property
+ def canvas(self) -> int:
+ """Edge length in pixels of the image the mark is drawn into."""
+ return self.mark.frame.grid * self.scale
+
+ @property
+ def corner_radius(self) -> float:
+ """Corner radius of the frame, in the pixels of the drawn image."""
+ return self.mark.frame.corner_radius * self.scale
+
+ @property
+ def frame_box(self) -> Tuple[int, int, int, int]:
+ """The whole image, as the box the frame and its rim are drawn in."""
+ return (0, 0, self.canvas - 1, self.canvas - 1)
+
+ def render(self) -> Image.Image:
+ """The mark drawn at the supersampled resolution, on a transparent ground."""
+ image = self._background()
+ draw = ImageDraw.Draw(image)
+ self._draw_sine(draw)
+ self._draw_square(draw)
+ image.alpha_composite(self._rim())
+ return image
+
+ def _background(self) -> Image.Image:
+ """The frame: a vertical gradient between the two background colours, rounded at its corners."""
+ size = (self.canvas, self.canvas)
+ top = Image.new("RGB", size, self.mark.colors.background.top)
+ bottom = Image.new("RGB", size, self.mark.colors.background.bottom)
+ shaded = Image.composite(bottom, top, Image.linear_gradient("L").resize(size))
+
+ background = Image.new("RGBA", size, TRANSPARENT)
+ background.paste(shaded, mask=self._frame_mask())
+ return background
+
+ def _frame_mask(self) -> Image.Image:
+ mask = Image.new("L", (self.canvas, self.canvas), 0)
+ ImageDraw.Draw(mask).rounded_rectangle(
+ self.frame_box,
+ radius=self.corner_radius,
+ fill=OPAQUE,
+ )
+ return mask
+
+ def _draw_sine(self, draw: ImageDraw.ImageDraw) -> None:
+ """Sweeps a disk of the stroke's half width along the curve.
+
+ The union of densely stamped disks equals a round-capped stroke of the curve, which is
+ what holds the outline smooth along its whole sweep.
+ """
+ radius = self.mark.waves.width * self.scale / 2
+ for point in sine_points(self.mark.waves.sine, self.mark.render.curve_samples):
+ center_x, center_y = point.x * self.scale, point.y * self.scale
+ draw.ellipse(
+ (
+ center_x - radius,
+ center_y - radius,
+ center_x + radius,
+ center_y + radius,
+ ),
+ fill=self.mark.colors.sine,
+ )
+
+ def _draw_square(self, draw: ImageDraw.ImageDraw) -> None:
+ for rectangle in square_rectangles(self.mark.waves.square, self.mark.waves.width):
+ draw.rectangle(
+ (
+ round(rectangle.left * self.scale),
+ round(rectangle.top * self.scale),
+ round(rectangle.right * self.scale) - 1,
+ round(rectangle.bottom * self.scale) - 1,
+ ),
+ fill=self.mark.colors.square,
+ )
+
+ def _rim(self) -> Image.Image:
+ """The hairline along the frame's edge, as a layer to composite over the drawn mark."""
+ overlay = Image.new("RGBA", (self.canvas, self.canvas), TRANSPARENT)
+ ImageDraw.Draw(overlay).rounded_rectangle(
+ self.frame_box,
+ radius=self.corner_radius,
+ outline=self._rim_color(),
+ width=round(self.mark.frame.rim.width * self.scale),
+ )
+ return overlay
+
+ def _rim_color(self) -> ColorRGBA:
+ return with_alpha_fraction(parse_hex_color(self.mark.colors.rim), self.mark.frame.rim.opacity)
diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py
new file mode 100644
index 00000000..fa600fe1
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/__init__.py
@@ -0,0 +1,38 @@
+from typing import Self
+
+from pydantic import BaseModel, Field
+
+from sampletones_assets.mark.paths import MARK_PATH
+from sampletones_assets.mark.specification.colors import MarkColors
+from sampletones_assets.mark.specification.frame import MarkFrame
+from sampletones_assets.mark.specification.render import MarkRender
+from sampletones_assets.mark.specification.waves import MarkWaves
+from sampletones_shared.utils.serialization import load_yaml_model
+
+
+class Mark(BaseModel, extra="forbid", frozen=True):
+ """The design definition of the application mark.
+
+ Every shipped icon derives from this one definition — the vector, the raster the
+ application loads, and the multi-resolution Windows icon — so the mark is drawn from a
+ single source and stays the same shape at every size. Coordinates are written on the
+ frame's grid, which keeps the wave edges on whole pixels once the grid is scaled to an
+ icon size.
+ """
+
+ frame: MarkFrame = Field(description="The rounded square the mark sits on.")
+ colors: MarkColors = Field(description="The colours the mark is drawn in.")
+ waves: MarkWaves = Field(description="The wave crossing the frame.")
+ render: MarkRender = Field(description="How the mark is rasterized.")
+
+ @classmethod
+ def load(cls) -> Self:
+ """Load the packaged mark definition.
+
+ Returns:
+ The mark validated from `sampletones_assets/mark/mark.yaml`.
+
+ Raises:
+ TypeError: If the definition file holds anything other than a mapping.
+ """
+ return load_yaml_model(MARK_PATH, cls)
diff --git a/src/sampletones_assets/mark/specification/colors.py b/src/sampletones_assets/mark/specification/colors.py
new file mode 100644
index 00000000..1063ea2b
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/colors.py
@@ -0,0 +1,29 @@
+from typing import Annotated
+
+from pydantic import AfterValidator, BaseModel, Field
+
+from sampletones_shared.utils.color import parse_hex_color
+
+
+def _validate_hex_color(value: str) -> str:
+ parse_hex_color(value)
+ return value
+
+
+HexColor = Annotated[str, AfterValidator(_validate_hex_color)]
+
+
+class MarkBackground(BaseModel, extra="forbid", frozen=True):
+ """The vertical gradient filling the frame."""
+
+ top: HexColor = Field(description="Colour at the top edge of the frame.")
+ bottom: HexColor = Field(description="Colour at the bottom edge of the frame.")
+
+
+class MarkColors(BaseModel, extra="forbid", frozen=True):
+ """The mark's colours, written as the hex strings the vector carries."""
+
+ background: MarkBackground = Field(description="Gradient behind the wave.")
+ sine: HexColor = Field(description="Colour of the smooth half of the wave.")
+ square: HexColor = Field(description="Colour of the stepped half of the wave.")
+ rim: HexColor = Field(description="Colour of the hairline inside the frame's edge.")
diff --git a/src/sampletones_assets/mark/specification/frame.py b/src/sampletones_assets/mark/specification/frame.py
new file mode 100644
index 00000000..4c9111a1
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/frame.py
@@ -0,0 +1,43 @@
+from typing import Self
+
+from pydantic import BaseModel, Field, PositiveFloat, PositiveInt, model_validator
+
+
+class MarkRim(BaseModel, extra="forbid", frozen=True):
+ """The hairline drawn just inside the frame's edge, lifting it off a dark desktop."""
+
+ inset: PositiveFloat = Field(description="Distance the hairline keeps from the frame's edge.")
+ width: PositiveFloat = Field(description="Stroke width of the hairline.")
+ opacity: float = Field(gt=0.0, le=1.0, description="Share of full opacity the hairline is drawn at.")
+
+
+class MarkFrame(BaseModel, extra="forbid", frozen=True):
+ """The rounded square the mark sits on.
+
+ ``grid`` is the edge length every other coordinate is expressed in, so the whole design
+ follows from this one number and scales to any icon size.
+ """
+
+ grid: PositiveInt = Field(description="Edge length of the design grid.")
+ corner_radius: PositiveFloat = Field(description="Radius the frame's corners are rounded to.")
+ rim: MarkRim = Field(description="The hairline inside the frame's edge.")
+
+ @property
+ def rim_radius(self) -> float:
+ """Corner radius the rim follows, keeping it concentric with the frame."""
+ return self.corner_radius - self.rim.inset
+
+ @property
+ def rim_extent(self) -> float:
+ """Edge length of the rim's square, inset on both sides."""
+ return self.grid - 2 * self.rim.inset
+
+ @model_validator(mode="after")
+ def _validate_rounding(self) -> Self:
+ if 2 * self.corner_radius > self.grid:
+ raise ValueError(f"The corner radius {self.corner_radius} must be at most half the grid {self.grid}")
+
+ if self.rim.inset >= self.corner_radius:
+ raise ValueError(f"The rim inset {self.rim.inset} must stay inside the corner radius {self.corner_radius}")
+
+ return self
diff --git a/src/sampletones_assets/mark/specification/point.py b/src/sampletones_assets/mark/specification/point.py
new file mode 100644
index 00000000..db8689f0
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/point.py
@@ -0,0 +1,16 @@
+from pydantic import BaseModel, Field
+
+
+class Point(BaseModel, extra="forbid", frozen=True):
+ """A position on the mark's design grid, in grid units."""
+
+ x: float = Field(description="Distance from the left edge of the grid.")
+ y: float = Field(description="Distance from the top edge of the grid.")
+
+
+class CubicCurve(BaseModel, extra="forbid", frozen=True):
+ """One cubic Bézier segment, starting where the segment before it ended."""
+
+ control_start: Point = Field(description="Control point steering the segment away from its start.")
+ control_end: Point = Field(description="Control point steering the segment into its end.")
+ end: Point = Field(description="Point the segment reaches.")
diff --git a/src/sampletones_assets/mark/specification/render.py b/src/sampletones_assets/mark/specification/render.py
new file mode 100644
index 00000000..2e77b129
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/render.py
@@ -0,0 +1,28 @@
+from typing import Tuple
+
+from pydantic import BaseModel, Field, PositiveInt, field_validator
+
+
+class MarkRender(BaseModel, extra="forbid", frozen=True):
+ """How the mark is turned into pixels.
+
+ Drawing happens at ``supersample`` times the design grid and the result is resampled
+ down to each shipped size, which is what keeps the curve edges and the rounded corners
+ smooth at 16 px.
+ """
+
+ supersample: PositiveInt = Field(description="Factor the design grid is drawn at before it is scaled down.")
+ curve_samples: PositiveInt = Field(description="Points each cubic segment of the smooth half is stamped along.")
+ raster_size: PositiveInt = Field(description="Edge length of the raster the application loads.")
+ windows_sizes: Tuple[PositiveInt, ...] = Field(
+ min_length=1,
+ description="Edge lengths the multi-resolution Windows icon carries.",
+ )
+
+ @field_validator("windows_sizes")
+ @classmethod
+ def _validate_windows_sizes(cls, windows_sizes: Tuple[int, ...]) -> Tuple[int, ...]:
+ if list(windows_sizes) != sorted(set(windows_sizes), reverse=True):
+ raise ValueError("Windows icon sizes must be listed once each, in descending order")
+
+ return windows_sizes
diff --git a/src/sampletones_assets/mark/specification/waves.py b/src/sampletones_assets/mark/specification/waves.py
new file mode 100644
index 00000000..b97a1c35
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/waves.py
@@ -0,0 +1,59 @@
+import itertools
+from typing import Self, Tuple
+
+from pydantic import BaseModel, Field, PositiveFloat, model_validator
+
+from sampletones_assets.mark.specification.point import CubicCurve, Point
+
+
+class MarkSine(BaseModel, extra="forbid", frozen=True):
+ """The smooth half of the wave, as cubic segments running on from the start point."""
+
+ start: Point = Field(description="Point the wave enters the frame at.")
+ curves: Tuple[CubicCurve, ...] = Field(min_length=1, description="Segments the wave follows, in drawing order.")
+
+ @property
+ def end(self) -> Point:
+ """Point the last segment reaches, where the stepped half takes over."""
+ return self.curves[-1].end
+
+
+class MarkSquare(BaseModel, extra="forbid", frozen=True):
+ """The stepped half of the wave, as corners joined by axis-aligned segments."""
+
+ points: Tuple[Point, ...] = Field(min_length=2, description="Corners the wave turns at, in drawing order.")
+
+ @model_validator(mode="after")
+ def _validate_segments_run_along_one_axis(self) -> Self:
+ for start, end in itertools.pairwise(self.points):
+ if start.x != end.x and start.y != end.y:
+ raise ValueError(
+ f"A square wave segment runs along one axis, "
+ f"where ({start.x}, {start.y}) to ({end.x}, {end.y}) turns on both"
+ )
+
+ return self
+
+
+class MarkWaves(BaseModel, extra="forbid", frozen=True):
+ """The single wave the mark carries: one sample entering smooth and leaving stepped.
+
+ Both halves are stroked at the same width, which is what reads them as one continuous
+ wave crossing the frame.
+ """
+
+ width: PositiveFloat = Field(description="Stroke width both halves of the wave are drawn at.")
+ sine: MarkSine = Field(description="The smooth half, entering from the left.")
+ square: MarkSquare = Field(description="The stepped half, leaving to the right.")
+
+ @model_validator(mode="after")
+ def _validate_the_halves_meet(self) -> Self:
+ handover = self.square.points[0]
+ if handover != self.sine.end:
+ raise ValueError(
+ f"The stepped half starts where the smooth half ends, "
+ f"where it starts at ({handover.x}, {handover.y}) "
+ f"and the smooth half ends at ({self.sine.end.x}, {self.sine.end.y})"
+ )
+
+ return self
diff --git a/src/sampletones_assets/mark/suite.py b/src/sampletones_assets/mark/suite.py
new file mode 100644
index 00000000..a464b50a
--- /dev/null
+++ b/src/sampletones_assets/mark/suite.py
@@ -0,0 +1,67 @@
+from pathlib import Path
+from typing import List, Tuple
+
+from PIL import Image
+
+from sampletones_assets.mark.raster import MarkRaster
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.vector import render_vector
+from sampletones_shared.paths.resources import (
+ ICON_UNIX_FILENAME,
+ ICON_VECTOR_FILENAME,
+ ICON_WIN_FILENAME,
+)
+
+
+def _resized(master: Image.Image, size: int) -> Image.Image:
+ return master.resize((size, size), Image.Resampling.LANCZOS)
+
+
+def _write_vector(path: Path, mark: Mark) -> Path:
+ path.write_text(render_vector(mark), encoding="utf-8")
+ return path
+
+
+def _write_raster(path: Path, master: Image.Image, size: int) -> Path:
+ _resized(master, size).save(path)
+ return path
+
+
+def _write_windows_icon(
+ path: Path,
+ master: Image.Image,
+ sizes: Tuple[int, ...],
+) -> Path:
+ """Writes the multi-resolution icon, rendering one frame per declared size.
+
+ Every frame is resampled from the supersampled master, so a 16 px frame carries the
+ detail the design grid puts there.
+ """
+ primary, *appended = (_resized(master, size) for size in sizes)
+ primary.save(
+ path,
+ format="ICO",
+ sizes=[(size, size) for size in sizes],
+ append_images=appended,
+ )
+ return path
+
+
+def write_icon_suite(directory: Path, mark: Mark) -> List[Path]:
+ """Writes the vector, the raster and the Windows icon the application ships.
+
+ Args:
+ directory (Path): Directory receiving the icon files, created where it is missing.
+ mark (Mark): Design definition every file is drawn from.
+
+ Returns:
+ List[Path]: The files written, in the order they were produced.
+ """
+ directory.mkdir(parents=True, exist_ok=True)
+ master = MarkRaster(mark).render()
+
+ return [
+ _write_vector(directory / ICON_VECTOR_FILENAME, mark),
+ _write_raster(directory / ICON_UNIX_FILENAME, master, mark.render.raster_size),
+ _write_windows_icon(directory / ICON_WIN_FILENAME, master, mark.render.windows_sizes),
+ ]
diff --git a/src/sampletones_assets/mark/template.svg b/src/sampletones_assets/mark/template.svg
new file mode 100644
index 00000000..3be72f01
--- /dev/null
+++ b/src/sampletones_assets/mark/template.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/sampletones_assets/mark/vector.py b/src/sampletones_assets/mark/vector.py
new file mode 100644
index 00000000..f9ba412c
--- /dev/null
+++ b/src/sampletones_assets/mark/vector.py
@@ -0,0 +1,71 @@
+import itertools
+from string import Template
+from typing import Dict
+
+from sampletones_assets.mark.paths import TEMPLATE_PATH
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.specification.point import Point
+from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare
+
+
+def _number(value: float) -> str:
+ return f"{value:g}"
+
+
+def _coordinates(point: Point) -> str:
+ return f"{_number(point.x)} {_number(point.y)}"
+
+
+def _sine_path(sine: MarkSine) -> str:
+ commands = [f"M{_coordinates(sine.start)}"]
+ for curve in sine.curves:
+ controls = f"{_coordinates(curve.control_start)} {_coordinates(curve.control_end)}"
+ commands.append(f"C{controls} {_coordinates(curve.end)}")
+
+ return " ".join(commands)
+
+
+def _square_path(square: MarkSquare) -> str:
+ """The stepped half as vertical and horizontal commands, one per segment.
+
+ Each segment turns on a single axis, so it is written as the one coordinate it moves
+ along and the renderer holds the other.
+ """
+ commands = [f"M{_coordinates(square.points[0])}"]
+ for previous, point in itertools.pairwise(square.points):
+ commands.append(f"V{_number(point.y)}" if point.x == previous.x else f"H{_number(point.x)}")
+
+ return " ".join(commands)
+
+
+def _placeholders(mark: Mark) -> Dict[str, str]:
+ return {
+ "grid": _number(mark.frame.grid),
+ "corner_radius": _number(mark.frame.corner_radius),
+ "background_top": mark.colors.background.top,
+ "background_bottom": mark.colors.background.bottom,
+ "sine_path": _sine_path(mark.waves.sine),
+ "sine_color": mark.colors.sine,
+ "square_path": _square_path(mark.waves.square),
+ "square_color": mark.colors.square,
+ "wave_width": _number(mark.waves.width),
+ "rim_inset": _number(mark.frame.rim.inset),
+ "rim_extent": _number(mark.frame.rim_extent),
+ "rim_radius": _number(mark.frame.rim_radius),
+ "rim_color": mark.colors.rim,
+ "rim_opacity": _number(mark.frame.rim.opacity),
+ "rim_width": _number(mark.frame.rim.width),
+ }
+
+
+def render_vector(mark: Mark) -> str:
+ """The mark as a standalone vector, filling the packaged template with its own geometry.
+
+ Coordinates stay on the design grid, which keeps the wave edges on whole pixels when the
+ icon is rasterized at 32 px and 16 px.
+
+ Raises:
+ KeyError: If the template names a placeholder the mark leaves unfilled.
+ """
+ template = Template(TEMPLATE_PATH.read_text(encoding="utf-8"))
+ return template.substitute(_placeholders(mark))
diff --git a/src/sampletones_core/audio/writers/capability.py b/src/sampletones_core/audio/writers/capability.py
index aa8ad78f..924a95a3 100644
--- a/src/sampletones_core/audio/writers/capability.py
+++ b/src/sampletones_core/audio/writers/capability.py
@@ -2,7 +2,7 @@
from typing import Final, Mapping, Tuple
from sampletones_core.constants.audio import SAMPLE_RATES
-from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE
+from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE
from .bitrate import MP3_SAMPLE_RATES
from .format import AUDIO_DEPTHS, AudioDepth, AudioFormat
diff --git a/src/sampletones_core/calibration/corpus/writer.py b/src/sampletones_core/calibration/corpus/writer.py
index f7367a6b..43ecee39 100644
--- a/src/sampletones_core/calibration/corpus/writer.py
+++ b/src/sampletones_core/calibration/corpus/writer.py
@@ -2,7 +2,7 @@
from typing import Dict, List
from sampletones_core.audio.io import write_wave
-from sampletones_core.paths import EXT_FILE_WAVE
+from sampletones_shared.paths.extensions import EXT_FILE_WAVE
from sampletones_shared.utils.system.paths import get_filename
from .item import CorpusItem
diff --git a/src/sampletones_core/calibration/paths.py b/src/sampletones_core/calibration/paths.py
index 27aed45a..751bbb53 100644
--- a/src/sampletones_core/calibration/paths.py
+++ b/src/sampletones_core/calibration/paths.py
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Final
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
CALIBRATION_CONFIG_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "calibration"
REFEREE_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "referee.yaml"
diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py
index 5390791f..2dafc796 100644
--- a/src/sampletones_core/configs/config.py
+++ b/src/sampletones_core/configs/config.py
@@ -8,7 +8,7 @@
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.data import DataModel
from sampletones_core.data.metadata import Metadata
-from sampletones_core.paths import CONFIG_PATH
+from sampletones_shared.paths.user import CONFIG_PATH
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import load_json, save_json
from sampletones_shared.utils.system.paths import to_path
diff --git a/src/sampletones_core/configs/general.py b/src/sampletones_core/configs/general.py
index 210696a6..b95c8a5f 100644
--- a/src/sampletones_core/configs/general.py
+++ b/src/sampletones_core/configs/general.py
@@ -10,7 +10,7 @@
)
from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH
from sampletones_core.data import DataModel
-from sampletones_core.paths import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY
+from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY
class GeneralConfig(DataModel):
diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py
index 5e050b77..0733a532 100644
--- a/src/sampletones_core/library/filename/fields.py
+++ b/src/sampletones_core/library/filename/fields.py
@@ -7,7 +7,7 @@
from sampletones_core.constants.enums import SpectrumMethod
from sampletones_core.constants.field_aliases import ALIASES
-from sampletones_core.paths import EXT_FILE_LIBRARY
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import HASH_PATTERN
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py
index 26b0927b..5088d498 100644
--- a/src/sampletones_core/library/filename/utils.py
+++ b/src/sampletones_core/library/filename/utils.py
@@ -7,7 +7,7 @@
)
from sampletones_core.library.filename.fields import InstructionsFilenameFields
from sampletones_core.library.key import InstructionLibraryKey
-from sampletones_core.paths import EXT_FILE_LIBRARY
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py
index 496d1691..76b0b5ee 100644
--- a/src/sampletones_core/library/library.py
+++ b/src/sampletones_core/library/library.py
@@ -7,8 +7,8 @@
from sampletones_core.configs import Config
from sampletones_core.fft import Window
-from sampletones_core.paths import LIBRARY_DIRECTORY
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import LIBRARY_DIRECTORY
from .data import InstructionLibraryData
from .key import InstructionLibraryKey
diff --git a/src/sampletones_core/paths.py b/src/sampletones_core/paths.py
deleted file mode 100644
index f0564761..00000000
--- a/src/sampletones_core/paths.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from pathlib import Path
-from typing import Final, Tuple
-
-from platformdirs import user_config_dir, user_data_dir, user_documents_path
-
-from sampletones_shared.application import (
- SAMPLETONES_GROUP,
- SAMPLETONES_NAME,
-)
-
-# User paths
-USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME
-USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
-USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
-
-# Application paths
-LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions"
-RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions"
-PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects"
-CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json"
-APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml"
-
-# File extensions
-EXT_FILE_JSON: Final[str] = ".json"
-EXT_FILE_YAML: Final[str] = ".yaml"
-EXT_FILE_LIBRARY: Final[str] = ".ins"
-EXT_FILE_INSTRUMENT: Final[str] = ".fti"
-EXT_FILE_RECONSTRUCTION: Final[str] = ".stn"
-EXT_FILE_PROJECT: Final[str] = ".stp"
-EXT_FILE_MODULE: Final[str] = ".ftm"
-EXT_FILE_BITPHASE: Final[str] = ".btp"
-EXT_FILE_WAVE: Final[str] = ".wav"
-EXT_FILE_MP3: Final[str] = ".mp3"
-EXT_FILE_FLAC: Final[str] = ".flac"
-EXT_FILE_OGG: Final[str] = ".ogg"
-EXT_FILE_AIFF: Final[str] = ".aiff"
-EXT_FILE_AU: Final[str] = ".au"
-EXT_FILES_AUDIO: Final[Tuple[str, ...]] = (
- EXT_FILE_WAVE,
- EXT_FILE_MP3,
- EXT_FILE_FLAC,
- EXT_FILE_OGG,
- EXT_FILE_AIFF,
- EXT_FILE_AU,
-)
-
-# Assets
-ASSETS_DIRECTORY: Final[str] = "assets"
-
-# Icon filenames
-ICON_DIRECTORY: Final[str] = "icons"
-ICON_WIN_FILENAME: Final[str] = "sampletones.ico"
-ICON_UNIX_FILENAME: Final[str] = "sampletones.png"
-
-# Font paths
-FONT_DIRECTORY: Final[str] = "fonts"
-FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf"
-FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf"
-FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf"
-FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf"
-FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf"
-FONT_ICON: Final[str] = "DejaVuSans.ttf"
-
-PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True)
-LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True)
-RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True)
diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py
index c5186a2c..375b93e4 100644
--- a/src/sampletones_core/project/container.py
+++ b/src/sampletones_core/project/container.py
@@ -4,7 +4,6 @@
from pydantic import ValidationError
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.project.document import ProjectDocument
from sampletones_core.project.instruments.record import SampleRecord
from sampletones_core.project.instruments.sample import Sample
@@ -27,6 +26,7 @@
NotAValidArchiveError,
UnhandledProjectError,
)
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import JSON_INDENT
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py
index 481ccc32..3069f11b 100644
--- a/src/sampletones_core/reconstructions/converter/paths/utils.py
+++ b/src/sampletones_core/reconstructions/converter/paths/utils.py
@@ -2,13 +2,13 @@
from typing import List, Tuple
from sampletones_core.configs import Config
-from sampletones_core.paths import (
- EXT_FILE_RECONSTRUCTION,
- EXT_FILES_AUDIO,
-)
from sampletones_core.reconstructions.converter.paths.fields import (
ConfigDirectoryFields,
)
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_RECONSTRUCTION,
+ EXT_FILES_AUDIO,
+)
from sampletones_shared.utils.system.paths import to_path
diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py
index 83e40cf5..38b3561e 100644
--- a/src/sampletones_core/trackers/implementation/bitphase.py
+++ b/src/sampletones_core/trackers/implementation/bitphase.py
@@ -8,11 +8,11 @@
sample_to_bitphase,
)
from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset
-from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON
from sampletones_core.trackers.artifact import ExportArtifact
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON
from sampletones_shared.utils.system.paths import get_filename
DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope)
diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py
index ee84198e..dfe9bee0 100644
--- a/src/sampletones_core/trackers/implementation/famitracker.py
+++ b/src/sampletones_core/trackers/implementation/famitracker.py
@@ -11,7 +11,6 @@
from sampletones_core.formats.famitracker.specification.sequences import (
MAX_SEQUENCE_ITEMS,
)
-from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
from sampletones_core.trackers.artifact import ExportArtifact
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.request import (
@@ -20,6 +19,7 @@
SampleExport,
)
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
from sampletones_shared.utils.system.paths import get_filename
SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope)
diff --git a/src/sampletones_shared/meta/source/packages.py b/src/sampletones_shared/meta/source/packages.py
index 772c40f5..f19a3899 100644
--- a/src/sampletones_shared/meta/source/packages.py
+++ b/src/sampletones_shared/meta/source/packages.py
@@ -1,6 +1,6 @@
from pathlib import Path
-from sampletones_shared.paths import SOURCE_ROOT
+from sampletones_shared.paths.source import SOURCE_ROOT
def package_directory(name: str, *parts: str) -> Path:
diff --git a/src/sampletones_shared/paths.py b/src/sampletones_shared/paths.py
deleted file mode 100644
index 53464482..00000000
--- a/src/sampletones_shared/paths.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import sys
-from importlib.resources import files
-from pathlib import Path
-from typing import Final, Optional
-
-_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None)
-
-CONFIG_DIRECTORY: Final[Path] = (
- Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config")))
-)
-SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
-REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent
diff --git a/src/sampletones_shared/paths/__init__.py b/src/sampletones_shared/paths/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py
new file mode 100644
index 00000000..857c1c91
--- /dev/null
+++ b/src/sampletones_shared/paths/extensions.py
@@ -0,0 +1,24 @@
+from typing import Final, Tuple
+
+EXT_FILE_JSON: Final[str] = ".json"
+EXT_FILE_YAML: Final[str] = ".yaml"
+EXT_FILE_LIBRARY: Final[str] = ".ins"
+EXT_FILE_INSTRUMENT: Final[str] = ".fti"
+EXT_FILE_RECONSTRUCTION: Final[str] = ".stn"
+EXT_FILE_PROJECT: Final[str] = ".stp"
+EXT_FILE_MODULE: Final[str] = ".ftm"
+EXT_FILE_BITPHASE: Final[str] = ".btp"
+EXT_FILE_WAVE: Final[str] = ".wav"
+EXT_FILE_MP3: Final[str] = ".mp3"
+EXT_FILE_FLAC: Final[str] = ".flac"
+EXT_FILE_OGG: Final[str] = ".ogg"
+EXT_FILE_AIFF: Final[str] = ".aiff"
+EXT_FILE_AU: Final[str] = ".au"
+EXT_FILES_AUDIO: Final[Tuple[str, ...]] = (
+ EXT_FILE_WAVE,
+ EXT_FILE_MP3,
+ EXT_FILE_FLAC,
+ EXT_FILE_OGG,
+ EXT_FILE_AIFF,
+ EXT_FILE_AU,
+)
diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py
new file mode 100644
index 00000000..9da9292d
--- /dev/null
+++ b/src/sampletones_shared/paths/resources.py
@@ -0,0 +1,25 @@
+import sys
+from importlib.resources import files
+from pathlib import Path
+from typing import Final, Optional
+
+_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None)
+
+CONFIG_DIRECTORY: Final[Path] = (
+ Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config")))
+)
+
+ASSETS_DIRECTORY: Final[str] = "assets"
+
+ICON_DIRECTORY: Final[str] = "icons"
+ICON_WIN_FILENAME: Final[str] = "sampletones.ico"
+ICON_UNIX_FILENAME: Final[str] = "sampletones.png"
+ICON_VECTOR_FILENAME: Final[str] = "sampletones.svg"
+
+FONT_DIRECTORY: Final[str] = "fonts"
+FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf"
+FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf"
+FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf"
+FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf"
+FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf"
+FONT_ICON: Final[str] = "DejaVuSans.ttf"
diff --git a/src/sampletones_shared/paths/source.py b/src/sampletones_shared/paths/source.py
new file mode 100644
index 00000000..51dd98fa
--- /dev/null
+++ b/src/sampletones_shared/paths/source.py
@@ -0,0 +1,5 @@
+from pathlib import Path
+from typing import Final
+
+SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
+REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent
diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py
new file mode 100644
index 00000000..6a34673d
--- /dev/null
+++ b/src/sampletones_shared/paths/user.py
@@ -0,0 +1,23 @@
+from pathlib import Path
+from typing import Final
+
+from platformdirs import user_config_dir, user_data_dir, user_documents_path
+
+from sampletones_shared.application import (
+ SAMPLETONES_GROUP,
+ SAMPLETONES_NAME,
+)
+
+USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME
+USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
+USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
+
+LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions"
+RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions"
+PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects"
+CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json"
+APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml"
+
+PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True)
+LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True)
+RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True)
diff --git a/tests/integration/tooling/test_check_commands.py b/tests/integration/tooling/test_check_commands.py
index fee708dc..a265cea4 100644
--- a/tests/integration/tooling/test_check_commands.py
+++ b/tests/integration/tooling/test_check_commands.py
@@ -3,7 +3,7 @@
import yaml
-from sampletones_shared.paths import REPOSITORY_ROOT
+from sampletones_shared.paths.source import REPOSITORY_ROOT
PRE_COMMIT_CONFIG: Final[Path] = REPOSITORY_ROOT / ".pre-commit-config.yaml"
MAKEFILE: Final[Path] = REPOSITORY_ROOT / "Makefile"
diff --git a/tests/suite/scripts.py b/tests/suite/scripts.py
index 97a5263e..413fa9d7 100644
--- a/tests/suite/scripts.py
+++ b/tests/suite/scripts.py
@@ -1,7 +1,7 @@
import importlib.util
from types import ModuleType
-from sampletones_shared.paths import REPOSITORY_ROOT
+from sampletones_shared.paths.source import REPOSITORY_ROOT
def load_script(relative_path: str) -> ModuleType:
diff --git a/tests/unit/sampletones_application/config/test_profile.py b/tests/unit/sampletones_application/config/test_profile.py
index f1d3e5aa..4a123e29 100644
--- a/tests/unit/sampletones_application/config/test_profile.py
+++ b/tests/unit/sampletones_application/config/test_profile.py
@@ -2,7 +2,7 @@
from sampletones_application.config.profile import UserProfile
from sampletones_application.paths import APPLICATION_STATE_PATH
-from sampletones_core.paths import APPLICATION_CONFIG_PATH
+from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH
class TestUserProfile:
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py
index 41662f90..8a1cae3e 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py
@@ -16,9 +16,9 @@
ReconstructionScan,
)
from sampletones_core.constants.enums import SpectrumMethod
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from tests.suite.language import FakeLanguageManager
HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0"
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
index 8c9f1c40..0d426cfb 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
@@ -19,15 +19,15 @@
from sampletones_core.configs import Config
from sampletones_core.constants.enums import AudioSourceType, GeneratorName
from sampletones_core.instructions import TriangleInstruction
-from sampletones_core.paths import (
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.trackers.format import TrackerFormat
+from sampletones_core.trackers.registry import build_tracker_backends
+from sampletones_shared.paths.extensions import (
EXT_FILE_BITPHASE,
EXT_FILE_INSTRUMENT,
EXT_FILE_JSON,
EXT_FILE_MODULE,
)
-from sampletones_core.reconstructions import Reconstruction
-from sampletones_core.trackers.format import TrackerFormat
-from sampletones_core.trackers.registry import build_tracker_backends
from tests.suite.case import BaseRegularTestCase
NO_EXTENSION: Final[str] = ""
diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py
index 3a7763e8..afc741b3 100644
--- a/tests/unit/sampletones_application/logic/shared/test_tree.py
+++ b/tests/unit/sampletones_application/logic/shared/test_tree.py
@@ -10,9 +10,9 @@
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
from sampletones_application.logic.shared.tree import TreeLogic
-from sampletones_core import paths
from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode
from sampletones_shared.exceptions import InvalidReconstructionError
+from sampletones_shared.paths import extensions
def _tree(
@@ -360,7 +360,7 @@ def test_load_failure_reports_autoplay_error(
audio_device_manager = MagicMock()
tree = _tree(audio_device_manager=audio_device_manager)
tree.on_autoplay_error = MagicMock()
- node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}")
+ node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}")
with patch(
"sampletones_application.logic.shared.tree.Reconstruction.load",
@@ -374,7 +374,7 @@ def test_load_failure_reports_autoplay_error(
def test_unexpected_failure_propagates(self, tmp_path: Path) -> None:
tree = _tree()
tree.on_autoplay_error = MagicMock()
- node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}")
+ node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}")
with (
patch(
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py
index d7461250..1c3c57ff 100644
--- a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py
@@ -7,10 +7,10 @@
from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
from sampletones_core.configs import Config
from sampletones_core.configs.display import format_sample_rate, short_hash
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode
from sampletones_core.structures.tree.type import NodeType
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from tests.suite.language import FakeLanguageManager
CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config())
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py
index 4c314e7e..75325efe 100644
--- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py
@@ -6,7 +6,7 @@
from sampletones_application.paths import KEYBINDINGS_DIRECTORY
from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog
from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
-from sampletones_core.paths import EXT_FILE_YAML
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
SHIPPED_FILE = KEYBINDINGS_DIRECTORY / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}"
SHIPPED_SCHEME_NAMES = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).names
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py
index 4a690e0e..868689bf 100644
--- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py
@@ -12,7 +12,7 @@
from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut
-from sampletones_core.paths import EXT_FILE_YAML
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
from tests.unit.sampletones_application.utils.gui.shortcuts.conftest import (
PROBE_SCHEME_NAME,
RebindScheme,
diff --git a/tests/unit/sampletones_assets/__init__.py b/tests/unit/sampletones_assets/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_assets/mark/__init__.py b/tests/unit/sampletones_assets/mark/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_assets/mark/test_geometry.py b/tests/unit/sampletones_assets/mark/test_geometry.py
new file mode 100644
index 00000000..78279edb
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_geometry.py
@@ -0,0 +1,69 @@
+import itertools
+from typing import Final
+
+import pytest
+
+from sampletones_assets.mark.geometry import Rectangle, sine_points, square_rectangles
+from sampletones_assets.mark.specification import Mark
+
+SAMPLES: Final[int] = 5
+
+
+def _overlap(first: Rectangle, second: Rectangle) -> float:
+ width = min(first.right, second.right) - max(first.left, second.left)
+ height = min(first.bottom, second.bottom) - max(first.top, second.top)
+ return min(width, height)
+
+
+class TestSinePoints:
+ def test_the_polyline_runs_from_the_start_to_the_handover(self) -> None:
+ sine = Mark.load().waves.sine
+ points = sine_points(sine, SAMPLES)
+
+ assert points[0] == sine.start
+ assert points[-1].x == pytest.approx(sine.end.x)
+ assert points[-1].y == pytest.approx(sine.end.y)
+
+ def test_every_segment_contributes_its_samples(self) -> None:
+ sine = Mark.load().waves.sine
+ assert len(sine_points(sine, SAMPLES)) == len(sine.curves) * SAMPLES + 1
+
+ def test_the_polyline_stays_within_the_curve_the_definition_draws(self) -> None:
+ """The wave swings between the extremes its control points reach, keeping it inside the frame."""
+ sine = Mark.load().waves.sine
+ controls = [sine.start] + [
+ point for curve in sine.curves for point in (curve.control_start, curve.control_end, curve.end)
+ ]
+ lowest = min(point.y for point in controls)
+ highest = max(point.y for point in controls)
+
+ for point in sine_points(sine, SAMPLES):
+ assert lowest <= point.y <= highest
+
+
+class TestSquareRectangles:
+ def test_one_rectangle_covers_each_segment(self) -> None:
+ square = Mark.load().waves.square
+ assert len(square_rectangles(square, width=4.0)) == len(square.points) - 1
+
+ def test_every_rectangle_reads_left_to_right_and_top_to_bottom(self) -> None:
+ square = Mark.load().waves.square
+ for rectangle in square_rectangles(square, width=4.0):
+ assert rectangle.left < rectangle.right
+ assert rectangle.top < rectangle.bottom
+
+ def test_a_segment_carries_the_stroke_width_across_its_run(self) -> None:
+ width = 4.0
+ square = Mark.load().waves.square
+ for (start, end), rectangle in zip(
+ itertools.pairwise(square.points),
+ square_rectangles(square, width=width),
+ ):
+ across = rectangle.bottom - rectangle.top if start.y == end.y else rectangle.right - rectangle.left
+ assert across == pytest.approx(width)
+
+ def test_consecutive_rectangles_meet_at_the_corner_they_turn_on(self) -> None:
+ """Overlapping rectangles fill the right-angle miter, so the stepped half draws as one stroke."""
+ square = Mark.load().waves.square
+ for first, second in itertools.pairwise(square_rectangles(square, width=4.0)):
+ assert _overlap(first, second) > 0.0
diff --git a/tests/unit/sampletones_assets/mark/test_raster.py b/tests/unit/sampletones_assets/mark/test_raster.py
new file mode 100644
index 00000000..5ae61ddf
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_raster.py
@@ -0,0 +1,47 @@
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_assets.mark.raster import MarkRaster
+from sampletones_assets.mark.specification import Mark
+from sampletones_shared.utils.color import parse_hex_color
+
+CORNER: Final[Tuple[int, int]] = (0, 0)
+ALPHA: Final[int] = 3
+CHANNELS: Final[int] = 3
+
+
+@pytest.fixture(name="mark", scope="module")
+def mark_fixture() -> Mark:
+ return Mark.load()
+
+
+class TestMarkRaster:
+ def test_the_image_covers_the_supersampled_grid(self, mark: Mark) -> None:
+ image = MarkRaster(mark).render()
+ edge = mark.frame.grid * mark.render.supersample
+ assert image.size == (edge, edge)
+
+ def test_the_image_corner_stays_clear_of_the_rounded_frame(self, mark: Mark) -> None:
+ image = MarkRaster(mark).render()
+ assert image.getpixel(CORNER)[ALPHA] == 0
+
+ def test_the_frame_centre_carries_the_background(self, mark: Mark) -> None:
+ """The frame reaches the top edge between its rounded corners, so the ground there is opaque."""
+ image = MarkRaster(mark).render()
+ centre = image.size[0] // 2
+ assert image.getpixel((centre, 1))[ALPHA] == 255
+
+ def test_the_smooth_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None:
+ image = MarkRaster(mark).render()
+ scale = mark.render.supersample
+ start = mark.waves.sine.start
+ pixel = image.getpixel((round(start.x * scale), round(start.y * scale)))
+ assert pixel[:CHANNELS] == parse_hex_color(mark.colors.sine)[:CHANNELS]
+
+ def test_the_stepped_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None:
+ image = MarkRaster(mark).render()
+ scale = mark.render.supersample
+ corner = mark.waves.square.points[1]
+ pixel = image.getpixel((round(corner.x * scale), round(corner.y * scale)))
+ assert pixel[:CHANNELS] == parse_hex_color(mark.colors.square)[:CHANNELS]
diff --git a/tests/unit/sampletones_assets/mark/test_specification.py b/tests/unit/sampletones_assets/mark/test_specification.py
new file mode 100644
index 00000000..a3f51d36
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_specification.py
@@ -0,0 +1,176 @@
+from dataclasses import dataclass
+from typing import Any, Dict, Final
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_assets.mark.specification import Mark
+from tests.suite.case import BaseRegularTestCase
+
+VALID_FRAME: Final[Dict[str, Any]] = {
+ "grid": 64,
+ "corner_radius": 14,
+ "rim": {"inset": 1, "width": 2, "opacity": 0.14},
+}
+
+VALID_COLORS: Final[Dict[str, Any]] = {
+ "background": {"top": "#3a3650", "bottom": "#211d30"},
+ "sine": "#64c8ff",
+ "square": "#ffc864",
+ "rim": "#cdb6ff",
+}
+
+VALID_SINE: Final[Dict[str, Any]] = {
+ "start": {"x": 8, "y": 32},
+ "curves": [
+ {
+ "control_start": {"x": 11, "y": 16},
+ "control_end": {"x": 15, "y": 16},
+ "end": {"x": 18, "y": 32},
+ },
+ ],
+}
+
+VALID_SQUARE: Final[Dict[str, Any]] = {
+ "points": [
+ {"x": 18, "y": 32},
+ {"x": 18, "y": 20},
+ {"x": 28, "y": 20},
+ ],
+}
+
+VALID_WAVES: Final[Dict[str, Any]] = {
+ "width": 4,
+ "sine": VALID_SINE,
+ "square": VALID_SQUARE,
+}
+
+VALID_RENDER: Final[Dict[str, Any]] = {
+ "supersample": 16,
+ "curve_samples": 96,
+ "raster_size": 256,
+ "windows_sizes": [256, 128, 64],
+}
+
+VALID_FIELDS: Final[Dict[str, Any]] = {
+ "frame": VALID_FRAME,
+ "colors": VALID_COLORS,
+ "waves": VALID_WAVES,
+ "render": VALID_RENDER,
+}
+
+
+class TestMark:
+ @dataclass(frozen=True, kw_only=True)
+ class InvalidFieldCase(BaseRegularTestCase):
+ field: str
+ value: Any
+
+ test_cases = (
+ InvalidFieldCase(
+ field="frame",
+ value={**VALID_FRAME, "grid": 0},
+ label="empty_grid",
+ ),
+ InvalidFieldCase(
+ field="frame",
+ value={**VALID_FRAME, "corner_radius": 33},
+ label="corner_radius_over_half_the_grid",
+ ),
+ InvalidFieldCase(
+ field="frame",
+ value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "inset": 14}},
+ label="rim_inset_outside_the_corner_radius",
+ ),
+ InvalidFieldCase(
+ field="frame",
+ value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "opacity": 1.5}},
+ label="rim_opacity_over_full",
+ ),
+ InvalidFieldCase(
+ field="colors",
+ value={**VALID_COLORS, "sine": "64c8ff"},
+ label="color_without_a_hash",
+ ),
+ InvalidFieldCase(
+ field="colors",
+ value={**VALID_COLORS, "sine": "#64c8"},
+ label="color_of_four_hex_digits",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={**VALID_WAVES, "width": 0},
+ label="wave_without_width",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={**VALID_WAVES, "sine": {**VALID_SINE, "curves": []}},
+ label="smooth_half_without_curves",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={**VALID_WAVES, "square": {"points": [{"x": 18, "y": 32}]}},
+ label="stepped_half_without_a_segment",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={
+ **VALID_WAVES,
+ "square": {"points": [{"x": 18, "y": 32}, {"x": 28, "y": 20}]},
+ },
+ label="stepped_segment_turning_on_both_axes",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={
+ **VALID_WAVES,
+ "square": {"points": [{"x": 40, "y": 32}, {"x": 40, "y": 20}]},
+ },
+ label="halves_meeting_apart",
+ ),
+ InvalidFieldCase(
+ field="render",
+ value={**VALID_RENDER, "supersample": 0},
+ label="drawing_below_the_design_grid",
+ ),
+ InvalidFieldCase(
+ field="render",
+ value={**VALID_RENDER, "windows_sizes": []},
+ label="windows_icon_without_a_frame",
+ ),
+ InvalidFieldCase(
+ field="render",
+ value={**VALID_RENDER, "windows_sizes": [64, 128, 256]},
+ label="windows_sizes_in_ascending_order",
+ ),
+ InvalidFieldCase(
+ field="render",
+ value={**VALID_RENDER, "windows_sizes": [256, 256, 128]},
+ label="repeated_windows_size",
+ ),
+ )
+
+ def test_the_packaged_definition_loads(self) -> None:
+ mark = Mark.load()
+ assert isinstance(mark, Mark)
+
+ def test_the_sample_of_the_packaged_definition_leaves_as_it_entered(self) -> None:
+ """The mark draws one wave, so the stepped half carries on from where the smooth half arrives."""
+ mark = Mark.load()
+ assert mark.waves.square.points[0] == mark.waves.sine.end
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_an_invalid_field_is_rejected(self, case: InvalidFieldCase) -> None:
+ fields = {**VALID_FIELDS, case.field: case.value}
+ with pytest.raises(ValidationError):
+ Mark.model_validate(fields)
+
+ @pytest.mark.parametrize("field", sorted(VALID_FIELDS))
+ def test_a_missing_field_is_rejected(self, field: str) -> None:
+ fields = {key: value for key, value in VALID_FIELDS.items() if key != field}
+ with pytest.raises(ValidationError):
+ Mark.model_validate(fields)
+
+ def test_an_unknown_field_is_rejected(self) -> None:
+ with pytest.raises(ValidationError):
+ Mark.model_validate({**VALID_FIELDS, "shadow": {"blur": 4}})
diff --git a/tests/unit/sampletones_assets/mark/test_suite.py b/tests/unit/sampletones_assets/mark/test_suite.py
new file mode 100644
index 00000000..b0a8f3e3
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_suite.py
@@ -0,0 +1,55 @@
+from pathlib import Path
+
+import pytest
+from PIL import Image
+
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.suite import write_icon_suite
+from sampletones_shared.paths.resources import (
+ ICON_UNIX_FILENAME,
+ ICON_VECTOR_FILENAME,
+ ICON_WIN_FILENAME,
+)
+
+RGBA_MODE = "RGBA"
+ICO_SIZES_KEY = "sizes"
+
+
+@pytest.fixture(name="mark", scope="module")
+def mark_fixture() -> Mark:
+ return Mark.load()
+
+
+class TestWriteIconSuite:
+ def test_the_suite_holds_every_file_the_application_ships(self, tmp_path: Path, mark: Mark) -> None:
+ paths = write_icon_suite(tmp_path, mark)
+ assert [path.name for path in paths] == [
+ ICON_VECTOR_FILENAME,
+ ICON_UNIX_FILENAME,
+ ICON_WIN_FILENAME,
+ ]
+ assert all(path.is_file() for path in paths)
+
+ def test_the_directory_is_created_where_it_is_missing(self, tmp_path: Path, mark: Mark) -> None:
+ directory = tmp_path / "icons"
+ write_icon_suite(directory, mark)
+ assert directory.is_dir()
+
+ def test_the_raster_is_the_size_the_definition_declares(self, tmp_path: Path, mark: Mark) -> None:
+ write_icon_suite(tmp_path, mark)
+ with Image.open(tmp_path / ICON_UNIX_FILENAME) as image:
+ assert image.size == (mark.render.raster_size, mark.render.raster_size)
+ assert image.mode == RGBA_MODE
+
+ def test_the_windows_icon_carries_every_declared_size(self, tmp_path: Path, mark: Mark) -> None:
+ write_icon_suite(tmp_path, mark)
+ with Image.open(tmp_path / ICON_WIN_FILENAME) as image:
+ carried = {width for width, _ in image.info[ICO_SIZES_KEY]}
+
+ assert carried == set(mark.render.windows_sizes)
+
+ def test_the_same_definition_writes_the_same_files(self, tmp_path: Path, mark: Mark) -> None:
+ """One definition produces one suite, so a rebuild leaves the shipped files as they were."""
+ first = write_icon_suite(tmp_path / "first", mark)
+ second = write_icon_suite(tmp_path / "second", mark)
+ assert [path.read_bytes() for path in first] == [path.read_bytes() for path in second]
diff --git a/tests/unit/sampletones_assets/mark/test_vector.py b/tests/unit/sampletones_assets/mark/test_vector.py
new file mode 100644
index 00000000..081cb682
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_vector.py
@@ -0,0 +1,47 @@
+from importlib.resources import files
+from pathlib import Path
+
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.vector import render_vector
+from sampletones_shared.paths.resources import ICON_VECTOR_FILENAME
+
+PLACEHOLDER_PREFIX = "$"
+REPLACEMENT_COLOR = "#010203"
+
+
+class TestRenderVector:
+ def test_the_shipped_vector_is_what_the_definition_renders(self) -> None:
+ """The committed vector is the mark's design source, so it stays in step with the definition."""
+ shipped = Path(str(files("sampletones_assets.icons"))) / ICON_VECTOR_FILENAME
+ assert render_vector(Mark.load()) == shipped.read_text(encoding="utf-8")
+
+ def test_the_template_is_filled_throughout(self) -> None:
+ assert PLACEHOLDER_PREFIX not in render_vector(Mark.load())
+
+ def test_every_colour_reaches_the_document(self) -> None:
+ mark = Mark.load()
+ document = render_vector(mark)
+ colors = (
+ mark.colors.background.top,
+ mark.colors.background.bottom,
+ mark.colors.sine,
+ mark.colors.square,
+ mark.colors.rim,
+ )
+
+ for color in colors:
+ assert color in document
+
+ def test_the_document_follows_the_definition(self) -> None:
+ """A colour changed in the definition is the colour the vector is drawn with."""
+ mark = Mark.load()
+ recolored = mark.model_copy(update={"colors": mark.colors.model_copy(update={"sine": REPLACEMENT_COLOR})})
+ document = render_vector(recolored)
+
+ assert REPLACEMENT_COLOR in document
+ assert mark.colors.sine not in document
+
+ def test_the_wave_starts_where_the_definition_places_it(self) -> None:
+ mark = Mark.load()
+ start = mark.waves.sine.start
+ assert f'd="M{start.x:g} {start.y:g}' in render_vector(mark)
diff --git a/tests/unit/sampletones_core/audio/writers/test_spec.py b/tests/unit/sampletones_core/audio/writers/test_spec.py
index cf3961be..cdbc328f 100644
--- a/tests/unit/sampletones_core/audio/writers/test_spec.py
+++ b/tests/unit/sampletones_core/audio/writers/test_spec.py
@@ -15,7 +15,7 @@
mp3_bitrates,
)
from sampletones_core.constants.audio import SAMPLE_RATES
-from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE
+from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE
from tests.suite.base import BaseTestSuite
MPEG_1_RATE: Final[int] = 44100
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py
index 10485b18..dc7b3aba 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py
@@ -13,7 +13,7 @@
CHIP_TYPE_NES,
TUNING_TABLE_LENGTH,
)
-from sampletones_core.paths import EXT_FILE_BITPHASE
+from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE
from .conftest import build_features, build_instrument, build_sample
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py
index 322f0e3f..ed3b8e11 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py
@@ -19,7 +19,7 @@
MIN_TONE_ADD,
NO_TONE_OFFSET,
)
-from sampletones_core.paths import EXT_FILE_JSON
+from sampletones_shared.paths.extensions import EXT_FILE_JSON
from .conftest import REFERENCE_PITCH, build_features, build_instrument
diff --git a/tests/unit/sampletones_core/library/filename/test_fields.py b/tests/unit/sampletones_core/library/filename/test_fields.py
index 466cfe03..1dbc7b07 100644
--- a/tests/unit/sampletones_core/library/filename/test_fields.py
+++ b/tests/unit/sampletones_core/library/filename/test_fields.py
@@ -7,7 +7,7 @@
FILENAME_SEPARATOR,
InstructionsFilenameFields,
)
-from sampletones_core.paths import EXT_FILE_LIBRARY
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.errors import expect_error
diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
index 4c38eec0..6955b77c 100644
--- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
+++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
@@ -4,13 +4,13 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.reconstructions.converter.paths import (
filter_files,
get_audio_files,
get_output_path,
get_relative_path,
)
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
@pytest.fixture(scope="module")
diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py
index 1bbd52a3..4c9aa41f 100644
--- a/tests/unit/sampletones_core/trackers/test_bitphase.py
+++ b/tests/unit/sampletones_core/trackers/test_bitphase.py
@@ -9,7 +9,6 @@
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.exporters import Features
-from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON
from sampletones_core.project.project import Project
from sampletones_core.project.settings import ProjectSettings
from sampletones_core.trackers.format import TrackerFormat
@@ -23,6 +22,7 @@
SampleExport,
)
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON
NES_FREQUENCY: Final[int] = 60
REFERENCE_PITCH: Final[int] = 60
diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py
index 10ac1651..b9cc907d 100644
--- a/tests/unit/sampletones_core/trackers/test_extensions.py
+++ b/tests/unit/sampletones_core/trackers/test_extensions.py
@@ -3,17 +3,17 @@
import pytest
-from sampletones_core.paths import (
- EXT_FILE_BITPHASE,
- EXT_FILE_INSTRUMENT,
- EXT_FILE_JSON,
- EXT_FILE_MODULE,
-)
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.extensions import format_for_extension
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.registry import build_tracker_backends
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_BITPHASE,
+ EXT_FILE_INSTRUMENT,
+ EXT_FILE_JSON,
+ EXT_FILE_MODULE,
+)
UNKNOWN_EXTENSION: Final[str] = ".xm"
NO_EXTENSION: Final[str] = ""
diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py
index 5aedf2c4..3f036b02 100644
--- a/tests/unit/sampletones_core/trackers/test_famitracker.py
+++ b/tests/unit/sampletones_core/trackers/test_famitracker.py
@@ -10,11 +10,11 @@
from sampletones_core.formats.famitracker.specification.sequences import (
MAX_SEQUENCE_ITEMS,
)
-from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend
from sampletones_core.trackers.request import InstrumentExport, SampleExport
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
NES_FREQUENCY: Final[int] = 60
diff --git a/tests/unit/sampletones_shared/meta/source/test_packages.py b/tests/unit/sampletones_shared/meta/source/test_packages.py
index bc2333ad..e18e8b85 100644
--- a/tests/unit/sampletones_shared/meta/source/test_packages.py
+++ b/tests/unit/sampletones_shared/meta/source/test_packages.py
@@ -1,7 +1,7 @@
import pytest
from sampletones_shared.meta.source.packages import package_directory
-from sampletones_shared.paths import SOURCE_ROOT
+from sampletones_shared.paths.source import SOURCE_ROOT
SHARED_PACKAGE = "sampletones_shared"
APPLICATION_PACKAGE = "sampletones_application"
diff --git a/tests/unit/sampletones_shared/paths/__init__.py b/tests/unit/sampletones_shared/paths/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_shared/paths/test_resources.py b/tests/unit/sampletones_shared/paths/test_resources.py
new file mode 100644
index 00000000..53ca661a
--- /dev/null
+++ b/tests/unit/sampletones_shared/paths/test_resources.py
@@ -0,0 +1,7 @@
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
+
+
+class TestConfigDirectory:
+ def test_the_configuration_directory_holds_the_shipped_files(self) -> None:
+ """Read as a package resource, so the bundle finds it beside the executable."""
+ assert list(CONFIG_DIRECTORY.rglob("*.yaml"))
diff --git a/tests/unit/sampletones_shared/test_paths.py b/tests/unit/sampletones_shared/paths/test_source.py
similarity index 63%
rename from tests/unit/sampletones_shared/test_paths.py
rename to tests/unit/sampletones_shared/paths/test_source.py
index ae1c2fe2..6d5f1ec8 100644
--- a/tests/unit/sampletones_shared/test_paths.py
+++ b/tests/unit/sampletones_shared/paths/test_source.py
@@ -1,4 +1,4 @@
-from sampletones_shared.paths import CONFIG_DIRECTORY, REPOSITORY_ROOT, SOURCE_ROOT
+from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT
PROJECT_FILE = "pyproject.toml"
SHARED_PACKAGE = "sampletones_shared"
@@ -10,7 +10,7 @@ def test_the_source_root_holds_the_packages(self) -> None:
def test_the_source_root_is_where_this_package_lives(self) -> None:
"""Reading the root off the package keeps it right wherever the packages are installed."""
- assert (SOURCE_ROOT / SHARED_PACKAGE / "paths.py").is_file()
+ assert (SOURCE_ROOT / SHARED_PACKAGE / "paths" / "source.py").is_file()
class TestRepositoryRoot:
@@ -19,9 +19,3 @@ def test_the_repository_root_holds_the_project_file(self) -> None:
def test_the_repository_root_holds_the_scripts_the_checks_run_from(self) -> None:
assert (REPOSITORY_ROOT / "scripts" / "checks").is_dir()
-
-
-class TestConfigDirectory:
- def test_the_configuration_directory_holds_the_shipped_files(self) -> None:
- """Read as a package resource, so the bundle finds it beside the executable."""
- assert list(CONFIG_DIRECTORY.rglob("*.yaml"))
diff --git a/tests/unit/sampletones_shared/paths/test_user.py b/tests/unit/sampletones_shared/paths/test_user.py
new file mode 100644
index 00000000..d26807b2
--- /dev/null
+++ b/tests/unit/sampletones_shared/paths/test_user.py
@@ -0,0 +1,16 @@
+from sampletones_shared.paths.user import (
+ LIBRARY_DIRECTORY,
+ PROJECTS_DIRECTORY,
+ RECONSTRUCTIONS_DIRECTORY,
+)
+
+
+class TestUserDirectories:
+ def test_the_user_directories_exist_after_import(self) -> None:
+ """Importing the module creates the directories the application saves into."""
+ for directory in (
+ LIBRARY_DIRECTORY,
+ PROJECTS_DIRECTORY,
+ RECONSTRUCTIONS_DIRECTORY,
+ ):
+ assert directory.is_dir()
diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py
index 0d51b8a7..2056eb0d 100644
--- a/tests/unit/scripts/checks/test_palette_colors.py
+++ b/tests/unit/scripts/checks/test_palette_colors.py
@@ -5,7 +5,7 @@
from sampletones_application.paths import PALETTES_DIRECTORY
from sampletones_shared.meta.source.modules import SourceModule, source_paths
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
from scripts.checks.palette_colors import dpg_module_helper
from tests.suite.scripts import load_script
from tests.suite.source import parse_source
diff --git a/tests/unit/scripts/checks/test_tag_names.py b/tests/unit/scripts/checks/test_tag_names.py
index a79a294f..3b03f4fc 100644
--- a/tests/unit/scripts/checks/test_tag_names.py
+++ b/tests/unit/scripts/checks/test_tag_names.py
@@ -7,7 +7,7 @@
from sampletones_application.categories.hierarchy import Page, Panel, Widget
from sampletones_application.categories.key.tag import TagName
from sampletones_shared.meta.source.modules import SourceModule, source_paths
-from sampletones_shared.paths import SOURCE_ROOT
+from sampletones_shared.paths.source import SOURCE_ROOT
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.scripts import load_script
diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/scripts/checks/test_unused_tags.py
index 4d8456f0..167e4a90 100644
--- a/tests/unit/scripts/checks/test_unused_tags.py
+++ b/tests/unit/scripts/checks/test_unused_tags.py
@@ -4,7 +4,7 @@
import pytest
from sampletones_shared.meta.source.modules import SourceModule, source_paths
-from sampletones_shared.paths import SOURCE_ROOT
+from sampletones_shared.paths.source import SOURCE_ROOT
from tests.suite.scripts import load_script
from tests.suite.source import parse_source
diff --git a/tests/unit/scripts/ci/checks/test_bundle.py b/tests/unit/scripts/ci/checks/test_bundle.py
index 28793dfa..06e339dc 100644
--- a/tests/unit/scripts/ci/checks/test_bundle.py
+++ b/tests/unit/scripts/ci/checks/test_bundle.py
@@ -111,6 +111,21 @@ def test_a_notice_directory_counts_as_absent(self, bundle: Path) -> None:
assert check_bundle.missing_notices(bundle) == ["LICENSE"]
+class TestCarriedBuildTools:
+ def test_an_application_bundle_holds_to_its_notices(self, bundle: Path) -> None:
+ assert check_bundle.carried_build_tools(bundle) == []
+
+ def test_a_build_tool_beside_the_application_is_reported(self, bundle: Path) -> None:
+ (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True)
+
+ assert check_bundle.carried_build_tools(bundle) == ["PIL"]
+
+ def test_a_build_tool_beside_the_launcher_is_reported(self, bundle: Path) -> None:
+ (bundle / "PIL").mkdir()
+
+ assert check_bundle.carried_build_tools(bundle) == ["PIL"]
+
+
class TestMain:
def test_a_complete_bundle_passes(
self,
@@ -137,6 +152,20 @@ def test_a_missing_notice_is_annotated_as_an_error(
assert output.startswith("::error::")
assert "THIRD-PARTY-NOTICES.md" in output
+ def test_bundled_build_tooling_is_annotated_as_an_error(
+ self,
+ bundle: Path,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ _install_launcher(bundle)
+ (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True)
+
+ assert check_bundle.main([str(bundle)]) == 1
+
+ output = capsys.readouterr().out
+ assert output.startswith("::error::")
+ assert "PIL" in output
+
def test_a_missing_launcher_is_annotated_as_an_error(
self,
bundle: Path,
diff --git a/uv.lock b/uv.lock
index d8dc1be3..4699d98d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1236,6 +1236,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
]
+[[package]]
+name = "pillow"
+version = "12.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
+ { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
+ { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
+ { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
+ { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
+ { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
+ { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
+ { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
+ { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
+ { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
+ { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
+ { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
+ { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
+ { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
+ { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
+ { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
+ { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
+ { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
+ { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
+ { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.9.6"
@@ -1754,10 +1825,14 @@ gpu-cuda11 = [
]
[package.dev-dependencies]
+assets = [
+ { name = "pillow" },
+]
dev = [
{ name = "black" },
{ name = "isort" },
{ name = "mypy" },
+ { name = "pillow" },
{ name = "pre-commit" },
{ name = "pylint" },
{ name = "pylint-pydantic" },
@@ -1796,10 +1871,12 @@ requires-dist = [
provides-extras = ["build", "gpu", "gpu-cuda11"]
[package.metadata.requires-dev]
+assets = [{ name = "pillow", specifier = ">=11,<13" }]
dev = [
{ name = "black", specifier = "==26.5.1" },
{ name = "isort", specifier = "==8.0.1" },
{ name = "mypy", specifier = "==2.1.0" },
+ { name = "pillow", specifier = ">=11,<13" },
{ name = "pre-commit", specifier = "==4.6.0" },
{ name = "pylint", specifier = "==4.0.6" },
{ name = "pylint-pydantic", specifier = "==0.4.1" },