From 8ab60af515761bf4bb29bb74c677f025294dc2e5 Mon Sep 17 00:00:00 2001 From: Kevin Montag Date: Mon, 1 Jul 2024 18:04:27 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE.txt | 9 + README.md | 65 ++++++++ pyproject.toml | 80 +++++++++ src/alpacka/__about__.py | 1 + src/alpacka/__init__.py | 350 +++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_alpacka.py | 51 ++++++ 8 files changed, 558 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/alpacka/__about__.py create mode 100644 src/alpacka/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/test_alpacka.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b32c4d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +/.mypy_cache/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f91b3b7 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024-present Kevin Montag + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6df5e95 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# alpacka + +-[![PyPI - Version](https://img.shields.io/pypi/v/alpacka.svg)](https://pypi.org/project/alpacka) + +--- + +`alpacka` allows you to generate custom Ableton Live packs. It +supports adding audio previews and Live 12 tags to the pack content. + +Currently, only directory-based packs can be created - **generating +`.alp` files is not supported.** + +Packs can be added to Live by dragging them into the Places pane. + +_This is alpha software. It works for my use cases but hasn't been +extensively tested, and is missing plenty of functionality. APIS are +subject to change significantly. Please submit issues and/or PRs if +you run into trouble._ + +## Installation + +```console +pip install alpacka +``` + +## Usage + +```python +from alpacka import DirectoryPackWriter +from time import time + +with DirectoryPackWriter( + "/path/to/output_dir", + name="My Pack", + unique_id="my.unique.id", + # Tell Live to re-index the pack when it gets regenerated. + revision=int(time()), +) as p: + p.set_file("Preset.adg", "/path/to/Preset.adg") + p.set_preview("Preset.adg", "/path/to/Preset.adg.ogg") + p.set_tags("Preset", [ + ("Sounds", "Lead"), + ("Custom", "Tag", "Subtag") + ]) + +``` + +An async variant is also available: + +```python +import asyncio +from alpacka import DirectoryPackWriterAsync +from time import time + +async def run(): + async with DirectoryPackWriterAsync( + "/path/to/output_dir", + name="My Pack", + unique_id="my.unique.id", + revision=int(time()), + ) as p: + await p.set_file("Preset.adg", "/path/to/Preset.adg") + # ... +asyncio.run(run()) +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..56380c5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "alpacka" +dynamic = ["version"] +description = 'Generate custom Ableton Live packs' +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +keywords = [] +authors = [ + { name = "Kevin Montag", email = "kmontag@cs.stanford.edu" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [] + +[project.urls] +Documentation = "https://github.com/kmontag/alpacka#readme" +Issues = "https://github.com/kmontag/alpacka/issues" +Source = "https://github.com/kmontag/alpacka" + +[tool.hatch.version] +path = "src/alpacka/__about__.py" + +# [tool.hatch.envs.hatch-test] +# extra-dependencies = [ +# "pytest-asyncio~=0.23.7", +# ] + +[tool.hatch.envs.types] +extra-dependencies = [ + "mypy>=1.0.0", +] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:src/alpacka tests}" + +[tool.coverage.run] +source_pkgs = ["alpacka", "tests"] +branch = true +parallel = true +omit = [ + "src/alpacka/__about__.py", +] + +[tool.coverage.paths] +alpacka = ["src/alpacka", "*/alpacka/src/alpacka"] +tests = ["tests", "*/alpacka/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.mypy] +disallow_untyped_defs = true + +[tool.ruff.lint] +# - ARG: unused arguments +# - B: flake8-bugbear +# - E: pycodestyle errors +# - I: import sorting +# - W: pycodestyle warnings +extend-select = ["ARG", "B", "E", "I", "W"] +# Turn off strict max line length; B950 allows for exceeding the max +# line length in some cases. +extend-ignore = ["E501"] diff --git a/src/alpacka/__about__.py b/src/alpacka/__about__.py new file mode 100644 index 0000000..15addcb --- /dev/null +++ b/src/alpacka/__about__.py @@ -0,0 +1 @@ +__version__ = "0.0.1.dev0" diff --git a/src/alpacka/__init__.py b/src/alpacka/__init__.py new file mode 100644 index 0000000..21ee419 --- /dev/null +++ b/src/alpacka/__init__.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import asyncio +import json +import os +import textwrap +from typing import ( + TYPE_CHECKING, + Collection, + Generic, + NotRequired, + Self, + Sequence, + TypeAlias, + TypedDict, + TypeVar, + Unpack, + override, +) +from xml.sax.saxutils import escape + +if TYPE_CHECKING: + from types import TracebackType + + +FOLDER_INFO_DIR = "Ableton Folder Info" +PROPERTIES_FILE = "Properties.cfg" + +# This appears to be Live's default filename for the first XMP +# (metadata) file in a pack (or the User Library). Unclear exactly +# where the name comes from. .alp files which include an XMP portion +# (e.g. Granulator III) don't seem to include this actual name +# anywhere. +XMP_FILE = "c55d131f-2661-5add-aece-29afb7099dfa.xmp" + +# First element is the tag name (e.g. "Character", "Devices"), second +# element is the tag and subtag values. +Tag: TypeAlias = tuple[str, Sequence[str]] + +_Context = TypeVar("_Context") + + +class PackProperties(TypedDict): + name: str + unique_id: str + vendor: NotRequired[str] + major_version: NotRequired[int] + minor_version: NotRequired[int] + revision: NotRequired[int] + product_id: NotRequired[int] + min_software_product_id: NotRequired[int] + is_hidden_in_browse_groups: NotRequired[bool] + + +class PackWriterAsync(Generic[_Context]): + def __init__(self, **k: Unpack[PackProperties]): + self._name: str = k["name"] + self._unique_id: str = k["unique_id"] + self._vendor: str = k.get("vendor", "") + + self._major_version: int = k.get("major_version", 1) + self._minor_version: int = k.get("minor_version", 0) + self._revision: int = k.get("revision", 0) + + self._product_id: int = k.get("product_id", 0) + self._min_software_product_id: int = k.get("min_software_product_id", 0) + + self._is_hidden_in_browse_groups = k.get("is_hidden_in_browse_groups", False) + + self.__context: _Context | None = None + + # Propagate unexpected keys up to `object`, so that errors + # will be thrown if appropriate. + for key in PackProperties.__annotations__: + if key in k: + del k[key] # type: ignore + + super().__init__(**k) # type: ignore + + async def set_file(self, path: str, file: str) -> None: + raise NotImplementedError + + async def set_file_content(self, path: str, content: bytes) -> None: + raise NotImplementedError + + async def set_tags(self, path: str, tags: Collection[Tag]) -> None: + raise NotImplementedError + + async def set_preview(self, path: str, ogg_file: str) -> None: + raise NotImplementedError + + async def set_preview_content(self, path: str, ogg_content: bytes) -> None: + raise NotImplementedError + + # Write any pending unwritten content to the output location. + async def commit(self) -> None: + raise NotImplementedError + + # Open any resources necessary to start adding content, e.g. a + # temp directory to stage files. + async def open(self) -> _Context: + raise NotImplementedError + + # Close any resources opened by `_open`. + async def close(self, context: _Context) -> None: + raise NotImplementedError + + # Allow usage like: + # + # async with await PackWriter(**args) as p: + # p.set_file(...) + # p.set_preview(...) + # + # Which is equivalent to: + # + # p = PackWriter(**args) + # context = p._open() + # try: + # p.set_file(...) + # p.set_preview(...) + # p._commit() + # finally: + # p._close(context) + # + async def __aenter__(self) -> Self: + if self.__context is not None: + msg = f"{self} is already open" + raise ValueError(msg) + + self.__context = await self.open() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_inst: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self.__context is None: + msg = f"{self} is not open" + raise ValueError(msg) + + try: + if exc_type is None: + await self.commit() + finally: + await self.close(self.__context) + + +# For synchronous writes, just wrap an async writer. +class PackWriter(Generic[_Context]): + def __init__(self, pack_writer_async: PackWriterAsync[_Context]) -> None: + self._pack_writer_async = pack_writer_async + + def set_file(self, path: str, file: str) -> None: + asyncio.run(self._pack_writer_async.set_file(path, file)) + + def set_file_content(self, path: str, content: bytes) -> None: + asyncio.run(self._pack_writer_async.set_file_content(path, content)) + + def set_tags(self, path: str, tags: Collection[Tag]) -> None: + asyncio.run(self._pack_writer_async.set_tags(path, tags)) + + def set_preview(self, path: str, ogg_file: str) -> None: + asyncio.run(self._pack_writer_async.set_preview(path, ogg_file)) + + def set_preview_content(self, path: str, ogg_content: bytes) -> None: + asyncio.run(self._pack_writer_async.set_preview_content(path, ogg_content)) + + def commit(self) -> None: + asyncio.run(self._pack_writer_async.commit()) + + def open(self) -> _Context: + return asyncio.run(self._pack_writer_async.open()) + + def close(self, context: _Context) -> None: + asyncio.run(self._pack_writer_async.close(context)) + + def __enter__(self) -> Self: + self._context = self.open() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + try: + if exc_type is None: + self.commit() + finally: + self.close(self._context) + + +class DirectoryPackWriterAsync(PackWriterAsync[None]): + def __init__(self, output_dir: str, **k: Unpack[PackProperties]): + super().__init__(**k) + + self._output_dir = output_dir + + # If the output dir exists and is non-empty (or is not a + # directory), raise an error. + if os.path.exists(output_dir) and (not os.path.isdir(output_dir) or os.listdir(output_dir)): + msg = f"Output directory '{output_dir}' exists and is not empty." + raise ValueError(msg) + + # Keys are paths within the pack. + self._tags: dict[str, Collection[Tag]] = {} + + @override + async def set_file(self, path: str, file: str) -> None: + await self._copy_to_path(path, file) + + @override + async def set_file_content(self, path: str, content: bytes) -> None: + await self._write_to_path(path, content) + + @override + async def set_tags(self, path: str, tags: Collection[Tag]) -> None: + self._tags[path] = tags + + @override + async def set_preview(self, path: str, ogg_file: str) -> None: + await self._copy_to_path(self._preview_path(path), ogg_file) + + @override + async def set_preview_content(self, path: str, ogg_content: bytes) -> None: + await self._write_to_path(self._preview_path(path), ogg_content) + + @override + async def open(self) -> None: + return None + + @override + async def close(self, context: None) -> None: + pass + + @override + async def commit(self) -> None: + await self._write_properties_file() + if len(self._tags) > 0: + await self._write_xmp_file() + + async def _write_properties_file(self) -> None: + text = textwrap.dedent( + f""" + Ableton#04I + + FolderConfigData + {{ + String PackUniqueID = {json.dumps(self._unique_id)}; + String PackDisplayName = {json.dumps(self._name)}; + String PackVendor = {json.dumps(self._vendor)}; + Bool FolderHiddenInBrowseGroups = {'true' if self._is_hidden_in_browse_groups else 'false'}; + Int PackMinorVersion = {self._minor_version}; + Int PackMajorVersion = {self._major_version}; + Int PackRevision = {self._revision}; + Int ProductId = {self._product_id}; + Int MinSoftwareProductId = {self._min_software_product_id}; + }} + """ + ).lstrip() + + await self._write_to_path(os.path.join(FOLDER_INFO_DIR, PROPERTIES_FILE), text.encode("utf-8")) + + # Write pack metadata, e.g. tags. + async def _write_xmp_file(self) -> None: + tags_text = "" + for path, tags in self._tags.items(): + rdf_items: list[str] = [] + for tag_name, tag_values in tags: + if len(tag_values) == 0: + msg = f"Tag `{tag_name}` is empty for path `{path}`" + raise ValueError(msg) + rdf_items.append("|".join(escape(val) for val in [tag_name, *tag_values])) + + rdf_indent = " " + tags_text += textwrap.indent( + textwrap.dedent( + f""" + + {escape(path)} + + + """ + ).lstrip("\n") + + "\n".join([f" {rdf_item}" for rdf_item in rdf_items]) + + textwrap.dedent( + """ + + + + """ + ), + rdf_indent, + ) + + xmp_text = textwrap.dedent( + f""" + + + + application/vnd.ableton.factory-pack + pack + mac + {self._unique_id} + {self._major_version}.{self._minor_version}.{self._revision} + + + {tags_text} + + + Updated by Ableton Index 12.0.1 + 2024-03-14T17:40:51-06:00 + 2024-03-15T11:55:05-06:00 + + + + """ + ).lstrip() + await self._write_to_path(os.path.join(FOLDER_INFO_DIR, XMP_FILE), xmp_text.encode("utf-8")) + + def _preview_path(self, path: str) -> str: + return os.path.join(FOLDER_INFO_DIR, "Previews", f"{path}.ogg") + + async def _copy_to_path(self, path: str, file: str) -> None: + def do_copy_file(path: str, file: str) -> None: + with open(file, "rb") as f: + asyncio.run(self._write_to_path(path, f.read())) + + await asyncio.to_thread(do_copy_file, path, file) + + async def _write_to_path(self, path: str, content: bytes) -> None: + def do_write_file(absolute_path: str, content: bytes) -> None: + os.makedirs(os.path.dirname(absolute_path), exist_ok=True) + with open(absolute_path, "wb") as f: + f.write(content) + + absolute_path = os.path.join(self._output_dir, path) + await asyncio.to_thread(do_write_file, absolute_path, content) + + +class DirectoryPackWriter(PackWriter): + def __init__(self, output_dir: str, **k: Unpack[PackProperties]): + pack_writer_async = DirectoryPackWriterAsync(output_dir, **k) + super().__init__(pack_writer_async) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_alpacka.py b/tests/test_alpacka.py new file mode 100644 index 0000000..0636d3c --- /dev/null +++ b/tests/test_alpacka.py @@ -0,0 +1,51 @@ +import os +import tempfile + +from alpacka import DirectoryPackWriter + + +def test_simple_directory() -> None: + name = "Test" + unique_id = "test.id" + with tempfile.TemporaryDirectory() as output_dir: + path = "test.txt" + version = "2.3.4" + with DirectoryPackWriter( + output_dir, + name=name, + unique_id=unique_id, + major_version=int(version.split(".")[0]), + minor_version=int(version.split(".")[1]), + revision=int(version.split(".")[2]), + ) as pack_writer: + pack_writer.set_file_content(path, b"test-content") + pack_writer.set_preview_content(path, b"test-preview-content") + pack_writer.set_tags(path, [("Tag Name", ("Tag Value", "Subtag Value"))]) + + with open(os.path.join(output_dir, path)) as f: + assert f.read() == "test-content" + with open(os.path.join(output_dir, "Ableton Folder Info", "Previews", f"{path}.ogg")) as f: + assert f.read() == "test-preview-content" + + with open(os.path.join(output_dir, "Ableton Folder Info", "Properties.cfg")) as f: + properties_text = f.read() + assert f'String PackUniqueID = "{unique_id}";' in properties_text + assert f'String PackDisplayName = "{name}";' in properties_text + for field, value in zip( + ("PackMajorVersion", "PackMinorVersion", "PackRevision"), + version.split("."), + strict=True, + ): + assert f"Int {field} = {value};" in properties_text + + xmp_files = [ + file for file in os.listdir(os.path.join(output_dir, "Ableton Folder Info")) if file.endswith(".xmp") + ] + assert len(xmp_files) == 1 + + xmp_file_path = os.path.join(output_dir, "Ableton Folder Info", xmp_files[0]) + with open(xmp_file_path) as xmp_file: + xmp_content = xmp_file.read() + assert f"{unique_id}" in xmp_content + assert "2.3.4" in xmp_content + assert "Tag Name|Tag Value|Subtag Value" in xmp_content