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 @@ [![Python](https://img.shields.io/pypi/pyversions/sampletones.svg)](https://pypi.org/project/sampletones/) [![License](https://img.shields.io/pypi/l/sampletones.svg)](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) +
+ SampleToNES +
+ ## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). -SampleToNES +SampleToNES 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" },