├── .python-version ├── tests └── peeler │ ├── pyproject │ ├── data │ │ ├── pyproject_no_peeler_table.toml │ │ ├── pyproject_peeler_table_empty.toml │ │ ├── pyproject_no_manifest_table.toml │ │ └── pyproject_minimal.toml │ ├── test_manifest_adapter.py │ ├── test_validator.py │ ├── conftest.py │ └── test_update.py │ ├── data │ ├── simple.toml │ ├── simple_with_comments.toml │ └── blender_manifest.toml │ ├── wheels │ ├── data │ │ ├── pyproject.toml │ │ ├── lock_file_uv.toml │ │ ├── lock_file_pylock.toml │ │ ├── pylock.toml │ │ ├── pylock.test.toml │ │ └── uv.lock │ ├── conftest.py │ ├── test_lock.py │ └── test_download.py │ ├── command │ └── test_version.py │ ├── manifest │ ├── test_validate.py │ └── test_write.py │ ├── test_toml_utils.py │ ├── utils │ ├── test__find_pyproject_file.py │ ├── test_restore_file.py │ └── test_normalize_platform.py │ ├── conftest.py │ └── uv_utils │ └── test_check_uv_version.py ├── license_header.txt ├── peeler-json-schema ├── README.md ├── pyproject.toml ├── data │ └── peeler_schema.json └── __main__.py ├── mypy.ini ├── peeler ├── command │ ├── version.py │ ├── manifest.py │ └── wheels.py ├── pyproject │ ├── __init__.py │ ├── parser.py │ ├── update.py │ ├── validator.py │ └── manifest_adapter.py ├── manifest │ ├── validate.py │ └── write.py ├── data │ ├── peeler_schema.json │ └── blender_manifest_schema.json ├── __init__.py ├── schema.py ├── toml_utils.py ├── cli.py ├── uv_utils.py ├── utils.py └── wheels │ ├── lock.py │ └── download.py ├── .github ├── workflows │ ├── unit_tests.yaml │ ├── on_uv_release.yml │ └── release.yaml └── actions │ └── unit_tests │ └── action.yml ├── .coveragerc ├── .pre-commit-config.yaml ├── pyproject.toml ├── .ruff.toml ├── CONTRIBUTING.md ├── .gitignore └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /tests/peeler/pyproject/data/pyproject_no_peeler_table.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "test_pyproject" 3 | version = "1.0.0" 4 | -------------------------------------------------------------------------------- /license_header.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Maxime Letellier 2 | 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | -------------------------------------------------------------------------------- /tests/peeler/data/simple.toml: -------------------------------------------------------------------------------- 1 | string = "value" 2 | boolean = false 3 | array = ["str1", "str2"] 4 | 5 | [object] 6 | string2 = "value2" 7 | boolean2 = true 8 | -------------------------------------------------------------------------------- /tests/peeler/pyproject/data/pyproject_peeler_table_empty.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "test_pyproject_with_empty_peeler_table" 3 | version = "1.0.0" 4 | 5 | [tool.peeler] 6 | -------------------------------------------------------------------------------- /peeler-json-schema/README.md: -------------------------------------------------------------------------------- 1 | # schema-convertor 2 | 3 | Create the peeler tool table json schema by combining a blender manifest json schema and peeler config json schema 4 | -------------------------------------------------------------------------------- /tests/peeler/pyproject/data/pyproject_no_manifest_table.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "test_pyproject_no_manifest_table" 3 | readme = "README.md" 4 | version = "1.0.0" 5 | 6 | 7 | [tool.peeler] 8 | download_wheels = true 9 | -------------------------------------------------------------------------------- /tests/peeler/pyproject/data/pyproject_minimal.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "test_pyproject" 3 | version = "1.0.0" 4 | 5 | 6 | [tool.peeler.manifest] 7 | blender_version_min = "4.2.0" 8 | id = "test_pyproject" 9 | license = ["SPDX:0BSD"] 10 | maintainer = "John Smith" 11 | tagline = "a pyproject for testing purposes" 12 | platforms = ["linux-x64"] 13 | -------------------------------------------------------------------------------- /tests/peeler/pyproject/test_manifest_adapter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from peeler.pyproject.manifest_adapter import ManifestAdapter 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "manifest_adapter", [("pyproject_minimal.toml")], indirect=["manifest_adapter"] 8 | ) 9 | def test_parser(manifest_adapter: ManifestAdapter) -> None: 10 | assert manifest_adapter.to_blender_manifest() 11 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | 2 | [mypy] 3 | ignore_missing_imports = True 4 | install_types = True 5 | non_interactive = True 6 | disallow_untyped_defs = True 7 | no_implicit_optional = True 8 | check_untyped_defs = True 9 | show_error_codes = True 10 | warn_unused_ignores = True 11 | warn_unused_configs = True 12 | disallow_incomplete_defs = True 13 | warn_redundant_casts = True 14 | strict_equality = True 15 | explicit_package_bases = True 16 | -------------------------------------------------------------------------------- /tests/peeler/wheels/data/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "test_pyproject" 3 | version = "1.0.0" 4 | requires-python = ">=3.11,<3.12" 5 | 6 | dependencies = [ 7 | "numpy<2" 8 | ] 9 | 10 | [tool.peeler] 11 | overwrite = true 12 | 13 | 14 | [tool.peeler.manifest] 15 | blender_version_min = "4.2.0" 16 | id = "test_pyproject" 17 | license = ["SPDX:0BSD"] 18 | maintainer = "John Smith" 19 | tagline = "a pyproject for testing purposes" 20 | -------------------------------------------------------------------------------- /peeler-json-schema/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "peeler-json-schema" 3 | description = "Create peeler tool table json schema" 4 | readme = "README.md" 5 | requires-python = ">=3.11" 6 | authors = [ 7 | { name = "Maxime Letellier", email = "maxime.eliot.letellier@gmail.com" } 8 | ] 9 | classifiers = [ 10 | "Programming Language :: Python :: 3", 11 | "Operating System :: OS Independent", 12 | ] 13 | license = {file = "LICENSE"} 14 | version = "1.0.0" 15 | -------------------------------------------------------------------------------- /tests/peeler/data/simple_with_comments.toml: -------------------------------------------------------------------------------- 1 | # first 2 | string = "value" 3 | boolean = false # inline 1 4 | array = [ 5 | "str1", # in array 6 | "str2" 7 | ] 8 | # middle 9 | [object] 10 | # under 11 | string2 = "value2" 12 | boolean2 = true # inline 2 13 | array_o = [ 14 | "o", # in array 2 15 | {"prop" ="prop_value"} # object_in_array 16 | ] 17 | 18 | [[array2]] # inline 3 19 | key1 = "v1" 20 | 21 | [[array2]] 22 | key2 = "v2" # inline 4 23 | # end 24 | -------------------------------------------------------------------------------- /peeler/command/version.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from importlib import metadata 6 | 7 | import typer 8 | 9 | 10 | def version_command() -> None: 11 | """Print the package name and version to the console.""" 12 | 13 | import peeler 14 | 15 | version = metadata.version(peeler.__name__) 16 | typer.echo(f"{peeler.__name__} {version}") 17 | -------------------------------------------------------------------------------- /peeler/pyproject/__init__.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | """Module for parsing, validating and updating a pyproject.toml file.""" 6 | 7 | from dep_logic.specifiers import RangeSpecifier 8 | from packaging.version import Version 9 | 10 | _BLENDER_SUPPORTED_PYTHON_VERSION = RangeSpecifier( 11 | Version("3.11"), Version("3.12"), include_min=True, include_max=False 12 | ) 13 | -------------------------------------------------------------------------------- /peeler/manifest/validate.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from tomlkit import TOMLDocument 6 | import jsonschema 7 | 8 | from ..schema import blender_manifest_json_schema 9 | 10 | 11 | def validate_manifest(blender_manifest: TOMLDocument) -> None: 12 | """Validate a blender_manifest. 13 | 14 | Validate using a json schema. 15 | 16 | :param blender_manifest: the blender_manifest as `TOMLDocument` 17 | """ 18 | 19 | schema = blender_manifest_json_schema() 20 | 21 | jsonschema.validate(blender_manifest, schema) 22 | -------------------------------------------------------------------------------- /tests/peeler/command/test_version.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from pytest import CaptureFixture 4 | 5 | from peeler.command.version import version_command 6 | 7 | package_name_regex = f"peeler" 8 | 9 | # from https://semver.org/spec/v2.0.0.html 10 | version_regex = r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?" 11 | 12 | 13 | def test_version_command(capfd: CaptureFixture) -> None: 14 | version_command() 15 | out, err = capfd.readouterr() 16 | 17 | assert re.match(f"^{package_name_regex} {version_regex}$", out) 18 | -------------------------------------------------------------------------------- /tests/peeler/manifest/test_validate.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tomlkit import TOMLDocument 3 | from jsonschema.exceptions import ValidationError 4 | 5 | from peeler.manifest.validate import validate_manifest 6 | 7 | 8 | @pytest.mark.parametrize(("toml_document"), [("blender_manifest.toml")], indirect=True) 9 | def test_validate_manifest(toml_document: TOMLDocument) -> None: 10 | try: 11 | validate_manifest(toml_document) 12 | except ValidationError as e: 13 | pytest.fail(f"Should not raise a ValidationError: {e.message}") 14 | 15 | 16 | def test_validate_manifest_empty_dict() -> None: 17 | with pytest.raises(ValidationError): 18 | validate_manifest({}) 19 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Tests Workflow 2 | 3 | on: [push] 4 | 5 | env: 6 | UV_SYSTEM_PYTHON: 1 7 | 8 | jobs: 9 | run_tests: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | uv-version: 14 | - 0.7.1 15 | - 0.9.17 16 | 17 | python-version: 18 | - 3.11 19 | - 3.12 20 | - 3.13 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Unit Tests 26 | id: unit-tests 27 | uses: ./.github/actions/unit_tests/ 28 | with: 29 | uv-version: ${{ matrix.uv-version }} 30 | python-version: ${{ matrix.python-version }} 31 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | # omit tests directory 5 | tests/* 6 | 7 | [report] 8 | ; Regexes for lines to exclude from consideration 9 | exclude_also = 10 | ; Don't complain about missing debug-only code: 11 | def __repr__ 12 | if self\.debug 13 | 14 | ; Don't complain if tests don't hit defensive assertion code: 15 | raise AssertionError 16 | raise NotImplementedError 17 | 18 | ; Don't complain if non-runnable code isn't run: 19 | if 0: 20 | if __name__ == .__main__.: 21 | 22 | ; Don't complain about abstract methods, they aren't run: 23 | @(abc\.)?abstractmethod 24 | 25 | # Skip any pass lines such as may be used for @abstractmethod 26 | pass -------------------------------------------------------------------------------- /peeler/data/peeler_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "download_wheels": { 6 | "type": "boolean", 7 | "description": "whether to download wheels from pyproject dependencies", 8 | "default": false 9 | }, 10 | "overwrite": { 11 | "type": "boolean", 12 | "description": "whether to overwrite properties in already existing blender manifest", 13 | "default": false 14 | }, 15 | "manifest": { 16 | "type": "object", 17 | "description": "manifest values", 18 | "required": [ 19 | "id", 20 | "tagline", 21 | "maintainer", 22 | "blender_version_min", 23 | "license" 24 | ] 25 | } 26 | }, 27 | "required": ["manifest"] 28 | 29 | } 30 | -------------------------------------------------------------------------------- /peeler-json-schema/data/peeler_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "download_wheels": { 6 | "type": "boolean", 7 | "description": "whether to download wheels from pyproject dependencies", 8 | "default": false 9 | }, 10 | "overwrite": { 11 | "type": "boolean", 12 | "description": "whether to overwrite properties in already existing blender manifest", 13 | "default": false 14 | }, 15 | "manifest": { 16 | "type": "object", 17 | "description": "manifest values", 18 | "required": [ 19 | "id", 20 | "tagline", 21 | "maintainer", 22 | "blender_version_min", 23 | "license" 24 | ] 25 | } 26 | }, 27 | "required": ["manifest"] 28 | 29 | } 30 | -------------------------------------------------------------------------------- /peeler/__init__.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | """Peeler - Simplify Your Blender Add-on Packaging. 6 | 7 | Usage: peeler [OPTIONS] COMMAND [ARGS]... 8 | 9 | Run `peeler --help` for more info. 10 | 11 | **Peeler Commands**: 12 | 13 | `version:` print the currently installed `peeler` version. 14 | 15 | `manifest:` create or update `blender_manifest.toml` from values in `pyproject.toml`. 16 | 17 | `wheels:` download wheels and write paths to the `blender_manifest.toml`. 18 | """ 19 | 20 | from pathlib import Path 21 | 22 | from dep_logic.specifiers import RangeSpecifier 23 | from packaging.version import Version 24 | 25 | DATA_DIR = Path(__file__).parent / "data" 26 | 27 | _MIN_UV_VERSION = Version("0.7.0") 28 | 29 | _MAX_UV_VERSION = Version("1") 30 | 31 | UV_VERSION_RANGE = RangeSpecifier( 32 | min=_MIN_UV_VERSION, include_min=True, max=_MAX_UV_VERSION, include_max=False 33 | ) 34 | -------------------------------------------------------------------------------- /tests/peeler/test_toml_utils.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pytest import mark 3 | 4 | from tomlkit import TOMLDocument 5 | from tomlkit.items import Comment 6 | 7 | from peeler.toml_utils import get_comments 8 | 9 | 10 | @mark.parametrize( 11 | ("toml_document", "comments"), 12 | [ 13 | ( 14 | "simple.toml", 15 | [], 16 | ), 17 | ( 18 | "simple_with_comments.toml", 19 | [ 20 | "# first", 21 | "# inline 1", 22 | "# in array", 23 | "# middle", 24 | "# under", 25 | "# inline 2", 26 | "# in array 2", 27 | "# object_in_array", 28 | "# inline 3", 29 | "# inline 4", 30 | "# end", 31 | ], 32 | ), 33 | ], 34 | indirect=["toml_document"], 35 | ) 36 | def test_get_comments(toml_document: TOMLDocument, comments: List[Comment]) -> None: 37 | assert get_comments(toml_document) == comments 38 | -------------------------------------------------------------------------------- /tests/peeler/wheels/data/lock_file_uv.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.11" 3 | 4 | [[package]] 5 | name = "package1" 6 | version = "24.3.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "sdist_url_package1", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } 9 | wheels = [ 10 | { url = "wheels_url_package1", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, 11 | ] 12 | 13 | 14 | 15 | [[package]] 16 | name = "package2" 17 | version = "1.3.0" 18 | source = { registry = "https://pypi.org/simple" } 19 | sdist = { url = "sdist_url_package2", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } 20 | wheels = [ 21 | { url = "wheels_url_package2_1", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, 22 | { url = "wheels_url_package2_2", hash = "sha256:ac96cd038792094f408ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, 23 | ] 24 | -------------------------------------------------------------------------------- /peeler/schema.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import json 6 | from pathlib import Path 7 | from typing import Any, Dict 8 | 9 | from . import DATA_DIR 10 | 11 | 12 | peeler_json_schema_path = DATA_DIR / "peeler_pyproject_schema.json" 13 | blender_manifest_schema_path = DATA_DIR / "blender_manifest_schema.json" 14 | 15 | 16 | def peeler_json_schema() -> Dict[str, Any]: 17 | """Return the [tool.peeler] table json schema. 18 | 19 | :return: the schema as a Dict 20 | """ 21 | 22 | with Path(peeler_json_schema_path).open() as file: 23 | return json.load(file) 24 | 25 | 26 | def blender_manifest_json_schema() -> Dict[str, Any]: 27 | """Return the blender_manifest.toml json schema. 28 | 29 | Downloaded from `https://extensions.blender-defender.com/api/blender_manifest_v1.schema.json` 30 | 31 | :return: the schema as a Dict 32 | """ 33 | with Path(blender_manifest_schema_path).open() as file: 34 | return json.load(file) 35 | -------------------------------------------------------------------------------- /tests/peeler/utils/test__find_pyproject_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pytest import raises, fixture 4 | from typer import Exit 5 | 6 | from peeler.command.manifest import _find_pyproject_file 7 | from peeler.command.manifest import PYPROJECT_FILENAME 8 | 9 | 10 | @fixture 11 | def pyproject_file(tmp_path: Path) -> Path: 12 | pyproject = tmp_path / PYPROJECT_FILENAME 13 | with pyproject.open("w") as file: 14 | file.write("some text") 15 | 16 | return pyproject 17 | 18 | 19 | @fixture 20 | def pyproject_directory(pyproject_file: Path) -> Path: 21 | return pyproject_file.parent 22 | 23 | 24 | def test__find_pyproject_file_no_pyproject(tmp_path: Path) -> None: 25 | with raises(Exit, match=f"No {PYPROJECT_FILENAME} found at"): 26 | _find_pyproject_file(tmp_path) 27 | 28 | 29 | def test__find_pyproject_file(pyproject_file: Path) -> None: 30 | assert _find_pyproject_file(pyproject_file) == pyproject_file 31 | 32 | 33 | def test__find_pyproject_dir(pyproject_directory: Path, pyproject_file: Path) -> None: 34 | assert _find_pyproject_file(pyproject_directory) == pyproject_file 35 | -------------------------------------------------------------------------------- /tests/peeler/conftest.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import json 3 | import sys 4 | from pathlib import Path 5 | from typing import Any, Dict 6 | 7 | import tomlkit 8 | import typer 9 | from pytest import FixtureRequest, MonkeyPatch, fixture 10 | from tomlkit import TOMLDocument 11 | 12 | from peeler import DATA_DIR 13 | 14 | TEST_DATA_DIR = Path(__file__).parent / "data" 15 | 16 | 17 | @fixture(autouse=(sys.platform == "win32")) 18 | def mock_typer_echo(monkeypatch: MonkeyPatch) -> None: 19 | monkeypatch.setattr(typer, "echo", builtins.print) 20 | 21 | 22 | @fixture 23 | def blender_manifest_schema() -> Dict[str, Any]: 24 | path: Path = DATA_DIR / "blender_manifest_schema.json" 25 | with path.open() as file: 26 | return json.load(file) 27 | 28 | 29 | @fixture 30 | def peeler_manifest_schema() -> Dict[str, Any]: 31 | path: Path = DATA_DIR / "peeler_pyproject_schema.json" 32 | with path.open() as file: 33 | return json.load(file) 34 | 35 | 36 | @fixture 37 | def toml_document(request: FixtureRequest) -> TOMLDocument: 38 | path: Path = TEST_DATA_DIR / request.param 39 | 40 | with path.open() as file: 41 | return tomlkit.load(file) 42 | -------------------------------------------------------------------------------- /peeler-json-schema/__main__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Dict, Any 3 | import json 4 | 5 | 6 | def main() -> None: 7 | """Create a json schema for peeler.""" 8 | 9 | # load blender_manifest schema 10 | # Downloaded from `https://extensions.blender-defender.com/api/blender_manifest_v1.schema.json` 11 | with Path( 12 | Path(__file__).parent / "data" / "blender_manifest_schema.json" 13 | ).open() as input_file: 14 | bl_manifest_schema: Dict[str, Any] = json.load(input_file) 15 | 16 | # load peeler properties schema 17 | with Path( 18 | Path(__file__).parent / "data" / "peeler_schema.json" 19 | ).open() as input_file: 20 | peeler_prop: Dict[str, Any] = json.load(input_file) 21 | 22 | # write blender manifest properties into peeler manifest properties 23 | peeler_prop["properties"]["manifest"]["properties"] = bl_manifest_schema[ 24 | "properties" 25 | ] 26 | 27 | with Path(Path(__file__).parent / "data" / "peeler_pyproject_schema.json").open( 28 | "w" 29 | ) as output_file: 30 | json.dump(peeler_prop, output_file, ensure_ascii=False, indent=4) 31 | 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /tests/peeler/wheels/data/lock_file_pylock.toml: -------------------------------------------------------------------------------- 1 | lock-version = "1.0" 2 | created-by = "uv" 3 | requires-python = "==3.11.*" 4 | 5 | [[packages]] 6 | name = "package1" 7 | version = "24.3.0" 8 | index = "https://pypi.org/simple" 9 | sdist = { url = "sdist_url_package1", upload-time = 2024-02-06T00:26:44Z, size = 805984, hashes = { sha256 = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff" } } 10 | wheels = [ 11 | { url = "wheels_url_package1", upload-time = 2024-02-05T23:51:50Z, size = 20630554, hashes = { sha256 = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308" } }, 12 | ] 13 | 14 | [[packages]] 15 | name = "package2" 16 | version = "1.3.0" 17 | index = "https://pypi.org/simple" 18 | sdist = { url = "sdist_url_package2", upload-time = 2024-02-06T00:26:44Z, size = 805984, hashes = { sha256 = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff" } } 19 | wheels = [ 20 | { url = "wheels_url_package2_1", upload-time = 2024-02-05T23:51:50Z, size = 63397, hashes = { sha256 = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308" } }, 21 | { url = "wheels_url_package2_2", upload-time = 2024-02-05T23:51:50Z, size = 63397, hashes = { sha256 = "sha256:ac96cd038792094f408ad1f6ff80837353805ac950cd2aa0e0625ef19850c308" } }, 22 | ] 23 | -------------------------------------------------------------------------------- /tests/peeler/wheels/conftest.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | import tomlkit 5 | from pytest import FixtureRequest, fixture 6 | from tomlkit import TOMLDocument 7 | 8 | from peeler.wheels.lock import UrlFetcherCreator 9 | 10 | TEST_DATA_DIR = Path(__file__).parent / "data" 11 | TEST_PYPROJECT = TEST_DATA_DIR / "pyproject.toml" 12 | TEST_LOCK = TEST_DATA_DIR / "uv.lock" 13 | 14 | 15 | @fixture 16 | def uv_lock_file() -> TOMLDocument: 17 | path: Path = TEST_DATA_DIR / "lock_file_uv.toml" 18 | with path.open() as file: 19 | return tomlkit.load(file) 20 | 21 | @fixture 22 | def pylock_file() -> TOMLDocument: 23 | path: Path = TEST_DATA_DIR / "lock_file_pylock.toml" 24 | with path.open() as file: 25 | return tomlkit.load(file) 26 | 27 | 28 | @fixture 29 | def pyproject_path_with_lock(tmp_path: Path) -> Path: 30 | shutil.copy2(TEST_LOCK, tmp_path / TEST_LOCK.name) 31 | return Path(shutil.copy2(TEST_PYPROJECT, tmp_path / TEST_PYPROJECT.name)) 32 | 33 | 34 | @fixture 35 | def pyproject_path_without_lock(tmp_path: Path) -> Path: 36 | return Path(shutil.copy2(TEST_PYPROJECT, tmp_path / TEST_PYPROJECT.name)) 37 | 38 | 39 | 40 | @fixture 41 | def url_fetcher_creator(request: FixtureRequest) -> UrlFetcherCreator: 42 | return UrlFetcherCreator(request.param) -------------------------------------------------------------------------------- /tests/peeler/uv_utils/test_check_uv_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | from unittest.mock import MagicMock, Mock 4 | 5 | import pytest 6 | from click import ClickException 7 | 8 | from peeler.uv_utils import check_uv_version 9 | 10 | 11 | @pytest.mark.skipif( 12 | os.environ.get("CI-on-uv-release") == "true", 13 | reason="Disable in on-uv-release workflow", 14 | ) 15 | def test_check_uv_version() -> None: 16 | try: 17 | check_uv_version() 18 | except ClickException as e: 19 | pytest.fail(f"Should not raise a ClickException: {e.message}") 20 | 21 | 22 | @pytest.mark.parametrize( 23 | "run_stdout", 24 | ["uv 0.7.2 (481d05d8d 2025-04-30)"], 25 | ) 26 | @mock.patch("peeler.uv_utils.run") 27 | def test_check_uv_version_valid(mock_run: Mock, run_stdout: str) -> None: 28 | mock_stdout = MagicMock() 29 | mock_stdout.configure_mock(**{"stdout": run_stdout}) 30 | mock_run.return_value = mock_stdout 31 | try: 32 | check_uv_version() 33 | except ClickException as e: 34 | pytest.fail(f"Should not raise a ClickException: {e.message}") 35 | 36 | 37 | @pytest.mark.parametrize("run_stdout", ["uv 0.4.0", "uv not found"]) 38 | @mock.patch("peeler.uv_utils.run") 39 | def test_check_uv_version_raises(mock_run: Mock, run_stdout: str) -> None: 40 | mock_stdout = MagicMock() 41 | mock_stdout.configure_mock(**{"stdout": run_stdout}) 42 | mock_run.return_value = mock_stdout 43 | 44 | with pytest.raises(ClickException): 45 | check_uv_version() 46 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.11 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: check-added-large-files 9 | - id: check-docstring-first 10 | - id: check-json 11 | - id: check-ast 12 | - id: check-merge-conflict 13 | - id: check-toml 14 | - id: check-yaml 15 | - id: end-of-file-fixer 16 | - id: name-tests-test 17 | args: ['--pytest-test-first'] 18 | - id: trailing-whitespace 19 | 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v1.15.0 22 | hooks: 23 | - id: mypy 24 | args: 25 | - --config-file 26 | - mypy.ini 27 | 28 | - repo: https://github.com/astral-sh/ruff-pre-commit 29 | # Ruff version. 30 | rev: v0.11.8 31 | hooks: 32 | # Run the linter. 33 | - id: ruff 34 | args: 35 | - check 36 | - --fix 37 | - --config 38 | - .ruff.toml 39 | # Run the formatter. 40 | - id: ruff-format 41 | args: 42 | - --config 43 | - .ruff.toml 44 | 45 | - repo: https://github.com/Lucas-C/pre-commit-hooks 46 | rev: v1.5.5 47 | hooks: 48 | - id: insert-license 49 | files: ^peeler[\/\\].*\.py$ 50 | args: 51 | - --license-filepath 52 | - license_header.txt 53 | - --use-current-year 54 | 55 | - repo: https://github.com/executablebooks/mdformat 56 | rev: 0.7.22 57 | hooks: 58 | - id: mdformat 59 | additional_dependencies: 60 | - mdformat-ruff 61 | -------------------------------------------------------------------------------- /peeler/toml_utils.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from typing import List 6 | 7 | from tomlkit import TOMLDocument 8 | from tomlkit.items import AoT, AbstractTable, Array, Whitespace 9 | from tomlkit.container import Container 10 | 11 | 12 | def get_comments(document_or_table: TOMLDocument | Container | AoT) -> List[str]: 13 | """Retrieve comments from a `TOMLDocument` or a Array of table literal. 14 | 15 | :param document: the document 16 | :return: a list of comments 17 | """ 18 | 19 | comments = [] 20 | 21 | if isinstance(document_or_table, (Container, TOMLDocument)): 22 | items = [value for _, value in document_or_table.body] 23 | elif isinstance(document_or_table, AoT): 24 | items = [value for value in document_or_table.body] 25 | else: 26 | raise TypeError(f"Invalid document type: {type(document_or_table)}") 27 | 28 | for item in items: 29 | if isinstance(item, Whitespace): 30 | continue 31 | 32 | if comment := item.trivia.comment: 33 | comments.append(comment) 34 | 35 | if isinstance(item, AoT): 36 | comments.extend(get_comments(item)) 37 | 38 | elif isinstance(item, AbstractTable): 39 | comments.extend(get_comments(item.value)) 40 | 41 | elif isinstance(item, Array): 42 | for elem in item._value: 43 | if elem.value and not isinstance(elem.value, Whitespace): 44 | if comment := elem.value.trivia.comment: 45 | comments.append(comment) 46 | if elem.comment: 47 | comments.append(elem.comment.trivia.comment) 48 | 49 | return comments 50 | -------------------------------------------------------------------------------- /.github/actions/unit_tests/action.yml: -------------------------------------------------------------------------------- 1 | name: Unit Testing 2 | description: Run python unit test with pytest and specified python and uv versions 3 | 4 | inputs: 5 | uv-version: 6 | description: uv version to test with 7 | required: true 8 | default: '' 9 | python-version: 10 | description: python version to test with 11 | required: true 12 | default: '' 13 | 14 | outputs: 15 | test-output: 16 | description: Outcome of the test step 17 | value: ${{ steps.run_tests.outcome }} 18 | test-logs: 19 | description: Pytest logs 20 | value: pytest-logs-uv-${{ inputs.uv-version }}-${{ inputs.python-version }} 21 | 22 | runs: 23 | using: 'composite' 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - uses: astral-sh/setup-uv@v5 28 | with: 29 | version: ${{ inputs.uv-version }} 30 | python-version: ${{ inputs.python-version }} 31 | enable-cache: true 32 | cache-suffix: ${{ inputs.uv-version }}-${{ inputs.python-version }} 33 | ignore-nothing-to-cache: true 34 | 35 | - name: Install the project 36 | shell: bash 37 | run: uv sync --all-extras --dev --no-extra uv 38 | 39 | - name: Static Type Check 40 | shell: bash 41 | id: mypy 42 | run: uvx mypy . 43 | 44 | - name: Run unit tests 45 | shell: bash 46 | id: run_tests 47 | run: | 48 | uv version 49 | uv run pytest --tb=no -q | tee pytest_output.txt 50 | 51 | 52 | - name: Upload pytest logs 53 | id: upload_tests_logs 54 | uses: actions/upload-artifact@v4 55 | if: always() 56 | with: 57 | name: pytest-logs-uv-${{ inputs.uv-version }}-${{ inputs.python-version }} 58 | path: pytest_output.txt 59 | 60 | - name: Reduce uv cache size 61 | shell: bash 62 | if: always() 63 | run: uv cache prune --ci 64 | 65 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "peeler" 3 | description = "A tool to use a pyproject.toml instead of a blender_manifest.toml to build Blender add-ons" 4 | readme = "README.md" 5 | requires-python = ">=3.11" 6 | classifiers = [ 7 | "Intended Audience :: Developers", 8 | "Topic :: Multimedia :: Graphics :: 3D Modeling", 9 | "Topic :: Multimedia :: Graphics :: 3D Rendering", 10 | "Operating System :: OS Independent", 11 | "Programming Language :: Python :: 3", 12 | "Programming Language :: Python :: 3.11", 13 | "Development Status :: 4 - Beta" 14 | ] 15 | license = {file = "LICENSE"} 16 | authors = [ 17 | { name = "Maxime Letellier", email = "maxime.eliot.letellier@gmail.com" } 18 | ] 19 | keywords = ["blender", "add-on"] 20 | version = "0.0.1" 21 | 22 | dependencies = [ 23 | "dep-logic>=0.4.11", 24 | "jsonschema>=4.23.0", 25 | "packaging>=24.2", 26 | "pyproject-metadata>=0.9.0", 27 | "rich>=13.9.4", 28 | "tomlkit>=0.13.2", 29 | "typer>=0.15.1", 30 | "validate-pyproject>=0.23", 31 | "wheel-filename>=1.4.2", 32 | ] 33 | 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/Maxioum/Peeler" 37 | Repository = "https://github.com/Maxioum/Peeler.git" 38 | Changelog = "https://github.com/Maxioum/Peeler/releases" 39 | Issues = "https://github.com/Maxioum/Peeler/issues" 40 | 41 | [project.scripts] 42 | peeler = "peeler.cli:app" 43 | 44 | [project.optional-dependencies] 45 | uv = [ 46 | "uv >= 0.7", 47 | "uv < 1" 48 | ] 49 | 50 | [tool.uv] 51 | required-version = ">=0.7" 52 | 53 | [tool.uv.workspace] 54 | members = ["schema-convertor"] 55 | 56 | 57 | [build-system] 58 | requires = ["hatchling"] 59 | build-backend = "hatchling.build" 60 | 61 | [dependency-groups] 62 | dev = [ 63 | "pytest>=8.3.4", 64 | "pytest-click>=1.1.0", 65 | "pytest-cov>=6.0.0", 66 | ] 67 | 68 | 69 | [tool.hatch.version] 70 | path = "version.txt" 71 | 72 | [tool.hatch.build] 73 | include = ["peeler"] 74 | exclude = ["tests"] 75 | 76 | [tool.pytest.ini_options] 77 | pythonpath = ["."] 78 | addopts = "-lsv" 79 | testpaths = ["tests"] 80 | -------------------------------------------------------------------------------- /peeler/command/manifest.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from pathlib import Path 6 | 7 | import tomlkit 8 | from rich import print 9 | from typer import Exit 10 | 11 | from ..manifest.validate import validate_manifest 12 | from ..manifest.write import export_to_blender_manifest 13 | from ..pyproject.manifest_adapter import ManifestAdapter 14 | from ..pyproject.validator import PyprojectValidator 15 | from ..schema import blender_manifest_json_schema, peeler_json_schema 16 | 17 | PYPROJECT_FILENAME = "pyproject.toml" 18 | 19 | 20 | def _find_pyproject_file(pyproject_path: Path) -> Path: 21 | if pyproject_path.is_dir(): 22 | pyproject_path = pyproject_path / PYPROJECT_FILENAME 23 | 24 | if not pyproject_path.is_file(): 25 | raise Exit( 26 | f"No {PYPROJECT_FILENAME} found at {pyproject_path.parent.resolve()}" 27 | ) 28 | 29 | return pyproject_path 30 | 31 | 32 | def manifest_command( 33 | pyproject_path: Path, blender_manifest_path: Path, validate: bool 34 | ) -> None: 35 | """Create or update a blender_manifest.toml from a pyproject.toml. 36 | 37 | :param pyproject_path: the path to the `pyproject.toml` file or directory 38 | :param blender_manifest_path: path to the `blender_manifest.toml` file or directory to be updated or created 39 | """ 40 | pyproject_path = _find_pyproject_file(pyproject_path) 41 | 42 | with Path(pyproject_path).open() as file: 43 | pyproject = tomlkit.load(file) 44 | 45 | if validate: 46 | validator = PyprojectValidator(pyproject, pyproject_path) 47 | validator() 48 | 49 | manifest_adapter = ManifestAdapter( 50 | pyproject, blender_manifest_json_schema(), peeler_json_schema() 51 | ) 52 | 53 | doc = manifest_adapter.to_blender_manifest() 54 | 55 | validate_manifest(doc) 56 | 57 | blender_manifest_path = export_to_blender_manifest(doc, blender_manifest_path) 58 | 59 | blender_manifest_path = blender_manifest_path.resolve() 60 | 61 | print( 62 | f"[bright_black]Exported manifest to:[/][bright_blue] {blender_manifest_path}" 63 | ) 64 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | indent-width = 4 2 | 3 | target-version = "py311" 4 | 5 | # Exclude a variety of commonly ignored directories. 6 | exclude = [ 7 | ".bzr", 8 | ".direnv", 9 | ".eggs", 10 | ".git", 11 | ".git-rewrite", 12 | ".hg", 13 | ".ipynb_checkpoints", 14 | ".mypy_cache", 15 | ".nox", 16 | ".pants.d", 17 | ".pyenv", 18 | ".pytest_cache", 19 | ".pytype", 20 | ".ruff_cache", 21 | ".svn", 22 | ".tox", 23 | ".venv", 24 | ".vscode", 25 | "__pypackages__", 26 | "_build", 27 | "buck-out", 28 | "build", 29 | "dist", 30 | "node_modules", 31 | "site-packages", 32 | "venv", 33 | ] 34 | 35 | [lint] 36 | select = [ 37 | 'RUF', # Ruff rules 38 | 'W', # pycodestyle 39 | 'NPY', # numpy 40 | 'PD', # pandas 41 | "PTH", 42 | "D", 43 | "I", # isort 44 | "F" 45 | ] 46 | 47 | ignore = [ 48 | "RUF012", 49 | "D202", 50 | "D100" 51 | ] 52 | 53 | # Allow fix for all enabled rules (when `--fix`) is provided. 54 | fixable = ["ALL"] 55 | unfixable = [] 56 | 57 | # Allow unused variables when underscore-prefixed. 58 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 59 | 60 | [lint.per-file-ignores] 61 | # Allow unused imports in __init__.py files. 62 | "__init__.py" = ["F401"] 63 | # Disable Docstring check in tests 64 | "test_*.py" = ["D"] 65 | "conftest.py" = ["D"] 66 | 67 | [lint.pydocstyle] 68 | convention = "numpy" # Accepts: "google", "numpy", or "pep257". 69 | 70 | [format] 71 | # Like Black, use double quotes for strings. 72 | quote-style = "double" 73 | 74 | # Like Black, indent with spaces, rather than tabs. 75 | indent-style = "space" 76 | 77 | # Like Black, respect magic trailing commas. 78 | skip-magic-trailing-comma = false 79 | 80 | # Like Black, automatically detect the appropriate line ending. 81 | line-ending = "auto" 82 | 83 | # Enable auto-formatting of code examples in docstrings. Markdown, 84 | # reStructuredText code/literal blocks and doctests are all supported. 85 | # 86 | # This is currently disabled by default, but it is planned for this 87 | # to be opt-out in the future. 88 | docstring-code-format = true 89 | 90 | # Set the line length limit used when formatting code snippets in 91 | # docstrings. 92 | # 93 | # This only has an effect when the `docstring-code-format` setting is 94 | # enabled. 95 | docstring-code-line-length = "dynamic" 96 | -------------------------------------------------------------------------------- /.github/workflows/on_uv_release.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests on uv updates 2 | 3 | on: 4 | schedule: 5 | - cron: "42 4 * * 1" 6 | workflow_dispatch: 7 | inputs: 8 | manual-version: 9 | description: "Manually input uv version" 10 | required: false 11 | default: "" 12 | 13 | env: 14 | UV_SYSTEM_PYTHON: 1 15 | 16 | jobs: 17 | check-uv-version: 18 | runs-on: ubuntu-latest 19 | outputs: 20 | latest-version: ${{ steps.get-latest-release.outputs.latest-version }} 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Get latest uv release 26 | id: get-latest-release 27 | run: | 28 | if [[ -n "${{ github.event.inputs.manual-version }}" ]]; then 29 | LATEST_VERSION="${{ github.event.inputs.manual-version }}" 30 | else 31 | LATEST_VERSION=$(curl https://api.github.com/repos/astral-sh/uv/releases/latest -s | jq .name -r) 32 | fi 33 | echo "latest-version=$LATEST_VERSION" >> $GITHUB_OUTPUT 34 | 35 | 36 | unit-tests: 37 | needs: check-uv-version 38 | runs-on: ubuntu-latest 39 | outputs: 40 | tests-passed: ${{ steps.unit-tests.outputs.test-output == 'success' }} 41 | tests-logs: ${{ steps.unit-tests.outputs.test-logs}} 42 | continue-on-error: true 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | uv-version: 47 | - ${{ needs.check-uv-version.outputs.latest-version }} 48 | 49 | python-version: 50 | - "3.11" 51 | - "3.12" 52 | - "3.13" 53 | 54 | steps: 55 | - uses: actions/checkout@v4 56 | 57 | - name: Unit Tests 58 | id: unit-tests 59 | uses: ./.github/actions/unit_tests/ 60 | with: 61 | uv-version: ${{ matrix.uv-version }} 62 | python-version: ${{ matrix.python-version }} 63 | env: 64 | CI-on-uv-release: true 65 | 66 | 67 | create-issue: 68 | needs: [check-uv-version, unit-tests] 69 | if: needs.unit-tests.outputs.tests-passed == 'false' 70 | runs-on: ubuntu-latest 71 | env: 72 | issue-title: "uv ${{ needs.check-uv-version.outputs.latest-version }} causes tests failures" 73 | 74 | steps: 75 | - name: Download Test Results 76 | uses: actions/download-artifact@v4 77 | with: 78 | name: ${{ needs.unit-tests.outputs.tests-logs }} 79 | 80 | - name: Create issue for failing tests 81 | uses: peter-evans/create-issue-from-file@v5 82 | with: 83 | title: ${{ env.issue-title }} 84 | content-filepath: pytest_output.txt 85 | labels: bug, dependency-update 86 | assignees: ${{github.repository_owner}} 87 | -------------------------------------------------------------------------------- /tests/peeler/utils/test_restore_file.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | from pathlib import Path 3 | from tempfile import NamedTemporaryFile 4 | 5 | from pytest import fixture, raises 6 | 7 | from peeler.utils import restore_file 8 | 9 | _ORIGINAL_CONTENT = "Original Content" 10 | _TEMP_CONTENT = "Temporary Content" 11 | 12 | 13 | @fixture 14 | def original_file() -> Generator[Path, None, None]: 15 | with NamedTemporaryFile(mode="w", delete=False) as tmp: 16 | tmp.write(_ORIGINAL_CONTENT) 17 | 18 | try: 19 | yield Path(tmp.name) 20 | finally: 21 | tmp.close() 22 | 23 | 24 | def test_restore_file(original_file: Path) -> None: 25 | with restore_file(original_file): 26 | original_file.write_text(_TEMP_CONTENT) 27 | 28 | assert original_file.read_text() == _ORIGINAL_CONTENT 29 | 30 | 31 | def test_restore_file_on_exception(original_file: Path) -> None: 32 | class SomeError(Exception): 33 | pass 34 | 35 | try: 36 | with restore_file(original_file): 37 | original_file.write_text(_TEMP_CONTENT) 38 | raise SomeError() 39 | except SomeError: 40 | ... 41 | 42 | assert original_file.read_text() == _ORIGINAL_CONTENT 43 | 44 | 45 | def test_restore_file_on_deleted(original_file: Path) -> None: 46 | with restore_file(original_file): 47 | original_file.unlink() 48 | 49 | assert original_file.exists() 50 | assert original_file.read_text() == _ORIGINAL_CONTENT 51 | 52 | 53 | def test_restore_file_on_exit(original_file: Path) -> None: 54 | try: 55 | with restore_file(original_file): 56 | original_file.write_text(_TEMP_CONTENT) 57 | exit(0) 58 | except SystemExit: 59 | ... 60 | 61 | assert original_file.read_text() == _ORIGINAL_CONTENT 62 | 63 | 64 | def test_restore_file_file_missing(original_file: Path) -> None: 65 | original_file.unlink() 66 | 67 | with restore_file(original_file, missing_ok=True): 68 | original_file.write_text(_TEMP_CONTENT) 69 | 70 | assert not original_file.exists() 71 | 72 | 73 | def test_restore_file_file_missing_raises(original_file: Path) -> None: 74 | original_file.unlink() 75 | 76 | with raises(FileNotFoundError): 77 | with restore_file(original_file, missing_ok=False): 78 | original_file.write_text(_TEMP_CONTENT) 79 | 80 | assert not original_file.exists() 81 | 82 | 83 | def test_restore_file_twice(original_file: Path) -> None: 84 | with restore_file(original_file): 85 | original_file.write_text(_TEMP_CONTENT) 86 | 87 | with restore_file(original_file): 88 | original_file.write_text(_TEMP_CONTENT + _TEMP_CONTENT) 89 | 90 | assert original_file.read_text() == _TEMP_CONTENT 91 | 92 | assert original_file.read_text() == _ORIGINAL_CONTENT 93 | -------------------------------------------------------------------------------- /tests/peeler/wheels/data/pylock.toml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --format pylock.toml 3 | lock-version = "1.0" 4 | created-by = "uv" 5 | requires-python = "==3.11.*" 6 | 7 | [[packages]] 8 | name = "numpy" 9 | version = "1.26.4" 10 | index = "https://pypi.org/simple" 11 | sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", upload-time = 2024-02-06T00:26:44Z, size = 15786129, hashes = { sha256 = "2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010" } } 12 | wheels = [ 13 | { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2024-02-05T23:51:50Z, size = 20630554, hashes = { sha256 = "4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71" } }, 14 | { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2024-02-05T23:52:15Z, size = 13997127, hashes = { sha256 = "edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef" } }, 15 | { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-02-05T23:52:47Z, size = 14222994, hashes = { sha256 = "7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e" } }, 16 | { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-02-05T23:53:15Z, size = 18252005, hashes = { sha256 = "666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5" } }, 17 | { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", upload-time = 2024-02-05T23:53:42Z, size = 13885297, hashes = { sha256 = "96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a" } }, 18 | { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", upload-time = 2024-02-05T23:54:11Z, size = 18093567, hashes = { sha256 = "60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a" } }, 19 | { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", upload-time = 2024-02-05T23:54:26Z, size = 5968812, hashes = { sha256 = "1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20" } }, 20 | { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", upload-time = 2024-02-05T23:54:53Z, size = 15811913, hashes = { sha256 = "cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2" } }, 21 | ] 22 | -------------------------------------------------------------------------------- /tests/peeler/wheels/data/pylock.test.toml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --format pylock.toml 3 | lock-version = "1.0" 4 | created-by = "uv" 5 | requires-python = "==3.11.*" 6 | 7 | [[packages]] 8 | name = "numpy" 9 | version = "1.26.4" 10 | index = "https://pypi.org/simple" 11 | sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", upload-time = 2024-02-06T00:26:44Z, size = 15786129, hashes = { sha256 = "2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010" } } 12 | wheels = [ 13 | { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2024-02-05T23:51:50Z, size = 20630554, hashes = { sha256 = "4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71" } }, 14 | { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2024-02-05T23:52:15Z, size = 13997127, hashes = { sha256 = "edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef" } }, 15 | { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2024-02-05T23:52:47Z, size = 14222994, hashes = { sha256 = "7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e" } }, 16 | { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2024-02-05T23:53:15Z, size = 18252005, hashes = { sha256 = "666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5" } }, 17 | { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", upload-time = 2024-02-05T23:53:42Z, size = 13885297, hashes = { sha256 = "96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a" } }, 18 | { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", upload-time = 2024-02-05T23:54:11Z, size = 18093567, hashes = { sha256 = "60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a" } }, 19 | { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", upload-time = 2024-02-05T23:54:26Z, size = 5968812, hashes = { sha256 = "1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20" } }, 20 | { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", upload-time = 2024-02-05T23:54:53Z, size = 15811913, hashes = { sha256 = "cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2" } }, 21 | ] 22 | -------------------------------------------------------------------------------- /tests/peeler/manifest/test_write.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from tomlkit import TOMLDocument 5 | from tomlkit.toml_file import TOMLFile 6 | 7 | from peeler.manifest.write import ( 8 | _write, 9 | export_to_blender_manifest, 10 | BLENDER_MANIFEST_FILENAME, 11 | ) 12 | 13 | 14 | @pytest.mark.parametrize(("toml_document"), [("blender_manifest.toml")], indirect=True) 15 | def test_write_manifest(toml_document: TOMLDocument, tmpdir: str) -> None: 16 | output_path = Path(tmpdir) / "blender_manifest.toml" 17 | 18 | _write(toml_document, output_path) 19 | 20 | assert Path(output_path).exists() 21 | 22 | 23 | @pytest.mark.parametrize(("toml_document"), [("blender_manifest.toml")], indirect=True) 24 | def test_write_manifest_unchanged(toml_document: TOMLDocument, tmpdir: str) -> None: 25 | output_path = Path(tmpdir) / "blender_manifest.toml" 26 | 27 | _write(toml_document, output_path) 28 | 29 | content = Path(output_path).read_text() 30 | assert content == toml_document.as_string() 31 | 32 | 33 | @pytest.mark.parametrize( 34 | ("toml_document", "overwrite"), 35 | [("simple.toml", True), ("simple.toml", False)], 36 | indirect=["toml_document"], 37 | ) 38 | def test_write_manifest_update( 39 | toml_document: TOMLDocument, tmpdir: str, overwrite: bool 40 | ) -> None: 41 | # write first toml to disk 42 | path = Path(tmpdir, "temp_simple.toml") 43 | TOMLFile(path).write(toml_document) 44 | 45 | # create copy and modify a value 46 | toml_copy = toml_document.copy() 47 | toml_copy.update({"boolean": not toml_document["boolean"]}) 48 | 49 | # call the function with overwrite arg 50 | _write(toml_copy, path, overwrite=overwrite) 51 | 52 | # check if the value has changed according to the overwrite parameter 53 | value = TOMLFile(path).read()["boolean"] 54 | 55 | if overwrite: 56 | assert value != toml_document["boolean"] 57 | else: 58 | assert value == toml_document["boolean"] 59 | 60 | 61 | @pytest.mark.parametrize(("toml_document"), [("blender_manifest.toml")], indirect=True) 62 | def test_export_to_blender_manifest_dirname( 63 | toml_document: TOMLDocument, tmpdir: str 64 | ) -> None: 65 | assert export_to_blender_manifest( 66 | toml_document, Path(tmpdir), allow_non_default_name=False 67 | ).exists() 68 | 69 | 70 | @pytest.mark.parametrize(("toml_document"), [("blender_manifest.toml")], indirect=True) 71 | def test_export_to_blender_manifest_value_error( 72 | toml_document: TOMLDocument, tmpdir: str 73 | ) -> None: 74 | with pytest.raises(ValueError): 75 | export_to_blender_manifest( 76 | toml_document, 77 | Path(tmpdir, "3dsmax_manifest.toml"), 78 | allow_non_default_name=False, 79 | ) 80 | 81 | 82 | @pytest.mark.parametrize(("toml_document"), [("blender_manifest.toml")], indirect=True) 83 | def test_export_to_blender_manifest_filename( 84 | toml_document: TOMLDocument, tmpdir: str 85 | ) -> None: 86 | assert export_to_blender_manifest( 87 | toml_document, 88 | Path(tmpdir) / BLENDER_MANIFEST_FILENAME, 89 | allow_non_default_name=False, 90 | ).exists() 91 | -------------------------------------------------------------------------------- /tests/peeler/wheels/data/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = "==3.11.*" 4 | 5 | [[package]] 6 | name = "numpy" 7 | version = "1.26.4" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload-time = "2024-02-05T23:51:50.149Z" }, 12 | { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload-time = "2024-02-05T23:52:15.314Z" }, 13 | { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload-time = "2024-02-05T23:52:47.569Z" }, 14 | { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload-time = "2024-02-05T23:53:15.637Z" }, 15 | { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload-time = "2024-02-05T23:53:42.16Z" }, 16 | { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" }, 17 | { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" }, 18 | { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" }, 19 | ] 20 | 21 | [[package]] 22 | name = "test-pyproject" 23 | version = "1.0.0" 24 | source = { virtual = "." } 25 | dependencies = [ 26 | { name = "numpy" }, 27 | ] 28 | 29 | [package.metadata] 30 | requires-dist = [{ name = "numpy", specifier = "<2" }] 31 | -------------------------------------------------------------------------------- /tests/peeler/data/blender_manifest.toml: -------------------------------------------------------------------------------- 1 | # file from https://docs.blender.org/manual/en/dev/advanced/extensions/getting_started.html 2 | 3 | schema_version = "1.0.0" 4 | 5 | # Example of manifest file for a Blender extension 6 | # Change the values according to your extension 7 | id = "my_example_extension" 8 | version = "1.0.0" 9 | name = "My Example Extension" 10 | tagline = "This is another extension" 11 | maintainer = "Developer name " 12 | # Supported types: "add-on", "theme" 13 | type = "add-on" 14 | 15 | # # Optional: link to documentation, support, source files, etc 16 | # website = "https://extensions.blender.org/add-ons/my-example-package/" 17 | 18 | # # Optional: tag list defined by Blender and server, see: 19 | # # https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html 20 | # tags = ["Animation", "Sequencer"] 21 | 22 | blender_version_min = "4.2.0" 23 | # # Optional: Blender version that the extension does not support, earlier versions are supported. 24 | # # This can be omitted and defined later on the extensions platform if an issue is found. 25 | # blender_version_max = "5.1.0" 26 | 27 | # License conforming to https://spdx.org/licenses/ (use "SPDX: prefix) 28 | # https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html 29 | license = [ 30 | "SPDX:GPL-3.0-or-later", 31 | ] 32 | # # Optional: required by some licenses. 33 | # copyright = [ 34 | # "2002-2024 Developer Name", 35 | # "1998 Company Name", 36 | # ] 37 | 38 | # # Optional: list of supported platforms. If omitted, the extension will be available in all operating systems. 39 | # platforms = ["windows-x64", "macos-arm64", "linux-x64"] 40 | # # Other supported platforms: "windows-arm64", "macos-x64" 41 | 42 | # # Optional: bundle 3rd party Python modules. 43 | # # https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html 44 | # wheels = [ 45 | # "./wheels/hexdump-3.3-py3-none-any.whl", 46 | # "./wheels/jsmin-3.0.1-py3-none-any.whl", 47 | # ] 48 | 49 | # # Optional: add-ons can list which resources they will require: 50 | # # * files (for access of any filesystem operations) 51 | # # * network (for internet access) 52 | # # * clipboard (to read and/or write the system clipboard) 53 | # # * camera (to capture photos and videos) 54 | # # * microphone (to capture audio) 55 | # # 56 | # # If using network, remember to also check `bpy.app.online_access` 57 | # # https://docs.blender.org/manual/en/dev/advanced/extensions/addons.html#internet-access 58 | # # 59 | # # For each permission it is important to also specify the reason why it is required. 60 | # # Keep this a single short sentence without a period (.) at the end. 61 | # # For longer explanations use the documentation or detail page. 62 | # 63 | # [permissions] 64 | # network = "Need to sync motion-capture data to server" 65 | # files = "Import/export FBX from/to disk" 66 | # clipboard = "Copy and paste bone transforms" 67 | 68 | # # Optional: advanced build settings. 69 | # # https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build 70 | # [build] 71 | # # These are the default build excluded patterns. 72 | # # You only need to edit them if you want different options. 73 | # paths_exclude_pattern = [ 74 | # "__pycache__/", 75 | # "/.git/", 76 | # "/*.zip", 77 | # ] 78 | -------------------------------------------------------------------------------- /peeler/pyproject/parser.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from pathlib import Path 6 | from typing import Dict, List, Self 7 | 8 | from tomlkit import TOMLDocument 9 | from tomlkit.items import Table 10 | from tomlkit.toml_file import TOMLFile 11 | 12 | DependencyGroups = Dict[str, List[str | Dict[str, str]]] 13 | 14 | 15 | class PyprojectParser: 16 | """A class to parse values from a `pyproject.toml` with a peeler tool table. 17 | 18 | :param document: The TOML document representing the `pyproject.toml` file. 19 | """ 20 | 21 | def __init__(self, document: TOMLDocument) -> None: 22 | self._document = document 23 | 24 | @classmethod 25 | def from_file(cls, pyproject_file: Path) -> Self: 26 | """Construct a PyprojectParser instance from a pyproject.toml file. 27 | 28 | :param pyproject_file: the file to parse 29 | :return: A new PyprojectParser instance 30 | """ 31 | return cls(TOMLFile(pyproject_file).read()) 32 | 33 | @property 34 | def project_table(self) -> Table: 35 | """Retrieve the `[project]` table from the `pyproject.toml`. 36 | 37 | :return: The `[project]` table. 38 | """ 39 | if not hasattr(self, "_project_table"): 40 | self._project_table = self._document.get("project") 41 | return self._project_table 42 | 43 | @property 44 | def peeler_table(self) -> Table: 45 | """Retrieve the `[tool.peeler]` table from the `pyproject.toml`. 46 | 47 | :return: The `[tool.peeler]` table. 48 | """ 49 | if not hasattr(self, "_peeler_table"): 50 | self._peeler_table = self._document.get("tool", {}).get("peeler") 51 | return self._peeler_table 52 | 53 | @property 54 | def settings_table(self) -> Table: 55 | """Retrieve the `settings` table from the `[tool.peeler]` section, excluding `manifest`. 56 | 57 | :return: The `settings` table. 58 | """ 59 | if not hasattr(self, "_settings_table"): 60 | _ = self.manifest_table 61 | self._settings_table = self.peeler_table.remove("manifest") 62 | return self._settings_table 63 | 64 | @property 65 | def manifest_table(self) -> Table: 66 | """Retrieve the `manifest` table from the `[tool.peeler]` section. 67 | 68 | :return: The `manifest` table. 69 | """ 70 | if not hasattr(self, "_manifest_table"): 71 | self._manifest_table = self.peeler_table.get("manifest") 72 | return self._manifest_table 73 | 74 | @property 75 | def dependency_groups(self) -> Table | None: 76 | """Retrieve the `dependency-groups` table. 77 | 78 | :return: The `dependency-groups` table. 79 | """ 80 | if not hasattr(self, "_dependency_groups"): 81 | self._dependency_groups = self._document.get("dependency-groups") 82 | 83 | return self._dependency_groups 84 | 85 | @dependency_groups.setter 86 | def dependency_groups( 87 | self, dependency_groups: DependencyGroups | Table | None 88 | ) -> None: 89 | """Set the `dependency-groups` table. 90 | 91 | :param dependency_groups: The `dependency-groups` table. 92 | """ 93 | self._document["dependency-groups"] = dependency_groups 94 | -------------------------------------------------------------------------------- /peeler/pyproject/update.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | 6 | from typing import List 7 | 8 | import typer 9 | from dep_logic.specifiers import parse_version_specifier 10 | from packaging.requirements import Requirement 11 | from tomlkit.items import Table 12 | 13 | from peeler.pyproject import _BLENDER_SUPPORTED_PYTHON_VERSION 14 | from peeler.pyproject.parser import DependencyGroups, PyprojectParser 15 | from peeler.utils import normalize_package_name 16 | 17 | 18 | def update_requires_python(pyproject: PyprojectParser) -> PyprojectParser: 19 | """Update a pyproject file to restrict project supported python version to the versions supported by Blender. 20 | 21 | The specifier set will not be resolved, and can lead to contradictions. 22 | 23 | :param pyproject_file: the pyproject 24 | 25 | :return: the parsed pyproject 26 | """ 27 | 28 | requires_python = parse_version_specifier( 29 | pyproject.project_table.get("requires-python", "") 30 | ) 31 | 32 | requires_python &= _BLENDER_SUPPORTED_PYTHON_VERSION 33 | 34 | pyproject.project_table.update({"requires-python": str(requires_python)}) 35 | 36 | return pyproject 37 | 38 | 39 | def update_dependencies( 40 | pyproject: PyprojectParser, excluded_dependencies: List[str] 41 | ) -> PyprojectParser: 42 | """Update a pyproject file to remove dependencies from [project].dependencies table. 43 | 44 | :param pyproject_file: the pyproject 45 | 46 | :return: the parsed pyproject 47 | """ 48 | dependencies: List[str] | None = pyproject.project_table.get("dependencies", None) 49 | 50 | if not excluded_dependencies or not dependencies: 51 | return pyproject 52 | 53 | _excluded_dependencies = { 54 | normalize_package_name(package) for package in excluded_dependencies 55 | } 56 | 57 | _requirements = [Requirement(dependency) for dependency in dependencies] 58 | 59 | _requirements_filtered = list( 60 | filter( 61 | lambda req: normalize_package_name(req.name) not in _excluded_dependencies, 62 | _requirements, 63 | ) 64 | ) 65 | 66 | pyproject.project_table.update( 67 | {"dependencies": [str(req) for req in _requirements_filtered]} 68 | ) 69 | 70 | return pyproject 71 | 72 | 73 | def _warn_non_existant_group(group: str) -> None: 74 | typer.echo(f"Warning: Excluded dependency group `{group}` not found") 75 | 76 | 77 | def update_dependency_groups( 78 | pyproject: PyprojectParser, excluded_dependency_groups: List[str] 79 | ) -> PyprojectParser: 80 | """Update a pyproject file to remove dependency group from [dependecy-groups] table. 81 | 82 | :param pyproject_file: the pyproject 83 | 84 | :return: the parsed pyproject 85 | """ 86 | dependency_groups = pyproject.dependency_groups 87 | 88 | if not dependency_groups: 89 | for group in excluded_dependency_groups: 90 | _warn_non_existant_group(group) 91 | return pyproject 92 | 93 | for group in excluded_dependency_groups: 94 | if group in dependency_groups: 95 | del dependency_groups[group] 96 | else: 97 | _warn_non_existant_group(group) 98 | 99 | pyproject.dependency_groups = dependency_groups 100 | 101 | return pyproject 102 | -------------------------------------------------------------------------------- /tests/peeler/pyproject/test_validator.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from validate_pyproject.error_reporting import ValidationError 5 | 6 | from peeler.pyproject.parser import PyprojectParser 7 | from peeler.pyproject.validator import PyprojectValidator 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "validator", 12 | [ 13 | "pyproject_minimal.toml", 14 | ], 15 | indirect=True, 16 | ) 17 | def test_validator(validator: PyprojectValidator) -> None: 18 | try: 19 | validator() 20 | except ValidationError as e: 21 | pytest.fail(f"Should not raise a ValidationError: {e.message}") 22 | 23 | 24 | @pytest.mark.parametrize( 25 | ("validator", "match"), 26 | [ 27 | ( 28 | "pyproject_no_manifest_table.toml", 29 | r"(tool.peeler).+(contain).+(manifest).+(properties)", 30 | ), 31 | ("pyproject_no_peeler_table.toml", r"(contain).+(tool.peeler).+(table)"), 32 | ( 33 | "pyproject_peeler_table_empty.toml", 34 | r"(tool.peeler).+(contain).+(manifest).+(properties)", 35 | ), 36 | ], 37 | indirect=["validator"], 38 | ) 39 | def test_validator_invalid(validator: PyprojectValidator, match: str) -> None: 40 | with pytest.raises(ValidationError, match=match): 41 | validator() 42 | 43 | 44 | @pytest.mark.parametrize( 45 | ("pyproject_platforms"), 46 | (["android"], [], ["windows-amd64"], ["linux-x86_64"], ["macos-x86_64"]), 47 | indirect=["pyproject_platforms"], 48 | ) 49 | def test_validate_platform_raises( 50 | pyproject_platforms: PyprojectParser, 51 | ) -> None: 52 | with pytest.raises(ValidationError): 53 | PyprojectValidator(pyproject_platforms._document, Path("_"))() 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ("pyproject_platforms"), 58 | ( 59 | ["windows-x64"], 60 | ["macos-arm64"], 61 | ["linux-x64"], 62 | ["windows-arm64"], 63 | ["macos-x64"], 64 | ), 65 | indirect=["pyproject_platforms"], 66 | ) 67 | def test_validate_platform( 68 | pyproject_platforms: PyprojectParser, 69 | ) -> None: 70 | try: 71 | PyprojectValidator(pyproject_platforms._document, Path("_"))() 72 | except ValidationError as e: 73 | pytest.fail(f"Should not raise a ValidationError: {e.message}") 74 | 75 | 76 | @pytest.mark.parametrize( 77 | "validator", 78 | [ 79 | "pyproject_minimal.toml", 80 | ], 81 | indirect=True, 82 | ) 83 | def test_validator_requires_python_empty(validator: PyprojectValidator) -> None: 84 | try: 85 | validator() 86 | except ValidationError as e: 87 | pytest.fail(f"Should not raise a ValidationError: {e.message}") 88 | 89 | 90 | @pytest.mark.parametrize( 91 | "validator_requires_python", 92 | [">=3.6", "==3.11.*", ">=3.11.9,<3.13", "~=3.11.2", "==3.11.7"], 93 | indirect=True, 94 | ) 95 | def test_validator_requires_python( 96 | validator_requires_python: PyprojectValidator, 97 | ) -> None: 98 | try: 99 | validator_requires_python() 100 | except ValidationError as e: 101 | pytest.fail(f"Should not raise a ValidationError: {e.message}") 102 | 103 | 104 | @pytest.mark.parametrize( 105 | "validator_requires_python", 106 | [ 107 | "<=3.5", 108 | "<3.11", 109 | ">=3.12.0,<3.13", 110 | "~=3.12.0", 111 | "~=3.7.0", 112 | ], 113 | indirect=True, 114 | ) 115 | def test_validator_requires_python_invalid( 116 | validator_requires_python: PyprojectValidator, 117 | ) -> None: 118 | with pytest.raises(ValidationError, match="requires-python"): 119 | validator_requires_python() 120 | -------------------------------------------------------------------------------- /peeler/cli.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from pathlib import Path 6 | from typing import Annotated, List, Optional 7 | 8 | from typer import Argument, Option, Typer 9 | 10 | app = Typer() 11 | 12 | 13 | @app.command(help=f"Display the current installed version.", hidden=True) 14 | def version() -> None: 15 | """Call the version command.""" 16 | 17 | from .command.version import version_command 18 | 19 | version_command() 20 | 21 | 22 | @app.command( 23 | help=f"Create or update a blender_manifest.toml file from a pyproject.toml file.", 24 | ) 25 | def manifest( 26 | pyproject: Annotated[Path, Argument()], 27 | blender_manifest: Annotated[Path, Argument(default_factory=Path.cwd)], 28 | validate: Annotated[ 29 | bool, 30 | Option( 31 | help="Validate the `pyproject.toml` file against PEP specifications (https://packaging.python.org/en/latest/specifications/pyproject-toml/)" 32 | ), 33 | ] = True, 34 | ) -> None: 35 | """Call a command to create or update a blender_manifest.toml from a pyproject.toml. 36 | 37 | :param pyproject: the path to the `pyproject.toml` file or directory 38 | :param blender_manifest: optional path to the `blender_manifest.toml` file to be updated or created 39 | """ 40 | 41 | from .command.manifest import manifest_command 42 | 43 | manifest_command(pyproject, blender_manifest, validate) 44 | 45 | 46 | @app.command( 47 | help="Download wheels and update the Blender manifest.", 48 | ) 49 | def wheels( 50 | path: Annotated[ 51 | Path, 52 | Argument( 53 | help="Path to a file or directory containing uv.lock, pylock.*.toml, or pyproject.toml (defaults to current working directory).", 54 | ), 55 | ] = Path.cwd(), 56 | manifest: Annotated[ 57 | Path, 58 | Argument( 59 | help="Path to a file or directory containing blender_manifest.toml (defaults to current working directory)." 60 | ), 61 | ] = Path.cwd(), 62 | wheels_dir: Annotated[ 63 | Path | None, 64 | Argument( 65 | show_default=False, 66 | help="Directory where wheels will be downloaded (defaults to a sibling directory of the given manifest).", 67 | ), 68 | ] = None, 69 | exclude_package: Annotated[ 70 | Optional[List[str]], 71 | Option( 72 | help="Exclude package from being downloaded. Can be used multiple time. Does not remove wheels already downloaded.", 73 | show_default=False, 74 | ), 75 | ] = None, 76 | exclude_dependency: Annotated[ 77 | Optional[List[str]], 78 | Option( 79 | help="Exclude dependency from dependencies resolution. Need a `pyproject.toml` file and uv (https://astral.sh/blog/uv) installed. Does not remove wheels already downloaded.", 80 | show_default=False, 81 | ), 82 | ] = None, 83 | exclude_dependency_groups: Annotated[ 84 | Optional[List[str]], 85 | Option( 86 | help="Exclude dependency group from dependencies resolution. Need a `pyproject.toml` file and uv (https://astral.sh/blog/uv) installed. Does not remove wheels already downloaded.", 87 | show_default=False, 88 | ), 89 | ] = None, 90 | ) -> None: 91 | """Download wheels and write their paths to the Blender manifest.""" 92 | 93 | from .command.wheels import wheels_command 94 | 95 | wheels_command( 96 | path, 97 | manifest, 98 | wheels_dir, 99 | exclude_package, 100 | exclude_dependency, 101 | exclude_dependency_groups, 102 | ) 103 | 104 | 105 | if __name__ == "__main__": 106 | app() 107 | -------------------------------------------------------------------------------- /tests/peeler/pyproject/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Dict, List 3 | 4 | import tomlkit 5 | from pytest import FixtureRequest, fixture 6 | from tomlkit import TOMLDocument 7 | from tomlkit.items import Table 8 | from tomlkit.toml_file import TOMLFile 9 | 10 | from peeler.pyproject.manifest_adapter import ManifestAdapter 11 | from peeler.pyproject.parser import DependencyGroups, PyprojectParser 12 | from peeler.pyproject.validator import PyprojectValidator 13 | 14 | TEST_DATA_DIR = Path(__file__).parent / "data" 15 | PYPROJECT_NO_PEELER_TABLE = TEST_DATA_DIR / "pyproject_no_peeler_table.toml" 16 | PYPROJECT_MINIMAL = TEST_DATA_DIR / "pyproject_minimal.toml" 17 | 18 | 19 | @fixture 20 | def pyproject_dependencies(request: FixtureRequest) -> PyprojectParser: 21 | key = "dependencies" 22 | 23 | pyproject = PyprojectParser(TOMLFile(PYPROJECT_NO_PEELER_TABLE).read()) 24 | 25 | dependencies: List[str] | None = request.param 26 | 27 | if dependencies is not None: 28 | pyproject.project_table.update({key: list(request.param)}) 29 | elif key in pyproject.project_table: 30 | del pyproject.project_table[key] 31 | 32 | return pyproject 33 | 34 | 35 | @fixture 36 | def pyproject_requires_python(request: FixtureRequest) -> PyprojectParser: 37 | key = "requires-python" 38 | 39 | pyproject = PyprojectParser(TOMLFile(PYPROJECT_NO_PEELER_TABLE).read()) 40 | 41 | requires_python: str | None = request.param 42 | 43 | if requires_python is not None: 44 | pyproject.project_table.update({key: str(request.param)}) 45 | elif key in pyproject.project_table: 46 | del pyproject.project_table[key] 47 | 48 | return pyproject 49 | 50 | 51 | @fixture 52 | def pyproject_dependency_groups(request: FixtureRequest) -> PyprojectParser: 53 | pyproject = PyprojectParser(TOMLFile(PYPROJECT_NO_PEELER_TABLE).read()) 54 | 55 | dependency_groups: DependencyGroups | None = request.param 56 | 57 | pyproject.dependency_groups = dependency_groups 58 | 59 | return pyproject 60 | 61 | 62 | @fixture 63 | def pyproject_platforms(request: FixtureRequest) -> PyprojectParser: 64 | key = "platforms" 65 | 66 | pyproject = PyprojectParser(TOMLFile(PYPROJECT_MINIMAL).read()) 67 | 68 | platforms: List[str] | None = request.param 69 | 70 | if platforms is not None: 71 | pyproject.manifest_table.update({key: request.param}) 72 | elif key in pyproject.manifest_table: 73 | del pyproject.manifest_table[key] 74 | 75 | return pyproject 76 | 77 | 78 | @fixture 79 | def pyproject(request: FixtureRequest) -> TOMLDocument: 80 | path: Path = TEST_DATA_DIR / Path(request.param) 81 | 82 | with path.open() as file: 83 | return tomlkit.load(file) 84 | 85 | 86 | @fixture 87 | def validator(request: FixtureRequest) -> PyprojectValidator: 88 | path: Path = TEST_DATA_DIR / request.param 89 | 90 | with path.open() as file: 91 | return PyprojectValidator(tomlkit.load(file), path) 92 | 93 | 94 | @fixture(scope="function") 95 | def manifest_adapter( 96 | request: FixtureRequest, 97 | blender_manifest_schema: Dict[str, Any], 98 | peeler_manifest_schema: Dict[str, Any], 99 | ) -> ManifestAdapter: 100 | path: Path = TEST_DATA_DIR / request.param 101 | 102 | with path.open() as file: 103 | return ManifestAdapter( 104 | tomlkit.load(file), blender_manifest_schema, peeler_manifest_schema 105 | ) 106 | 107 | 108 | @fixture(scope="function") 109 | def validator_requires_python(request: FixtureRequest) -> PyprojectValidator: 110 | path: Path = TEST_DATA_DIR / "pyproject_minimal.toml" 111 | 112 | document = TOMLFile(path).read() 113 | 114 | project_table: Table = document.get("project") 115 | 116 | project_table.update({"requires-python": request.param}) 117 | 118 | return PyprojectValidator(document, path) 119 | -------------------------------------------------------------------------------- /peeler/uv_utils.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import re 6 | import shutil 7 | from importlib.util import find_spec 8 | from os import PathLike, fspath 9 | from pathlib import Path 10 | from subprocess import run 11 | from typing import Literal, overload 12 | 13 | from click import ClickException 14 | from packaging.version import Version 15 | 16 | from peeler import UV_VERSION_RANGE 17 | 18 | version_regex = r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?" # NOSONAR(S5843) # from docs: https://semver.org/ 19 | 20 | 21 | def get_uv_bin_version(uv_bin: PathLike) -> Version | None: 22 | """Return the uv version. 23 | 24 | :param uv_bin: path to a uv bin 25 | :return: the version of the provided binary 26 | """ 27 | 28 | uv_bin = fspath(uv_bin) 29 | 30 | result = run( 31 | [uv_bin, "self", "version"], capture_output=True, text=True, check=True 32 | ) 33 | output = result.stdout.strip() 34 | match = re.search(version_regex, output) 35 | 36 | if not match: 37 | return None 38 | 39 | return Version(match.group(0)) 40 | 41 | 42 | @overload 43 | def find_uv_bin() -> str: ... 44 | 45 | 46 | @overload 47 | def find_uv_bin(raises: Literal[True]) -> str: ... 48 | 49 | 50 | @overload 51 | def find_uv_bin(raises: Literal[False]) -> str | None: ... 52 | 53 | 54 | def find_uv_bin(raises: bool = True) -> str | None: 55 | """Return the path to the uv bin. 56 | 57 | :raises ClickException: if the bin cannot be found. 58 | """ 59 | 60 | spec = find_spec("uv") 61 | 62 | if spec: 63 | from uv import _find_uv 64 | 65 | uv_bin: str | None = _find_uv.find_uv_bin() 66 | else: 67 | uv_bin = shutil.which("uv") 68 | 69 | if raises and uv_bin is None: 70 | raise ClickException( 71 | """Cannot find uv bin 72 | Install uv `https://astral.sh/blog/uv` or 73 | Install peeler with uv (eg: pip install peeler[uv]) 74 | """ 75 | ) 76 | 77 | return uv_bin 78 | 79 | 80 | def has_uv() -> bool: 81 | """Return whether uv is present on the system. 82 | 83 | :return: True if uv is found, False otherwise. 84 | """ 85 | 86 | return find_uv_bin(raises=False) is not None 87 | 88 | 89 | def get_uv_version() -> Version | None: 90 | """Return uv version.""" 91 | 92 | return get_uv_bin_version(Path(find_uv_bin())) 93 | 94 | 95 | def check_uv_version() -> None: 96 | """Check the current uv version is between 0.7.0 and current supported max uv version. 97 | 98 | See pyproject.toml file. 99 | 100 | :raises ClickException: if uv version cannot be determined or is lower than the minimum version. 101 | """ 102 | 103 | uv_version = get_uv_bin_version(Path(find_uv_bin())) 104 | 105 | from_pip = find_spec("uv") is not None 106 | 107 | from peeler import __name__ 108 | 109 | body = f"To use {__name__} wheels feature with a pyproject.toml uv version must be between {UV_VERSION_RANGE.min} (inclusive) and {UV_VERSION_RANGE.max} (exclusive)" 110 | 111 | if from_pip: 112 | update_uv = """Install peeler with a supported uv version: 113 | 114 | pip install peeler[uv]""" 115 | else: 116 | update_uv = """Use peeler with a supported uv version without changing your current uv installation: 117 | 118 | uvx peeler[uv] [OPTIONS] COMMAND [ARGS]""" 119 | 120 | if not uv_version: 121 | header = "Error when checking uv version. Make sur to have installed, visit: https://docs.astral.sh/uv/getting-started/installation/" 122 | raise ClickException(f"""{header} 123 | 124 | {body} 125 | 126 | {update_uv}""") 127 | 128 | if uv_version not in UV_VERSION_RANGE: 129 | header = f"uv version is {uv_version}" 130 | 131 | raise ClickException(f"""{header} 132 | {body} 133 | {update_uv}""") 134 | -------------------------------------------------------------------------------- /peeler/pyproject/validator.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from pathlib import Path 6 | from typing import Any, Dict 7 | 8 | from click import format_filename 9 | from dep_logic.specifiers import RangeSpecifier, parse_version_specifier 10 | from fastjsonschema import JsonSchemaValueException 11 | from packaging.version import Version 12 | from tomlkit import TOMLDocument 13 | from validate_pyproject.api import Validator as _Validator 14 | from validate_pyproject.plugins import PluginWrapper 15 | 16 | from peeler.pyproject import _BLENDER_SUPPORTED_PYTHON_VERSION 17 | 18 | from ..schema import peeler_json_schema 19 | from .parser import PyprojectParser 20 | 21 | 22 | def _peeler_plugin(_: str) -> Dict[str, Any]: 23 | json_schema = peeler_json_schema() 24 | return {"$id": json_schema["$schema"][:-1], **json_schema} 25 | 26 | 27 | class PyprojectValidator: 28 | """A tool to validate a pyproject. 29 | 30 | Validate a pyproject file against standard and peeler own fields. 31 | 32 | :param pyproject: the pyproject as a `TOMLDocument` 33 | :param pyproject_path: the pyproject path (for error reporting) 34 | """ 35 | 36 | def __init__(self, pyproject: TOMLDocument, pyproject_path: Path) -> None: 37 | self.pyproject = pyproject 38 | self.pyproject_path = pyproject_path 39 | 40 | def _validate_has_peeler_table(self, pyproject: TOMLDocument) -> TOMLDocument: 41 | """Raise an error if the peeler table is missing or empty. 42 | 43 | :param pyproject: the pyproject document 44 | :raises JsonSchemaValueException: on missing or empty [tool.peeler] table. 45 | :return: the pyproject document 46 | """ 47 | 48 | table = PyprojectParser(pyproject).peeler_table 49 | 50 | if table: 51 | return pyproject 52 | 53 | path = self.pyproject_path.resolve() 54 | 55 | if table is None: 56 | msg = "The pyproject must contain a [tool.peeler] table." 57 | else: 58 | msg = "The pyproject [tool.peeler] table must not be empty." 59 | 60 | raise JsonSchemaValueException(message=f"{msg} (at {path})", name="tool.peeler") 61 | 62 | def _validate_python_version(self, pyproject: TOMLDocument) -> TOMLDocument: 63 | """Raise an error the python versions in project requires-python don't contains a supported python version by Blender. 64 | 65 | If requires-python is not specified, do nothing. 66 | 67 | :param pyproject: the pyproject document 68 | :raises JsonSchemaValueException: on invalid python versions. 69 | :return: the pyproject document 70 | """ 71 | table = PyprojectParser(pyproject).project_table 72 | 73 | if (python_versions := table.get("requires-python")) is None: 74 | return pyproject 75 | 76 | version_specifier = parse_version_specifier(python_versions) 77 | 78 | if (version_specifier & _BLENDER_SUPPORTED_PYTHON_VERSION).is_empty(): 79 | msg = f"""Invalid Python version range specified in your pyproject: 80 | 81 | [project] 82 | ... 83 | requires-python = "{python_versions}" 84 | 85 | None of the specified Python versions are supported by Blender. 86 | 87 | Python version range required by Blender: {_BLENDER_SUPPORTED_PYTHON_VERSION} 88 | 89 | Consider deleting or updating the requires-python field: 90 | 91 | [project] 92 | ... 93 | requires-python = "{_BLENDER_SUPPORTED_PYTHON_VERSION}" 94 | 95 | (at: {format_filename(self.pyproject_path.resolve())})""" 96 | 97 | raise JsonSchemaValueException(message=msg, name="project.requires-python") 98 | 99 | return pyproject 100 | 101 | def __call__(self) -> None: 102 | """Validate the file as generic pyproject file, and for peeler purposes. 103 | 104 | :raises ValidationError: on invalid pyproject file. 105 | """ 106 | 107 | validator = _Validator( 108 | extra_plugins=[PluginWrapper("peeler", _peeler_plugin)], 109 | extra_validations=[ 110 | self._validate_has_peeler_table, 111 | self._validate_python_version, 112 | ], 113 | ) 114 | 115 | validator(self.pyproject) 116 | -------------------------------------------------------------------------------- /peeler/manifest/write.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from typing import Optional 6 | from pathlib import Path 7 | from logging import getLogger 8 | 9 | from tomlkit import TOMLDocument, comment, nl 10 | from tomlkit.toml_file import TOMLFile 11 | 12 | from ..toml_utils import get_comments 13 | 14 | logger = getLogger(__file__) 15 | 16 | BLENDER_MANIFEST_FILENAME = "blender_manifest.toml" 17 | 18 | 19 | _HEADLINE_CREATED = "This file was autogenerated by peeler" 20 | _HEADLINE_UPDATED = "This file was updated by peeler" 21 | 22 | 23 | def _add_headlines(document: TOMLDocument, is_update: bool) -> None: 24 | comments = get_comments(document) 25 | 26 | if any(_HEADLINE_CREATED in comment_ for comment_ in comments) or any( 27 | _HEADLINE_UPDATED in comment_ for comment_ in comments 28 | ): 29 | return 30 | if is_update: 31 | comment_ = _HEADLINE_UPDATED 32 | else: 33 | comment_ = _HEADLINE_CREATED 34 | 35 | document.body.insert(0, (None, comment(comment_))) 36 | document.body.insert(1, (None, nl())) 37 | 38 | 39 | def _write(document: TOMLDocument, path: Path, /, overwrite: bool = True) -> None: 40 | if not path.exists(): 41 | _add_headlines(document, is_update=False) 42 | TOMLFile(path).write(document) 43 | return 44 | 45 | existing_file = TOMLFile(path) 46 | 47 | if overwrite: 48 | existing_document = existing_file.read() 49 | existing_document.update(document) 50 | document = existing_document 51 | else: 52 | document.update(existing_file.read()) 53 | 54 | _add_headlines(document, is_update=True) 55 | 56 | existing_file.write(document) 57 | 58 | 59 | def _retrieve_valid_path(path: Path | None, /, allow_non_default_name: bool) -> Path: 60 | """Return a complete path of the blender_manifest.toml to be created. 61 | 62 | :param path: the original path given by the user 63 | :param allow_non_default_name: whether to allow to export to a file named other than `blender_manifest.toml`, defaults to False 64 | :raises ValueError: if allow_non_default_name is False and the given path is not named `blender_manifest.toml` 65 | :return: The valid path 66 | 67 | >>> _retrieve_valid_path(None, allow_non_default_name=False) 68 | Path("/path/to/cwd/blender_manifest.toml") 69 | >>> _retrieve_valid_path(Path("/path/to/dir/"), allow_non_default_name=False) 70 | Path("/path/to/dir/blender_manifest.toml") 71 | >>> _retrieve_valid_path( 72 | ... Path("/path/to/manifest/blender_manifest.toml"), 73 | ... allow_non_default_name=False, 74 | ... ) 75 | Path("/path/to/manifest/blender_manifest.toml") 76 | >>> _retrieve_valid_path( 77 | ... Path("/path/to/manifest/maya_manifest.toml"), allow_non_default_name=True 78 | ... ) 79 | Path("/path/to/manifest/maya_manifest.toml") 80 | >>> _retrieve_valid_path( 81 | ... Path("/path/to/manifest/maya_manifest.toml"), allow_non_default_name=False 82 | ... ) 83 | ValueError: the blender manifest should be named : `blender_manifest.toml` not `maya_manifest.toml` 84 | """ 85 | 86 | if path is None: 87 | path = Path.cwd() 88 | 89 | if not path.exists(): 90 | if path.suffix: 91 | path.parent.mkdir(parents=True, exist_ok=True) 92 | else: 93 | path.mkdir(exist_ok=True, parents=True) 94 | 95 | if path.is_dir(): 96 | path = path / BLENDER_MANIFEST_FILENAME 97 | elif not path.name == BLENDER_MANIFEST_FILENAME: 98 | msg = f"the blender manifest should be named : `{BLENDER_MANIFEST_FILENAME}` not `{path.name}`" 99 | if allow_non_default_name: 100 | logger.warning(msg) 101 | else: 102 | raise ValueError(msg) 103 | 104 | return path 105 | 106 | 107 | def export_to_blender_manifest( 108 | document: TOMLDocument, 109 | path: Optional[Path] = None, 110 | /, 111 | overwrite: bool = True, 112 | allow_non_default_name: bool = False, 113 | ) -> Path: 114 | """Export the given document to the given path. 115 | 116 | :param document: the toml document representing a blender_manifest.toml 117 | :param path: the path, file or directory, defaults to current working directory 118 | :param overwrite: whether to overwrite values already existing in a file at the given path, defaults to True 119 | :param allow_non_default_name: whether to allow to export to a file named other than `blender_manifest.toml`, defaults to False 120 | :return: The path of file updated or created 121 | """ 122 | 123 | path = _retrieve_valid_path(path, allow_non_default_name=allow_non_default_name) 124 | 125 | _write(document, path, overwrite=overwrite) 126 | 127 | return path 128 | -------------------------------------------------------------------------------- /tests/peeler/utils/test_normalize_platform.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | from click import ClickException 5 | from pytest import mark 6 | 7 | from peeler.schema import blender_manifest_json_schema 8 | from peeler.utils import ( 9 | _BLENDER_TO_WHEEL_PLATFORM_TAGS, 10 | parse_blender_supported_platform, 11 | parse_package_platform_tag, 12 | ) 13 | 14 | 15 | def test_schema_supported_platfrom() -> None: 16 | """Assert that the comparison table match dthe json schema specifications.""" 17 | blender_manifest_schema = blender_manifest_json_schema() 18 | supported_platforms = blender_manifest_schema["properties"]["platforms"]["items"][ 19 | "enum" 20 | ] 21 | 22 | assert set(_BLENDER_TO_WHEEL_PLATFORM_TAGS.keys()) == set(supported_platforms) 23 | 24 | 25 | @mark.parametrize( 26 | ("platform", "expected_result"), 27 | ( 28 | [ 29 | ("windows-x64", ("win", "amd64")), 30 | ("windows-arm64", ("win", "32")), 31 | ("linux-x64", ("manylinux", "x86_64")), 32 | ("macos-arm64", ("macosx", "arm64")), 33 | ("macos-x64", ("macosx", "x86_64")), 34 | ] 35 | ), 36 | ) 37 | def test_normalize_blender_supported_platform( 38 | platform: str, expected_result: Tuple[str, str] 39 | ) -> None: 40 | assert parse_blender_supported_platform(platform) == expected_result 41 | 42 | 43 | @mark.parametrize( 44 | ("platform"), 45 | ( 46 | [ 47 | "windows", 48 | "any", 49 | "", 50 | ] 51 | ), 52 | ) 53 | def test_normalize_blender_supported_platform_raises(platform: str) -> None: 54 | with pytest.raises(ClickException): 55 | parse_blender_supported_platform(platform) 56 | 57 | 58 | @pytest.mark.parametrize( 59 | ("platform_tag", "expected_result"), 60 | ( 61 | [ 62 | # --- macOS variants --- 63 | ("macosx_11_0_arm64", ("macosx", "11_0", "arm64")), 64 | ("macosx_10_9_x86_64", ("macosx", "10_9", "x86_64")), 65 | ("macosx_10_10_universal2", ("macosx", "10_10", "universal2")), 66 | ("macosx_14_2_arm64", ("macosx", "14_2", "arm64")), 67 | ("macosx_10_9_intel", ("macosx", "10_9", "intel")), 68 | ("macosx_11_0_universal2", ("macosx", "11_0", "universal2")), 69 | # --- manylinux variants --- 70 | ("manylinux1_x86_64", ("manylinux", "1", "x86_64")), 71 | ("manylinux2010_x86_64", ("manylinux", "2010", "x86_64")), 72 | ("manylinux2014_aarch64", ("manylinux", "2014", "aarch64")), 73 | ("manylinux_2_17_x86_64", ("manylinux", "2_17", "x86_64")), 74 | ("manylinux_2_5_aarch64", ("manylinux", "2_5", "aarch64")), 75 | # --- musllinux variants --- 76 | ("musllinux_1_1_aarch64", ("musllinux", "1_1", "aarch64")), 77 | ("musllinux_1_2_x86_64", ("musllinux", "1_2", "x86_64")), 78 | # --- plain linux (non-many/musl) --- 79 | ("linux_x86_64", ("linux", None, "x86_64")), 80 | ("linux_aarch64", ("linux", None, "aarch64")), 81 | # --- Windows variants --- 82 | ("win_amd64", ("win", None, "amd64")), 83 | ("win32", ("win", None, "32")), 84 | ("win_arm64", ("win", None, "arm64")), 85 | ("win_arm32", ("win", None, "arm32")), 86 | # --- universal wheels --- 87 | ("any", ("any", None, None)), 88 | # --- edge / uncommon formats --- 89 | ("manylinux_2_17-x86_64", ("manylinux", "2_17", "x86_64")), 90 | ("macosx-10_9-x86_64", ("macosx", "10_9", "x86_64")), 91 | ] 92 | ), 93 | ) 94 | def test_normalize_package_platform_tag( 95 | platform_tag: str, expected_result: Tuple[str, str | None, str | None] 96 | ) -> None: 97 | assert parse_package_platform_tag(platform_tag) == expected_result 98 | 99 | 100 | @mark.parametrize( 101 | ("platform_tag"), 102 | ( 103 | [ 104 | # too vague / missing arch 105 | "win", 106 | "macosx", 107 | "manylinux", 108 | "linux", 109 | # separators without content 110 | "win-", 111 | "win_", 112 | # unexpected suffix 113 | "any_extra", 114 | # uppercase 115 | "MACOSX_11_0_ARM64", 116 | "Win_AMD64", 117 | # .whl suffix should not be tolerated 118 | "manylinux1_x86_64.whl", 119 | "win_amd64.whl", 120 | # punctuation / wrong separators 121 | "win_amd64.1", 122 | "musllinux_1.1_aarch64", # dot in version 123 | # missing platform but has arch 124 | "_x86_64", 125 | "-x86_64", 126 | # duplicate archs or malformed tail 127 | "manylinux_2_17_x86_64-x86_64", 128 | ] 129 | ), 130 | ) 131 | def test_normalize_package_platform_tag_raises(platform_tag: str) -> None: 132 | with pytest.raises(ClickException): 133 | parse_package_platform_tag(platform_tag) 134 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" 8 | - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" 9 | - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" 10 | 11 | env: 12 | PACKAGE_NAME: "peeler" 13 | OWNER: ${{github.repository_owner}} 14 | 15 | jobs: 16 | details: 17 | runs-on: ubuntu-latest 18 | outputs: 19 | new_version: ${{ steps.release.outputs.new_version }} 20 | suffix: ${{ steps.release.outputs.suffix }} 21 | tag_name: ${{ steps.release.outputs.tag_name }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Extract tag and Details 26 | id: release 27 | run: | 28 | if [ "${{ github.ref_type }}" = "tag" ]; then 29 | TAG_NAME=${GITHUB_REF#refs/tags/} 30 | NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') 31 | SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "") 32 | echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" 33 | echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" 34 | echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" 35 | echo "Version is $NEW_VERSION" 36 | echo "Suffix is $SUFFIX" 37 | echo "Tag name is $TAG_NAME" 38 | else 39 | echo "No tag found" 40 | exit 1 41 | fi 42 | 43 | check_pypi: 44 | needs: details 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Fetch information from PyPI 48 | run: | 49 | response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") 50 | latest_previous_version=$(echo $response | jq --raw-output 'select(.releases != null) | .releases | keys 51 | | map(split(".") | map(capture("^(?[0-9]+)") | .num | tonumber)) | max_by(.) | join(".")') 52 | if [ -z "$latest_previous_version" ]; then 53 | echo "Package not found on PyPI." 54 | latest_previous_version="0.0.0" 55 | fi 56 | echo "Latest version on PyPI: $latest_previous_version" 57 | echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV 58 | 59 | - name: Compare versions and exit if not newer 60 | run: | 61 | NEW_VERSION=${{ needs.details.outputs.new_version }} 62 | LATEST_VERSION=$latest_previous_version 63 | LATEST_VERSION_CLEAN=$(echo "$LATEST_VERSION" | sed 's/-.*//') 64 | NEW_VERSION_CLEAN=$(echo "$NEW_VERSION" | sed 's/-.*//') 65 | 66 | if [ "$(printf '%s\n' "$LATEST_VERSION_CLEAN" "$NEW_VERSION_CLEAN" | sort -rV | head -n 1)" != "$NEW_VERSION_CLEAN" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then 67 | echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI." 68 | exit 1 69 | else 70 | echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI." 71 | fi 72 | 73 | setup_and_build: 74 | needs: [details, check_pypi] 75 | runs-on: ubuntu-latest 76 | steps: 77 | - uses: actions/checkout@v4 78 | 79 | - name: Setup UV 80 | uses: astral-sh/setup-uv@v4 81 | with: 82 | enable-cache: true 83 | 84 | - name: Setup Python 85 | run: uv python install 86 | 87 | - name: Set project version from tag 88 | run: | 89 | uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version ${{ needs.details.outputs.new_version }} 90 | 91 | - name: Install the project 92 | run: uv sync --all-extras --dev 93 | 94 | - name: Build the Package 95 | run: uv build 96 | 97 | - uses: actions/upload-artifact@v4 98 | with: 99 | name: dist 100 | path: dist/ 101 | 102 | pypi_publish: 103 | name: Upload release to PyPI 104 | needs: [setup_and_build, details] 105 | runs-on: ubuntu-latest 106 | environment: 107 | name: release 108 | permissions: 109 | id-token: write 110 | steps: 111 | - name: Download artifacts 112 | uses: actions/download-artifact@v4 113 | with: 114 | name: dist 115 | path: dist/ 116 | 117 | - name: Publish distribution to PyPI 118 | uses: pypa/gh-action-pypi-publish@release/v1 119 | 120 | github_release: 121 | name: Create GitHub Release 122 | needs: [setup_and_build, details] 123 | runs-on: ubuntu-latest 124 | permissions: 125 | contents: write 126 | steps: 127 | - name: Checkout Code 128 | uses: actions/checkout@v4 129 | with: 130 | fetch-depth: 0 131 | 132 | - name: Download artifacts 133 | uses: actions/download-artifact@v4 134 | with: 135 | name: dist 136 | path: dist/ 137 | 138 | - name: Create GitHub Release 139 | id: create_release 140 | env: 141 | GH_TOKEN: ${{ github.token }} 142 | run: | 143 | gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes 144 | -------------------------------------------------------------------------------- /peeler/pyproject/manifest_adapter.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from __future__ import annotations 6 | 7 | from collections.abc import Callable 8 | from functools import partial 9 | from typing import Any, Dict, Set 10 | 11 | from tomlkit import TOMLDocument 12 | 13 | from .parser import PyprojectParser 14 | 15 | 16 | class ManifestAdapter: 17 | """A tool to adapt a TOML Document representing a pyproject into a TOML Document representing a blender manifest. 18 | 19 | :param pyproject: the pyproject to extract values from. 20 | :param blender_manifest_jsonschema: the blender manifest json schema, specifying required fields etc. 21 | :param peeler_jsonschema: the peeler json schema, specifying required fields in the `[tool.peeler.manifest]` pyproject table. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | pyproject: TOMLDocument, 27 | blender_manifest_jsonschema: Dict[str, Any], 28 | peeler_jsonschema: Dict[str, Any], 29 | ) -> None: 30 | self.pyproject = PyprojectParser(pyproject) 31 | self.blender_manifest_jsonschema = blender_manifest_jsonschema 32 | self.peeler_jsonschema = peeler_jsonschema 33 | 34 | @property 35 | def _blender_manifest_required_fields(self) -> Dict[str, Any]: 36 | """Fields required by the blender manifest specifications. 37 | 38 | :return: A dictionary mapping properties names to their json schema 39 | """ 40 | blender_manifest_required_fields_names = self.blender_manifest_jsonschema[ 41 | "required" 42 | ] 43 | return { 44 | field: self.blender_manifest_jsonschema["properties"][field] 45 | for field in blender_manifest_required_fields_names 46 | } 47 | 48 | @property 49 | def _fields_to_fill(self) -> Set[str]: 50 | """Required fields in the blender manifest, and not in the peeler pyproject table. 51 | 52 | These fields will be inferred by peeler if possible. 53 | (For 1.0.0) example: "name", "schema_version" etc 54 | :return: The field names 55 | """ 56 | return set(self._blender_manifest_required_fields.keys()) - set( 57 | self.pyproject.manifest_table.keys() 58 | ) 59 | 60 | @property 61 | def _strictly_required_fields(self) -> Set[str]: 62 | """Required fields in the blender manifest, that cannot be inferred by peeler. 63 | 64 | These fields will be inferred by peeler if possible. 65 | (For 1.0.0) example: "id", "maintainer" 66 | :return: The field names 67 | """ 68 | 69 | peeler_manifest_required_field: Set[str] = set( 70 | self.peeler_jsonschema["properties"]["manifest"]["required"] 71 | ) 72 | 73 | return peeler_manifest_required_field & self._fields_to_fill 74 | 75 | def _get_default_value(self, property_name: str) -> Any | None: 76 | """Get the default value of the property as specified in the blender manifest jsonschema. 77 | 78 | :param property_name: the name of the properties as specified 79 | :raises RuntimeError: if no default value 80 | :return: the default value 81 | """ 82 | properties: Dict[str, Dict[str, Any]] = self.blender_manifest_jsonschema[ 83 | "properties" 84 | ] 85 | 86 | property_: Dict[str, Any] = properties[property_name] 87 | default_value = property_.get("default") 88 | 89 | if default_value is None: 90 | raise RuntimeError( 91 | f"The property: {property_name} has no default value provided in the json_schema" 92 | ) 93 | 94 | return default_value 95 | 96 | @property 97 | def _infer_callables(self) -> Dict[str, Callable[[], Any]]: 98 | return { 99 | "name": lambda: self.pyproject.project_table.get("name"), 100 | "version": lambda: self.pyproject.project_table.get("version"), 101 | } 102 | 103 | def _infer_fields(self) -> Dict[str, Any]: 104 | return { 105 | field: self._infer_callables.get( 106 | field, partial(self._get_default_value, field) 107 | )() 108 | for field in self._fields_to_fill 109 | } 110 | 111 | def to_blender_manifest(self) -> TOMLDocument: 112 | """Generate a blender manifest TOML document. 113 | 114 | :raises ValueError: if required values cannot be inferred from the pyproject. 115 | :return: A blender manifest TOML document. 116 | """ 117 | 118 | # raise an error if some fields are missing 119 | # and cannot be filled automatically 120 | if self._strictly_required_fields: 121 | header = "Missing field in [peeler.manifest] table:" 122 | missing_properties = { 123 | f"{field}:\n\t{self.blender_manifest_jsonschema['properties']['description']}" 124 | for field in self._strictly_required_fields 125 | } 126 | msg = header + r"\n".join(missing_properties) 127 | raise ValueError(msg) 128 | 129 | document = TOMLDocument() 130 | 131 | # update the doc with fields inferred by peeler 132 | document.update(self._infer_fields()) 133 | 134 | # update the doc with fields given in the pyproject manifest peeler table 135 | document.update(self.pyproject.manifest_table) 136 | 137 | return document 138 | -------------------------------------------------------------------------------- /peeler/utils.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import atexit 6 | from collections.abc import Generator 7 | from contextlib import contextmanager 8 | from pathlib import Path 9 | from re import sub 10 | from shutil import copy2 11 | from tempfile import TemporaryDirectory 12 | from typing import Dict, NoReturn, Tuple 13 | 14 | import typer 15 | from click import ClickException 16 | from typer import format_filename 17 | 18 | PYPROJECT_FILENAME = "pyproject.toml" 19 | 20 | 21 | def find_pyproject_file( 22 | pyproject_path: Path, *, allow_non_default_name: bool = False 23 | ) -> Path: 24 | """Ensure that the file exists at the given path. 25 | 26 | :param pyproject_path: file or directory path 27 | :param allow_non_default_name: whether to allow a file to be named other than `pyproject.toml` 28 | :raises ClickException: on missing file 29 | :raises ClickException: if allow_non_default_name is set to False, on file named other than `pyproject.toml` 30 | :return: the pyproject file path 31 | """ 32 | 33 | if pyproject_path.is_dir(): 34 | pyproject_path = pyproject_path / PYPROJECT_FILENAME 35 | 36 | if not pyproject_path.is_file(): 37 | raise ClickException( 38 | f"No {PYPROJECT_FILENAME} found at {format_filename(pyproject_path.parent.resolve())}" 39 | ) 40 | 41 | if not pyproject_path.name == PYPROJECT_FILENAME: 42 | msg = f"""The pyproject file at {format_filename(pyproject_path.parent)} 43 | Should be named : `{PYPROJECT_FILENAME}` not `{pyproject_path.name}` 44 | """ 45 | if allow_non_default_name: 46 | typer.echo(f"Warning: {msg}") 47 | else: 48 | raise ClickException(msg) 49 | 50 | return pyproject_path 51 | 52 | 53 | @contextmanager 54 | def restore_file( 55 | filepath: Path, *, missing_ok: bool = False 56 | ) -> Generator[None, None, None]: 57 | """Context Manager to ensure that a file contents and metadata are restored after use. 58 | 59 | The file must NOT be opened before calling `restore_file` 60 | 61 | :param filepath: The path of the file 62 | :param missing_ok: if set to True and the file does not exist, delete the file after use. 63 | :raises FileNotFoundError: if missing_ok is False and the file does not exist 64 | """ 65 | 66 | file_exist = filepath.exists() 67 | 68 | if not missing_ok and not file_exist: 69 | raise FileNotFoundError(f"File {format_filename(filepath)} not found.") 70 | 71 | with TemporaryDirectory(ignore_cleanup_errors=True) as tempdir: 72 | if file_exist: 73 | temp_path = Path(copy2(Path(filepath), tempdir)) 74 | 75 | def restore_file() -> None: 76 | filepath.unlink(missing_ok=True) 77 | if file_exist: 78 | copy2(temp_path, filepath) 79 | 80 | atexit.register(restore_file) 81 | 82 | try: 83 | yield 84 | finally: 85 | restore_file() 86 | atexit.unregister(restore_file) 87 | 88 | 89 | def normalize_package_name(name: str) -> str: 90 | """Normalize a package name for comparison. 91 | 92 | from: https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization 93 | 94 | :param name: the package name 95 | :return: the normalized package name 96 | """ 97 | return sub(r"[-_.]+", "-", name).lower() 98 | 99 | 100 | _BLENDER_TO_WHEEL_PLATFORM_TAGS: Dict[str, Tuple[str, str]] = { 101 | "windows-x64": ("win", "amd64"), 102 | "windows-arm64": ("win", "32"), 103 | "linux-x64": ("manylinux", "x86_64"), # muslinux not supported by blender, 104 | "macos-arm64": ("macosx", "arm64"), 105 | "macos-x64": ("macosx", "x86_64"), 106 | } 107 | 108 | 109 | def parse_blender_supported_platform(platform: str) -> Tuple[str, str]: 110 | """Normalize a platform from blender manifest supported platfrom. 111 | 112 | from: https://docs.blender.org/manual/en/latest/advanced/extensions/getting_started.html#manifest 113 | 114 | :param platform: the platform string 115 | :return: a tuple with platform and arch 116 | """ 117 | 118 | if (_platform := _BLENDER_TO_WHEEL_PLATFORM_TAGS.get(platform, None)) is None: 119 | raise ClickException( 120 | f"""Invalid platform: `{platform}` . 121 | Expected one the following platform: 122 | {" ".join([f"`{platform_}`" for platform_ in _BLENDER_TO_WHEEL_PLATFORM_TAGS.keys()])} 123 | see https://docs.blender.org/manual/en/latest/advanced/extensions/getting_started.html#manifest """ 124 | ) 125 | 126 | return _platform 127 | 128 | 129 | import re 130 | 131 | PLATFORM_REGEX = re.compile( 132 | r"^(?Pmacosx|manylinux|musllinux|win|linux|any)" 133 | r"(?:[_\-]?(?P\d+(?:[_\-]\d+)*))?" # version ex: 11_0, 2014, 2_17 134 | r"(?:[_\-](?P[A-Za-z0-9_]+))?$" # arch ex: x86_64, arm64, amd64 135 | ) 136 | 137 | 138 | def parse_package_platform_tag( 139 | platform_tag: str, 140 | ) -> Tuple[str, str | None, str | None]: 141 | """Normalize a platform tag from a wheel url. 142 | 143 | from: https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/#platform-tag 144 | 145 | :param platform: the platform tag 146 | :return: a tuple with platform optional version number and arch 147 | """ 148 | 149 | if platform_tag == "win32": # special case 150 | return ("win", None, "32") 151 | 152 | def _raise() -> NoReturn: 153 | raise ClickException(f"""Invalid platform tag: `{platform_tag}` .""") 154 | 155 | if (match_ := PLATFORM_REGEX.match(platform_tag)) is None: 156 | _raise() 157 | if len(groups_ := match_.groups(default=None)) != 3: 158 | _raise() 159 | 160 | platform, version, arch = groups_ 161 | 162 | if platform == "any": 163 | if version or arch: 164 | _raise() 165 | return platform, version, arch 166 | 167 | if not platform or not arch: 168 | _raise() 169 | 170 | return platform, version, arch 171 | -------------------------------------------------------------------------------- /tests/peeler/wheels/test_lock.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Set, Type 3 | from unittest import mock 4 | from unittest.mock import MagicMock, Mock 5 | 6 | import pytest 7 | from click import ClickException 8 | from pytest import fixture, mark 9 | from tomlkit import TOMLDocument 10 | 11 | from peeler.uv_utils import check_uv_version 12 | from peeler.wheels.lock import ( 13 | AbstractURLFetcherStrategy, 14 | PylockUrlFetcher, 15 | PyprojectUVLockFetcher, 16 | UrlFetcherCreator, 17 | UVLockUrlFetcher, 18 | _generate_uv_lock, 19 | _get_wheels_urls_from_pylock, 20 | _get_wheels_urls_from_uv_lock, 21 | ) 22 | 23 | 24 | def test__get_wheels_urls_from_uv_lock(uv_lock_file: TOMLDocument) -> None: 25 | assert _get_wheels_urls_from_uv_lock(uv_lock_file) == { 26 | "package1": ["wheels_url_package1"], 27 | "package2": ["wheels_url_package2_1", "wheels_url_package2_2"], 28 | } 29 | 30 | 31 | def test__get_wheels_urls_from_pylock(pylock_file: TOMLDocument) -> None: 32 | assert _get_wheels_urls_from_pylock(pylock_file) == { 33 | "package1": ["wheels_url_package1"], 34 | "package2": ["wheels_url_package2_1", "wheels_url_package2_2"], 35 | } 36 | 37 | 38 | def test__get_lock_file(pyproject_path_with_lock: Path) -> None: 39 | with _generate_uv_lock(pyproject_path_with_lock) as lock_file: 40 | assert lock_file.exists() 41 | 42 | assert lock_file.exists() 43 | 44 | 45 | def test__get_lock_file_no_lock_file(pyproject_path_without_lock: Path) -> None: 46 | with _generate_uv_lock(pyproject_path_without_lock) as lock_file: 47 | assert lock_file.exists() 48 | 49 | assert not lock_file.exists() 50 | 51 | 52 | _DATA = Path(__file__).parent / "data" 53 | 54 | 55 | @mark.parametrize( 56 | ("url_fetcher_creator", "expected_strategy"), 57 | [ 58 | (_DATA / "pyproject.toml", PyprojectUVLockFetcher), 59 | (_DATA / "uv.lock", UVLockUrlFetcher), 60 | (_DATA / "pylock.toml", PylockUrlFetcher), 61 | (_DATA / "pylock.test.toml", PylockUrlFetcher), 62 | (_DATA, PylockUrlFetcher), 63 | ], 64 | indirect=["url_fetcher_creator"], 65 | ) 66 | def test_url_fetcher_creator_get_fetch_url_strategy( 67 | url_fetcher_creator: UrlFetcherCreator, 68 | expected_strategy: Type[AbstractURLFetcherStrategy], 69 | ) -> None: 70 | assert isinstance(url_fetcher_creator.get_fetch_url_strategy(), expected_strategy) 71 | 72 | 73 | def test_url_fetcher_creator_get_fetch_url_strategy_raise_dir(tmp_path: Path) -> None: 74 | with pytest.raises(ClickException, match="No supported file found in"): 75 | UrlFetcherCreator(tmp_path).get_fetch_url_strategy() 76 | 77 | 78 | def test_url_fetcher_creator_get_fetch_url_strategy_raise_file(tmp_path: Path) -> None: 79 | with pytest.raises(ClickException, match=" is not a supported type."): 80 | UrlFetcherCreator(tmp_path / "non_existent.file").get_fetch_url_strategy() 81 | 82 | 83 | @mark.parametrize( 84 | ("url_fetcher_creator", "expected_strategy"), 85 | [ 86 | (_DATA / "pyproject.toml", PyprojectUVLockFetcher), 87 | ], 88 | indirect=["url_fetcher_creator"], 89 | ) 90 | def test_url_fetcher_creator_get_fetch_url_strategy_with_excluded_dependencies( 91 | url_fetcher_creator: UrlFetcherCreator, 92 | expected_strategy: Type[AbstractURLFetcherStrategy], 93 | ) -> None: 94 | assert isinstance( 95 | url_fetcher_creator.get_fetch_url_strategy(excluded_dependencies=["package1"]), 96 | expected_strategy, 97 | ) 98 | 99 | 100 | @mark.parametrize( 101 | ("url_fetcher_creator"), 102 | [ 103 | (_DATA / "uv.lock"), 104 | (_DATA / "pylock.toml"), 105 | (_DATA / "pylock.test.toml"), 106 | ], 107 | indirect=["url_fetcher_creator"], 108 | ) 109 | def test_url_fetcher_creator_get_fetch_url_strategy_with_excluded_dependencies_raise( 110 | url_fetcher_creator: UrlFetcherCreator, 111 | ) -> None: 112 | with pytest.raises(ClickException, match="Expected a `pyproject.toml` file"): 113 | url_fetcher_creator.get_fetch_url_strategy(excluded_dependencies=["package1"]) 114 | 115 | 116 | @fixture 117 | def urls() -> Set[str]: 118 | return { 119 | "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", 120 | "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", 121 | "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", 122 | "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", 123 | "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", 124 | "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", 125 | "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", 126 | "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", 127 | } 128 | 129 | 130 | @mark.parametrize( 131 | "url_fetcher", 132 | [ 133 | UVLockUrlFetcher(_DATA / "uv.lock"), 134 | PylockUrlFetcher(_DATA / "pylock.toml"), 135 | ], 136 | ids=["uv_lock", "pylock"], 137 | ) 138 | def test_url_fetcher(url_fetcher: AbstractURLFetcherStrategy, urls: Set[str]) -> None: 139 | assert set(url_fetcher.get_urls()["numpy"]) == urls 140 | 141 | 142 | @mock.patch("peeler.wheels.lock.check_uv_version", new=lambda: None) 143 | @mark.parametrize( 144 | "url_fetcher", 145 | [ 146 | PyprojectUVLockFetcher(_DATA / "pyproject.toml"), 147 | ], 148 | ids=["pyproject"], 149 | ) 150 | def test_url_fetcher_wo_uv_version_check( 151 | url_fetcher: AbstractURLFetcherStrategy, urls: Set[str] 152 | ) -> None: 153 | assert set(url_fetcher.get_urls()["numpy"]) == urls 154 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish 4 | to make via chat, email or other means with the owners of this 5 | repository before making a change. 6 | 7 | Please note we have a code of conduct, please follow it in all your 8 | interactions with the project. 9 | 10 | ## Pull request Process 11 | 12 | See [first-contribution](https://github.com/firstcontributions/first-contributions) 13 | 14 | - Fork this repository 15 | - Clone the repository see [Getting Started](#getting-started) 16 | - Make necessary changes and commit those changes. 17 | - If needed, increase the version numbers in pyproject.toml to the new version that this Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 18 | - Wait until the success of the CICD pipeline 19 | - Make sure your branch is rebased onto the destination branch. 20 | - You may request the reviewer to merge it for you. 21 | 22 | 23 | ## Getting Started 24 | 25 | These instructions will get you a copy of the project on your 26 | local machine for development and testing purposes. 27 | 28 | Clone this repository in an empty directory 29 | 30 | ```bash 31 | git clone https://github.com/Maxioum/peeler.git 32 | ``` 33 | 34 | ### Project Management 35 | 36 | This project is using [uv](https://docs.astral.sh/uv/) to manage dependencies 37 | and to build the package. 38 | 39 | See the [pyproject.toml](pyproject.toml) file for the list of dependencies. 40 | 41 | [How to install uv](https://docs.astral.sh/uv/getting-started/installation/) 42 | 43 | ### Virtual Environments 44 | 45 | You may create a virtual environnement managed by uv with 46 | 47 | ```bash 48 | uv venv 49 | ``` 50 | 51 | make sure to activate the environnement 52 | 53 | ### Testing 54 | 55 | ```bash 56 | uv run pytest . 57 | ``` 58 | 59 | ## Coding style enforcement 60 | 61 | ### Pre-commit 62 | 63 | [Pre-commit](https://pre-commit.com/) framework manages and maintains multi-language 64 | pre-commit hooks. 65 | 66 | To install hooks the first time, run 67 | 68 | ```bash 69 | uvx pre-commit install 70 | ``` 71 | 72 | After this step all your future commits will need to satisfy coding style rules. 73 | 74 | ### Ruff 75 | 76 | [Ruff](https://docs.astral.sh/ruff/) is a Python linter and code formatter. 77 | 78 | To manually run Ruff on your code 79 | 80 | ```bash 81 | uvx ruff check 82 | ``` 83 | 84 | ```bash 85 | uvx ruff format 86 | ``` 87 | 88 | ### Mypy 89 | 90 | [Mypy](https://mypy-lang.org/) is a static type checker for Python. 91 | 92 | These packages uses as much as possible python static typing feature and mypy helps 93 | us to check our typing inconsistencies. 94 | 95 | To manually run Mypy on your code 96 | 97 | ```bash 98 | uv run mypy . 99 | ``` 100 | 101 | ## Contributing 102 | 103 | Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our code of 104 | conduct, and the process for submitting Pull Requests. 105 | 106 | ## Versioning 107 | 108 | This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 109 | 110 | 111 | ## Code of Conduct 112 | 113 | ### Our Pledge 114 | 115 | In the interest of fostering an open and welcoming environment, we as 116 | contributors and maintainers pledge to making participation in our project and 117 | our community a harassment-free experience for everyone, regardless of age, body 118 | size, disability, ethnicity, gender identity and expression, level of 119 | experience, nationality, personal appearance, race, religion, or sexual identity 120 | and orientation. 121 | 122 | ### Our Standards 123 | 124 | Examples of behavior that contributes to creating a positive environment 125 | include: 126 | 127 | - Using welcoming and inclusive language 128 | - Being respectful of differing viewpoints and experiences 129 | - Gracefully accepting constructive criticism 130 | - Focusing on what is best for the community 131 | - Showing empathy towards other community members 132 | 133 | Examples of unacceptable behavior by participants include: 134 | 135 | - The use of sexualized language or imagery and unwelcome sexual attention or 136 | advances 137 | - Trolling, insulting/derogatory comments, and personal or political attacks 138 | - Public or private harassment 139 | - Publishing others' private information, such as a physical or electronic 140 | address, without explicit permission 141 | - Other conduct which could reasonably be considered inappropriate in a 142 | professional setting 143 | 144 | ### Our Responsibilities 145 | 146 | Project maintainers are responsible for clarifying the standards of acceptable 147 | behavior and are expected to take appropriate and fair corrective action in 148 | response to any instances of unacceptable behavior. 149 | 150 | Project maintainers have the right and responsibility to remove, edit, or 151 | reject comments, commits, code, wiki edits, issues, and other contributions 152 | that are not aligned to this Code of Conduct, or to ban temporarily or 153 | permanently any contributor for other behaviors that they deem inappropriate, 154 | threatening, offensive, or harmful. 155 | 156 | ### Scope 157 | 158 | This Code of Conduct applies both within project spaces and in public spaces 159 | when an individual is representing the project or its community. Examples of 160 | representing a project or community include using an official project e-mail 161 | address, posting via an official social media account, or acting as an appointed 162 | representative at an online or offline event. Representation of a project may be 163 | further defined and clarified by project maintainers. 164 | 165 | ### Enforcement 166 | 167 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 168 | reported by contacting the project team. All complaints will be reviewed and 169 | investigated and will result in a response that is deemed necessary and 170 | appropriate to the circumstances. The project team is obligated to maintain 171 | confidentiality with regard to the reporter of an incident. Further details of 172 | specific enforcement policies may be posted separately. 173 | 174 | Project maintainers who do not follow or enforce the Code of Conduct in good 175 | faith may face temporary or permanent repercussions as determined by other 176 | members of the project's leadership. 177 | 178 | ### Attribution 179 | 180 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), 181 | version 2.1, available at 182 | -------------------------------------------------------------------------------- /tests/peeler/pyproject/test_update.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dep_logic.specifiers import RangeSpecifier, parse_version_specifier 4 | from packaging.version import Version 5 | from pytest import mark 6 | from tomlkit.items import Table 7 | 8 | from peeler.pyproject import _BLENDER_SUPPORTED_PYTHON_VERSION 9 | from peeler.pyproject.parser import PyprojectParser 10 | from peeler.pyproject.update import ( 11 | update_dependencies, 12 | update_dependency_groups, 13 | update_requires_python, 14 | ) 15 | 16 | _unsupported_python_versions_sup = RangeSpecifier(min=Version("3.12"), include_min=True) 17 | _unsupported_python_versions_inf = RangeSpecifier(max=Version("3.10"), include_max=True) 18 | 19 | 20 | @mark.parametrize( 21 | "pyproject_requires_python", (None, ">=3.9.0,<3.14", "==3.11.*"), indirect=True 22 | ) 23 | def test_update_requires_python(pyproject_requires_python: PyprojectParser) -> None: 24 | pyproject = update_requires_python(pyproject_requires_python) 25 | version_specifier = parse_version_specifier( 26 | pyproject.project_table["requires-python"] 27 | ) 28 | 29 | assert not (version_specifier & _BLENDER_SUPPORTED_PYTHON_VERSION).is_empty() 30 | assert (version_specifier & _unsupported_python_versions_sup).is_empty() 31 | assert (version_specifier & _unsupported_python_versions_inf).is_empty() 32 | 33 | 34 | @mark.parametrize( 35 | "pyproject_requires_python", 36 | (">=3.11.10,<3.12", ">=3.11.7,<3.14", ">3.11.5"), 37 | indirect=True, 38 | ) 39 | def test_update_requires_python_restritive( 40 | pyproject_requires_python: PyprojectParser, 41 | ) -> None: 42 | pyproject = update_requires_python(pyproject_requires_python) 43 | version_specifier = parse_version_specifier( 44 | pyproject.project_table["requires-python"] 45 | ) 46 | 47 | assert Version("3.11.5") not in version_specifier 48 | assert not (version_specifier & _BLENDER_SUPPORTED_PYTHON_VERSION).is_empty() 49 | assert (version_specifier & _unsupported_python_versions_sup).is_empty() 50 | assert (version_specifier & _unsupported_python_versions_inf).is_empty() 51 | 52 | 53 | @mark.parametrize( 54 | ("pyproject_dependencies", "excluded_dependencies", "expected_result"), 55 | ( 56 | [ 57 | [ 58 | [ 59 | "dep-logic>=0.4.11", 60 | "jsonschema>=4.23.0", 61 | "packaging>=24.2", 62 | "pyproject-metadata>=0.9.0", 63 | "rich>=13.9.4", 64 | ], 65 | ["packaging"], 66 | [ 67 | "dep-logic>=0.4.11", 68 | "jsonschema>=4.23.0", 69 | "pyproject-metadata>=0.9.0", 70 | "rich>=13.9.4", 71 | ], 72 | ], 73 | [ 74 | [ 75 | "dep-logic>=0.4.11", 76 | "jsonschema>=4.23.0", 77 | "packaging>=24.2", 78 | "pyproject-metadata>=0.9.0", 79 | "rich>=13.9.4", 80 | ], 81 | ["dep-logic", "packaging", "jsonschema", "pyproject-metadata", "rich"], 82 | [], 83 | ], 84 | [ 85 | [ 86 | "dep-logic>=0.4.11", 87 | "jsonschema>=4.23.0", 88 | "packaging>=24.2", 89 | "pyproject-metadata>=0.9.0", 90 | "rich>=13.9.4", 91 | ], 92 | [], 93 | [ 94 | "dep-logic>=0.4.11", 95 | "jsonschema>=4.23.0", 96 | "packaging>=24.2", 97 | "pyproject-metadata>=0.9.0", 98 | "rich>=13.9.4", 99 | ], 100 | ], 101 | [ 102 | [ 103 | "dep-logic>=0.4.11", 104 | "jsonschema>=4.23.0", 105 | "packaging>=24.2", 106 | "pyproject-metadata>=0.9.0", 107 | "rich>=13.9.4", 108 | ], 109 | ["numpy"], 110 | [ 111 | "dep-logic>=0.4.11", 112 | "jsonschema>=4.23.0", 113 | "packaging>=24.2", 114 | "pyproject-metadata>=0.9.0", 115 | "rich>=13.9.4", 116 | ], 117 | ], 118 | [ 119 | [ 120 | "dep-logic>=0.4.11", 121 | "jsonschema>=4.23.0", 122 | "packaging>=24.2", 123 | "pyproject-metadata>=0.9.0", 124 | "rich>=13.9.4", 125 | ], 126 | ["dep_lOgic", "pYproJect.MetadaTA"], 127 | [ 128 | "jsonschema>=4.23.0", 129 | "packaging>=24.2", 130 | "rich>=13.9.4", 131 | ], 132 | ], 133 | ] 134 | ), 135 | indirect=["pyproject_dependencies"], 136 | ) 137 | def test_update_dependencies( 138 | pyproject_dependencies: PyprojectParser, 139 | excluded_dependencies: List[str], 140 | expected_result: List[str], 141 | ) -> None: 142 | assert ( 143 | update_dependencies( 144 | pyproject_dependencies, excluded_dependencies 145 | ).project_table.get("dependencies") 146 | == expected_result 147 | ) 148 | 149 | 150 | @mark.parametrize( 151 | ("pyproject_dependency_groups", "excluded_dependency_groups", "expected_result"), 152 | ( 153 | [ 154 | [{}, [], {}], 155 | [{}, ["non_existant_group"], {}], 156 | [{"group1": ["package1.1"]}, [], {"group1": ["package1.1"]}], 157 | [ 158 | {"group1": ["package1.1"], "group2": ["pacakge2.1"]}, 159 | ["group2"], 160 | {"group1": ["package1.1"]}, 161 | ], 162 | [ 163 | {"group1": ["package1.1"], "group2": ["pacakge2.1"]}, 164 | ["non_existant_group"], 165 | {"group1": ["package1.1"], "group2": ["pacakge2.1"]}, 166 | ], 167 | [ 168 | { 169 | "group1": ["package1.1"], 170 | "group2": ["pacakge2.1", {"include-group": "group1"}], 171 | }, 172 | ["group2"], 173 | {"group1": ["package1.1"]}, 174 | ], 175 | [ 176 | { 177 | "group1": ["package1.1", {"include-group": "group2"}], 178 | "group2": [ 179 | "pacakge2.1", 180 | ], 181 | }, 182 | ["group2"], 183 | {"group1": ["package1.1", {"include-group": "group2"}]}, 184 | ], 185 | ] 186 | ), 187 | indirect=["pyproject_dependency_groups"], 188 | ) 189 | def test_update_dependency_groups( 190 | pyproject_dependency_groups: PyprojectParser, 191 | excluded_dependency_groups: List[str], 192 | expected_result: List[str], 193 | ) -> None: 194 | assert ( 195 | update_dependency_groups( 196 | pyproject_dependency_groups, excluded_dependency_groups 197 | ).dependency_groups 198 | == expected_result 199 | ) 200 | -------------------------------------------------------------------------------- /peeler/command/wheels.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | from contextlib import suppress 6 | from pathlib import Path 7 | from typing import List, Optional 8 | 9 | import typer 10 | from click import format_filename 11 | from click.exceptions import ClickException 12 | from tomlkit.toml_file import TOMLFile 13 | 14 | from peeler.pyproject.parser import PyprojectParser 15 | from peeler.utils import find_pyproject_file 16 | from peeler.wheels.download import download_wheels 17 | from peeler.wheels.lock import UrlFetcherCreator 18 | 19 | # https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html 20 | WHEELS_DIRECTORY = "wheels" 21 | BLENDER_MANIFEST = "blender_manifest.toml" 22 | 23 | 24 | def _resolve_wheels_dir( 25 | wheels_directory: Path | None, 26 | blender_manifest_file: Path, 27 | *, 28 | allow_non_default_name: bool = False, 29 | ) -> Path: 30 | """Return a complete path of the wheels directory. 31 | 32 | :param wheels_directory: the original path given by the user 33 | :param blender_manifest_file: the path the blender_manifest.toml file, the wheels directory should be next to this file. 34 | :param allow_non_default_name: whether to allow the directory to be named other than `wheels`, defaults to False, see `https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html` 35 | :raises ClickException: if allow_non_default_name is False and the given path is not named `wheels` 36 | :raises ClickException: if the given path is not None and not a directory 37 | :return: The valid path 38 | 39 | >>> _resolve_wheels_dir( 40 | ... None, 41 | ... Path("/path/to/manifest/blender_manifest.toml"), 42 | ... allow_non_default_name=False, 43 | ... ) 44 | Path("/path/to/manifest/wheels") 45 | >>> _resolve_wheels_dir( 46 | ... Path("/path/to/manifest/wheels/"), 47 | ... Path("/path/to/manifest/blender_manifest.toml"), 48 | ... allow_non_default_name=False, 49 | ... ) 50 | Path("/path/to/manifest/wheels/") 51 | >>> _resolve_wheels_dir( 52 | ... Path("/path/to/wheels/"), 53 | ... Path("/path/to/other_dir/blender_manifest.toml"), 54 | ... allow_non_default_name=True, 55 | ... ) 56 | Path("/path/to/wheels/") 57 | >>> _resolve_wheels_dir( 58 | ... Path("/path/to/wheels/"), 59 | ... Path("/path/to/other_dir/blender_manifest.toml"), 60 | ... allow_non_default_name=False, 61 | ... ) 62 | ClickException: The wheels directory "/path/to/wheels" Should be next to the blender_manifest.toml file ... 63 | """ 64 | if wheels_directory is None: 65 | wheels_directory = blender_manifest_file.parent / WHEELS_DIRECTORY 66 | 67 | wheels_directory.mkdir(parents=True, exist_ok=True) 68 | 69 | if not wheels_directory.is_dir(): 70 | raise ClickException( 71 | f"{format_filename(wheels_directory)} is not a directory !" 72 | ) 73 | 74 | if not wheels_directory.name == WHEELS_DIRECTORY: 75 | msg = f"""The wheels directory {format_filename(wheels_directory)} 76 | Should be named : `{WHEELS_DIRECTORY}` not `{wheels_directory.name}` 77 | See: `https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html` 78 | """ 79 | if allow_non_default_name: 80 | typer.echo(f"Warning: {msg}") 81 | else: 82 | raise ClickException(msg) 83 | 84 | if not wheels_directory.parent == blender_manifest_file.parent: 85 | msg = f"""The wheels directory {format_filename(wheels_directory)} 86 | Should be next to the blender_manifest.toml file {format_filename(blender_manifest_file)} 87 | See: `https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html` 88 | """ 89 | if allow_non_default_name: 90 | typer.echo(f"Warning: {msg}") 91 | else: 92 | raise ClickException(msg) 93 | 94 | return wheels_directory 95 | 96 | 97 | def _resolve_blender_manifest_file( 98 | blender_manifest: Path, allow_non_default_name: bool = False 99 | ) -> Path: 100 | if blender_manifest.is_dir(): 101 | blender_manifest = blender_manifest / BLENDER_MANIFEST 102 | 103 | if not blender_manifest.name == BLENDER_MANIFEST: 104 | msg = f"""The supplied blender_manifest file {format_filename(blender_manifest)} 105 | Should be named : `{BLENDER_MANIFEST}` not `{blender_manifest.name}` 106 | See: `https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html` 107 | """ 108 | if allow_non_default_name: 109 | typer.echo(f"Warning: {msg}") 110 | else: 111 | raise ClickException(msg) 112 | 113 | return blender_manifest 114 | 115 | 116 | def _normalize(path: Path, dir: Path) -> str: 117 | return f"./{path.relative_to(dir).as_posix()}" 118 | 119 | 120 | def write_wheels_path(blender_manifest_path: Path, wheels_paths: List[Path]) -> None: 121 | """Write wheels path to blender manifest. 122 | 123 | :param blender_manifest_path: _description_ 124 | :param wheels_paths: _description_ 125 | """ 126 | 127 | if not blender_manifest_path.exists(): 128 | raise RuntimeError(f"No blender_manifest at {blender_manifest_path}") 129 | 130 | file = TOMLFile(blender_manifest_path) 131 | doc = file.read() 132 | 133 | doc.update( 134 | { 135 | "wheels": [ 136 | _normalize(wheel, blender_manifest_path.parent) 137 | for wheel in wheels_paths 138 | ] 139 | } 140 | ) 141 | 142 | file.write(doc) 143 | 144 | 145 | def _get_supported_platforms(pyproject_path: Path) -> List[str] | None: 146 | with suppress(ClickException): 147 | if not (pyproject_file := find_pyproject_file(pyproject_path)): 148 | return None 149 | 150 | if not ( 151 | manifest_table := PyprojectParser.from_file(pyproject_file).manifest_table 152 | ): 153 | return None 154 | 155 | return manifest_table["platforms"] 156 | 157 | 158 | def wheels_command( 159 | path: Path, 160 | blender_manifest_file: Path, 161 | wheels_directory: Path | None, 162 | excluded_packages: Optional[List[str]] = None, 163 | excluded_dependency: Optional[List[str]] = None, 164 | excluded_dependency_group: Optional[List[str]] = None, 165 | ) -> None: 166 | """Download wheel from pyproject dependency and write their paths to the blender manifest. 167 | 168 | :param file: The pyproject / uv.lock / pylock file or directory. 169 | :param blender_manifest_file: the blender manifest file 170 | :param wheels_directory: the directory to download wheels into. 171 | """ 172 | 173 | blender_manifest_file = _resolve_blender_manifest_file( 174 | blender_manifest_file, allow_non_default_name=True 175 | ) 176 | 177 | wheels_directory = _resolve_wheels_dir( 178 | wheels_directory, blender_manifest_file, allow_non_default_name=True 179 | ) 180 | 181 | strategy = UrlFetcherCreator(path).get_fetch_url_strategy( 182 | excluded_dependencies=excluded_dependency, 183 | excluded_dependency_groups=excluded_dependency_group, 184 | ) 185 | 186 | urls = strategy.get_urls() 187 | 188 | supported_platform = _get_supported_platforms(path) 189 | 190 | wheels_paths = download_wheels( 191 | wheels_directory, 192 | urls, 193 | excluded_packages=excluded_packages, 194 | supported_platforms=supported_platform, 195 | ) 196 | 197 | write_wheels_path(blender_manifest_file, wheels_paths) 198 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # blender temp files 2 | *.blend1 3 | 4 | ### dotenv ### 5 | .env 6 | 7 | ### Linux ### 8 | *~ 9 | 10 | # temporary files which can be created if a process still has a handle open of a deleted file 11 | .fuse_hidden* 12 | 13 | # KDE directory preferences 14 | .directory 15 | 16 | # Linux trash folder which might appear on any partition or disk 17 | .Trash-* 18 | 19 | # .nfs files are created when an open file is removed but is still being accessed 20 | .nfs* 21 | 22 | ### PyCharm ### 23 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 24 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 25 | 26 | # User-specific stuff 27 | .idea/**/workspace.xml 28 | .idea/**/tasks.xml 29 | .idea/**/usage.statistics.xml 30 | .idea/**/dictionaries 31 | .idea/**/shelf 32 | 33 | # AWS User-specific 34 | .idea/**/aws.xml 35 | 36 | # Generated files 37 | .idea/**/contentModel.xml 38 | 39 | # Sensitive or high-churn files 40 | .idea/**/dataSources/ 41 | .idea/**/dataSources.ids 42 | .idea/**/dataSources.local.xml 43 | .idea/**/sqlDataSources.xml 44 | .idea/**/dynamic.xml 45 | .idea/**/uiDesigner.xml 46 | .idea/**/dbnavigator.xml 47 | 48 | # Gradle 49 | .idea/**/gradle.xml 50 | .idea/**/libraries 51 | 52 | # Gradle and Maven with auto-import 53 | # When using Gradle or Maven with auto-import, you should exclude module files, 54 | # since they will be recreated, and may cause churn. Uncomment if using 55 | # auto-import. 56 | # .idea/artifacts 57 | # .idea/compiler.xml 58 | # .idea/jarRepositories.xml 59 | # .idea/modules.xml 60 | # .idea/*.iml 61 | # .idea/modules 62 | # *.iml 63 | # *.ipr 64 | 65 | # CMake 66 | cmake-build-*/ 67 | 68 | # Mongo Explorer plugin 69 | .idea/**/mongoSettings.xml 70 | 71 | # File-based project format 72 | *.iws 73 | 74 | # IntelliJ 75 | out/ 76 | 77 | # mpeltonen/sbt-idea plugin 78 | .idea_modules/ 79 | 80 | # JIRA plugin 81 | atlassian-ide-plugin.xml 82 | 83 | # Cursive Clojure plugin 84 | .idea/replstate.xml 85 | 86 | # SonarLint plugin 87 | .idea/sonarlint/ 88 | 89 | # Crashlytics plugin (for Android Studio and IntelliJ) 90 | com_crashlytics_export_strings.xml 91 | crashlytics.properties 92 | crashlytics-build.properties 93 | fabric.properties 94 | 95 | # Editor-based Rest Client 96 | .idea/httpRequests 97 | 98 | # Android studio 3.1+ serialized cache file 99 | .idea/caches/build_file_checksums.ser 100 | 101 | ### PyCharm Patch ### 102 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 103 | 104 | # *.iml 105 | # modules.xml 106 | # .idea/misc.xml 107 | # *.ipr 108 | 109 | # Sonarlint plugin 110 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 111 | .idea/**/sonarlint/ 112 | 113 | # SonarQube Plugin 114 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 115 | .idea/**/sonarIssues.xml 116 | 117 | # Markdown Navigator plugin 118 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 119 | .idea/**/markdown-navigator.xml 120 | .idea/**/markdown-navigator-enh.xml 121 | .idea/**/markdown-navigator/ 122 | 123 | # Cache file creation bug 124 | # See https://youtrack.jetbrains.com/issue/JBR-2257 125 | .idea/$CACHE_FILE$ 126 | 127 | # CodeStream plugin 128 | # https://plugins.jetbrains.com/plugin/12206-codestream 129 | .idea/codestream.xml 130 | 131 | # Azure Toolkit for IntelliJ plugin 132 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 133 | .idea/**/azureSettings.xml 134 | 135 | ### Python ### 136 | # Byte-compiled / optimized / DLL files 137 | __pycache__/ 138 | *.py[cod] 139 | *$py.class 140 | 141 | # C extensions 142 | *.so 143 | 144 | # Distribution / packaging 145 | .Python 146 | build/ 147 | develop-eggs/ 148 | dist/ 149 | downloads/ 150 | eggs/ 151 | .eggs/ 152 | lib/ 153 | lib64/ 154 | parts/ 155 | sdist/ 156 | var/ 157 | wheels/ 158 | share/python-wheels/ 159 | *.egg-info/ 160 | .installed.cfg 161 | *.egg 162 | MANIFEST 163 | 164 | # PyInstaller 165 | # Usually these files are written by a python script from a template 166 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 167 | *.manifest 168 | *.spec 169 | 170 | # Installer logs 171 | pip-log.txt 172 | pip-delete-this-directory.txt 173 | 174 | # Unit test / coverage reports 175 | htmlcov/ 176 | .tox/ 177 | .nox/ 178 | .coverage 179 | .coverage.* 180 | .cache 181 | nosetests.xml 182 | coverage.xml 183 | *.cover 184 | *.py,cover 185 | .hypothesis/ 186 | .pytest_cache/ 187 | cover/ 188 | 189 | # Translations 190 | *.mo 191 | *.pot 192 | 193 | # Django stuff: 194 | *.log 195 | local_settings.py 196 | db.sqlite3 197 | db.sqlite3-journal 198 | 199 | # Flask stuff: 200 | instance/ 201 | .webassets-cache 202 | 203 | # Scrapy stuff: 204 | .scrapy 205 | 206 | # Sphinx documentation 207 | docs/build/ 208 | docs/source/_auto/ 209 | docs/_build/ 210 | 211 | # PyBuilder 212 | .pybuilder/ 213 | target/ 214 | 215 | # Jupyter Notebook 216 | .ipynb_checkpoints 217 | 218 | # IPython 219 | profile_default/ 220 | ipython_config.py 221 | 222 | # pyenv 223 | # For a library or package, you might want to ignore these files since the code is 224 | # intended to run in multiple environments; otherwise, check them in: 225 | # .python-version 226 | 227 | # pipenv 228 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 229 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 230 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 231 | # install all needed dependencies. 232 | #Pipfile.lock 233 | 234 | # poetry 235 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 236 | # This is especially recommended for binary packages to ensure reproducibility, and is more 237 | # commonly ignored for libraries. 238 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 239 | poetry.lock 240 | 241 | # pdm 242 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 243 | pdm.lock 244 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 245 | # in version control. 246 | # https://pdm.fming.dev/#use-with-ide 247 | .pdm.toml 248 | 249 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 250 | __pypackages__/ 251 | 252 | # Celery stuff 253 | celerybeat-schedule 254 | celerybeat.pid 255 | 256 | # SageMath parsed files 257 | *.sage.py 258 | 259 | # Environments 260 | .venv 261 | env/ 262 | venv/ 263 | ENV/ 264 | env.bak/ 265 | venv.bak/ 266 | 267 | # Spyder project settings 268 | .spyderproject 269 | .spyproject 270 | 271 | # Rope project settings 272 | .ropeproject 273 | 274 | # mkdocs documentation 275 | /site 276 | 277 | # mypy 278 | .mypy_cache/ 279 | .dmypy.json 280 | dmypy.json 281 | 282 | # Pyre type checker 283 | .pyre/ 284 | 285 | # pytype static type analyzer 286 | .pytype/ 287 | 288 | # Cython debug symbols 289 | cython_debug/ 290 | 291 | # PyCharm 292 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 293 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 294 | # and can be added to the global gitignore or merged into this file. For a more nuclear 295 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 296 | #.idea/ 297 | 298 | ### Python Patch ### 299 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 300 | poetry.toml 301 | 302 | # ruff 303 | .ruff_cache/ 304 | 305 | # LSP config files 306 | pyrightconfig.json 307 | 308 | ### venv ### 309 | # Virtualenv 310 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 311 | [Bb]in 312 | [Ii]nclude 313 | [Ll]ib 314 | [Ll]ib64 315 | [Ll]ocal 316 | [Ss]cripts 317 | pyvenv.cfg 318 | pip-selfcheck.json 319 | 320 | ### VisualStudioCode ### 321 | .vscode/* 322 | # !.vscode/settings.json 323 | # !.vscode/tasks.json 324 | # !.vscode/launch.json 325 | # !.vscode/extensions.json 326 | # !.vscode/*.code-snippets 327 | 328 | # Local History for Visual Studio Code 329 | .history/ 330 | 331 | # Built Visual Studio Code Extensions 332 | *.vsix 333 | 334 | ### VisualStudioCode Patch ### 335 | # Ignore all local history of files 336 | .history 337 | .ionide 338 | 339 | ### Windows ### 340 | # Windows thumbnail cache files 341 | Thumbs.db 342 | Thumbs.db:encryptable 343 | ehthumbs.db 344 | ehthumbs_vista.db 345 | 346 | # Dump file 347 | *.stackdump 348 | 349 | # Folder config file 350 | [Dd]esktop.ini 351 | 352 | # Recycle Bin used on file shares 353 | $RECYCLE.BIN/ 354 | 355 | # Windows Installer files 356 | *.cab 357 | *.msi 358 | *.msix 359 | *.msm 360 | *.msp 361 | 362 | # Windows shortcuts 363 | *.lnk 364 | 365 | # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,pycharm,linux,venv,dotenv,windows 366 | 367 | # project specific gitignore: 368 | 369 | !peeler/manifest 370 | !peeler/wheels 371 | !tests/peeler/manifest/ 372 | !tests/peeler/wheels/ 373 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Peeler – Simplify Your Blender Add-on Packaging 2 | 3 | > Feel free to ask for help [here](https://github.com/Maxioum) or open an issue [here](https://github.com/Maxioum/Peeler/issues/new). 4 | 5 | A tool to easily package your **Blender add-on** 6 | 7 | Building and installing a Blender add-on with dependencies requires **manually** downloading the necessary wheels and specifying their paths in `blender_manifest.toml`. Peeler automates this process, allowing you to package your Blender add-on without **manually handling dependencies** (and their own dependencies !) or **manually writing their paths** in `blender_manifest.toml`. 8 | 9 | Since Blender 4.2, add-ons must use `blender_manifest.toml` instead of the standard `pyproject.toml` used in Python projects. **Peeler** lets you use `pyproject.toml` instead (or alongside) to simplify dependency management and streamline your workflow. 10 | 11 | # Installation 12 | 13 | You can install **Peeler** with your favorite package manager (pip, uv, pipx, etc.). To get started, simply run: 14 | 15 | ```bash 16 | pip install peeler 17 | ``` 18 | 19 | If you use [uv](https://docs.astral.sh/uv/) **Peeler** does not need to be added to your project dependencies - you can use **Peeler** directly as a tool: 20 | 21 | ```bash 22 | uvx peeler [OPTIONS] COMMAND [ARGS] 23 | ``` 24 | 25 | # Features 26 | 27 | Each feature can be used independently. 28 | 29 | 🛠️ [Manifest](#Manifest) 30 | 31 | > Generate a `blender_manifest.toml` file from your `pyproject.toml` fields. 32 | 33 | 📦 [Wheels](#Wheels) 34 | 35 | > Automatically download the required **wheels** from your add-on’s dependencies specified in your `pyproject.toml` and write their paths to `blender_manifest.toml`. 36 | 37 | ## Manifest 38 | 39 | Generate the `blender_manifest.toml` from fields in a `pyproject.toml`. 40 | 41 | ### 1. Ensure your `pyproject.toml` contains basic field values 42 | 43 | ```toml 44 | # pyproject.toml 45 | 46 | [project] 47 | name = "MyAwesomeAddon" 48 | version = "1.0.0" 49 | requires-python = "==3.11.*" 50 | ``` 51 | 52 | ### 2. Some metadata are specific to **Blender** 53 | 54 | For instance `blender_version_min`, you can specify these metadata in your `pyproject.toml` file under the `[tool.peeler.manifest]` table 55 | Here's a minimal working version: 56 | 57 | ```toml 58 | # pyproject.toml 59 | 60 | [project] 61 | name = "MyAwesomeAddon" 62 | version = "1.0.0" 63 | requires-python = "==3.11.*" 64 | 65 | [tool.peeler.manifest] 66 | blender_version_min = "4.2.0" 67 | id = "my_awesome_add_on" 68 | license = ["SPDX:0BSD"] 69 | maintainer = "John Smith" 70 | tagline = "My Add-on is awesome" 71 | ``` 72 | 73 | ### 3. Run Peeler to create (or update) your `blender_manifest.toml` 74 | 75 | ```bash 76 | peeler manifest /path/to/your/pyproject.toml /path/to/blender_manifest.toml 77 | ``` 78 | 79 | ```toml 80 | # Generated blender_manifest.toml 81 | 82 | version = "1.0.0" 83 | name = "MyAwesomeAddon" 84 | schema_version = "1.0.0" 85 | type = "add-on" 86 | blender_version_min = "4.2.0" 87 | id = "my_awesome_add_on" 88 | license = ["SPDX:0BSD"] 89 | maintainer = "John Smith" 90 | tagline = "My Add-on is awesome" 91 | ``` 92 | 93 | The manifest is populated with values from your `pyproject.toml` `[project]` and `[tool.peeler.manifest]` tables, along with default values. 94 | 95 | For a full list of required and optional values in a `blender_manifest.toml` visit [Blender Documentation](https://docs.blender.org/manual/en/latest/advanced/extensions/getting_started.html#manifest) 96 | 97 | ### 4. Build your add-on 98 | 99 | If your add-on has dependencies make sure to use the [Wheels](#wheels) feature below. 100 | 101 | Then to build your add-on use the [regular Blender command](https://docs.blender.org/manual/en/latest/advanced/extensions/getting_started.html#command-line): 102 | 103 | ```bash 104 | blender --command extension build 105 | ``` 106 | 107 | Hint: Ensure Blender is [added to your `PATH`](https://docs.blender.org/manual/en/4.4/advanced/command_line/launch/) 108 | 109 | ## Wheels 110 | 111 | Download the required **wheels** for packaging your add-on based on the dependencies specified in your `pyproject.toml`, automatically write their paths to `blender_manifest.toml`. 112 | 113 | ### 0. Installation 114 | 115 | **Peeler** [Wheels](#wheels) feature relies on a [lockfile](https://pydevtools.com/handbook/explanation/what-is-a-lock-file/) 📄 to work. 116 | 117 | Currently supported lockfile formats: 118 | 119 | - :snake: Python [PEP 751](https://peps.python.org/pep-0751/) [pylock.toml](https://packaging.python.org/en/latest/specifications/pylock-toml/) 120 | - 🚀 [uv](https://docs.astral.sh/uv/concepts/projects/sync/) [uv.lock](https://docs.astral.sh/uv/concepts/projects/sync/) 121 | 122 | Use your favorite tool such as [PDM](https://pdm-project.org/en/latest/usage/lockfile/#export-locked-packages-to-alternative-formats) or [uv](https://docs.astral.sh/uv/concepts/projects/layout/#pylocktoml) to generate a **pylock.toml** file. 123 | 124 | If you don't have a tool yet, just run: 125 | 126 | ```bash 127 | pip install peeler[uv] 128 | ``` 129 | 130 | Then sit back and let **Peeler** handle it for you 😄 131 | 132 | ### 1. In your `pyproject.toml`, specify your dependencies 133 | 134 | ```toml 135 | # pyproject.toml 136 | 137 | [project] 138 | name = "MyAwesomeAddon" 139 | version = "1.0.0" 140 | requires-python = "==3.11.*" 141 | 142 | # For instance rich and Pillow (the popular image manipulation module) 143 | 144 | dependencies = [ 145 | "Pillow==11.1.0", 146 | "rich>=13.9.4", 147 | ] 148 | 149 | ``` 150 | 151 | ### 2. Run peeler wheels to download the wheels for **all platforms** 152 | 153 | ```bash 154 | peeler wheels ./pyproject.toml ./blender_manifest.toml 155 | ``` 156 | 157 | **Peeler** updates your `blender_manifest.toml` with the downloaded wheels paths. 158 | 159 | ```toml 160 | # Updated blender_manifest.toml 161 | 162 | version = "1.0.0" 163 | name = "MyAwesomeAddon" 164 | schema_version = "1.0.0" 165 | type = "add-on" 166 | blender_version_min = "4.2.0" 167 | 168 | # The wheels as a list of paths 169 | wheels = [ 170 | # Pillow wheels for all platforms 171 | "./wheels/pillow-11.1.0-cp311-cp311-macosx_10_10_x86_64.whl", 172 | "./wheels/pillow-11.1.0-cp311-cp311-macosx_11_0_arm64.whl", 173 | "./wheels/pillow-11.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", 174 | "./wheels/pillow-11.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", 175 | "./wheels/pillow-11.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", 176 | "./wheels/pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", 177 | "./wheels/pillow-11.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", 178 | "./wheels/pillow-11.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", 179 | "./wheels/pillow-11.1.0-cp311-cp311-win32.whl", 180 | "./wheels/pillow-11.1.0-cp311-cp311-win_amd64.whl", 181 | "./wheels/pillow-11.1.0-cp311-cp311-win_arm64.whl", 182 | 183 | # Wheels for rich and its dependencies 184 | "./wheels/rich-13.9.4-py3-none-any.whl", 185 | "./wheels/markdown_it_py-3.0.0-py3-none-any.whl", 186 | "./wheels/mdurl-0.1.2-py3-none-any.whl", 187 | "./wheels/pygments-2.18.0-py3-none-any.whl" 188 | ] 189 | 190 | ``` 191 | 192 | Note that the **dependencies of the dependencies** (and so on) specified in `pyproject.toml` are also downloaded, ensuring everything is packaged correctly. Pretty neat, right? 193 | 194 | ```bash 195 | # Pillow and rich dependency tree resolved from 196 | # dependencies = [ 197 | # "Pillow==11.1.0", 198 | # "rich>=13.9.4", 199 | # ] 200 | 201 | MyAwesomeAddon on v1.0.0 202 | ├── pillow v11.1.0 203 | ├── rich v13.9.4 204 | │ ├── markdown-it-py v3.0.0 205 | │ │ └── mdurl v0.1.2 206 | │ └── pygments v2.18.0 207 | ``` 208 | 209 | ### Options 210 | 211 | #### Exclude a package from being downloaded 212 | 213 | Use `--exclude-package PACKAGE` to prevent wheels for this package from being downloaded. 214 | 215 | > Useful for packages already bundled with `Blender` (e.g. `numpy`) that have to be part of dependency resolution. 216 | 217 | Example: 218 | 219 | ```bash 220 | peeler wheels --exclude-package numpy 221 | ``` 222 | 223 | This option can be used multiple times: 224 | 225 | ```bash 226 | peeler wheels --exclude-package bpy --exclude-package numpy 227 | ``` 228 | 229 | #### Exclude a dependency from dependency resolution 230 | 231 | Use `--exclude-dependency DEPENDENCY` to prevent wheels for this dependency from being downloaded. 232 | 233 | > Useful for dependencies not used in production (e.g. `fake-bpy-module`). 234 | 235 | Example: 236 | 237 | ```bash 238 | peeler wheels --exclude-dependency fake-bpy-module 239 | ``` 240 | 241 | This option can be used multiple times: 242 | 243 | ```bash 244 | peeler wheels --exclude-dependency fake-bpy-module --exclude-dependency pip 245 | ``` 246 | 247 | This option requires a `pyproject.toml` file and uv (`https://astral.sh/blog/uv`) 248 | 249 | #### Exclude a dependency group from dependency resolution 250 | 251 | Use `--exclude-dependency-group DEPENDENCY_GROUP` to prevent wheels for this dependency group from being downloaded. 252 | 253 | > Useful for dependency groups not used in production. 254 | 255 | ```toml 256 | # pyproject.toml 257 | 258 | [dependency-groups] 259 | docs = ["sphinx"] 260 | coverage = ["coverage[toml]"] 261 | test = ["pytest>7", {include-group = "coverage"}] 262 | ``` 263 | 264 | Example: 265 | 266 | ```bash 267 | peeler wheels --exclude-dependency-group dev 268 | ``` 269 | 270 | This option can be used multiple times: 271 | 272 | ```bash 273 | peeler wheels --exclude-dependency-group dev --exclude-dependency-group test 274 | ``` 275 | 276 | This option requires a `pyproject.toml` file and uv (`https://astral.sh/blog/uv`) 277 | 278 | See more on dependency groups on [python.org](https://packaging.python.org/en/latest/specifications/dependency-groups/) 279 | 280 | # Authors 281 | 282 | 283 | 284 | - **Maxime Letellier** - _Initial work_ 285 | 286 | 287 | -------------------------------------------------------------------------------- /peeler/wheels/lock.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import re 6 | from abc import ABC, abstractmethod 7 | from collections.abc import Generator 8 | from contextlib import contextmanager 9 | from os import fspath 10 | from pathlib import Path 11 | from subprocess import run 12 | from typing import Any, Dict, List, Optional, Tuple, Type 13 | 14 | from click import ClickException, format_filename 15 | from tomlkit import TOMLDocument 16 | from tomlkit.toml_file import TOMLFile 17 | 18 | from peeler.pyproject.parser import PyprojectParser 19 | from peeler.pyproject.update import ( 20 | update_dependencies, 21 | update_dependency_groups, 22 | update_requires_python, 23 | ) 24 | from peeler.utils import restore_file 25 | from peeler.uv_utils import check_uv_version, find_uv_bin 26 | 27 | UV_LOCK_FILE = "uv.lock" 28 | 29 | from peeler.pyproject.parser import PyprojectParser 30 | 31 | 32 | @contextmanager 33 | def _generate_uv_lock(pyproject_file: Path) -> Generator[Path, None, None]: 34 | """Generate or update a uv.lock file from a pyproject.toml files. 35 | 36 | :param pyproject_file: the pyproject filepath 37 | :yield: the lock file path 38 | """ 39 | uv_bin = find_uv_bin() 40 | 41 | lock_path = Path(pyproject_file).parent / UV_LOCK_FILE 42 | 43 | cmd: List[str] = [ 44 | uv_bin, 45 | "--no-config", 46 | "--directory", 47 | fspath(pyproject_file.parent), 48 | "lock", 49 | "--no-build", 50 | ] 51 | 52 | python_specifiers = PyprojectParser.from_file(pyproject_file).project_table.get( 53 | "requires-python" 54 | ) 55 | 56 | if python_specifiers: 57 | cmd.extend(["--python", str(python_specifiers)]) 58 | 59 | with restore_file(lock_path, missing_ok=True): 60 | run( 61 | cmd, 62 | cwd=pyproject_file.parent, 63 | ) 64 | 65 | yield lock_path 66 | 67 | 68 | def _get_wheels_urls_from_uv_lock(lock_toml: TOMLDocument) -> Dict[str, List[str]]: 69 | """Retrieve wheels url from a uv.lock toml. 70 | 71 | :param lock_toml: the uv.lock file 72 | :return: A mapping from package to a list of url. 73 | """ 74 | 75 | urls: Dict[str, List[str]] = {} 76 | 77 | if (packages := lock_toml.get("package", None)) is None: 78 | return {} 79 | 80 | for package in packages: 81 | if "wheels" not in package: 82 | continue 83 | 84 | urls[package["name"]] = [wheels["url"] for wheels in package["wheels"]] 85 | 86 | return urls 87 | 88 | 89 | def _get_wheels_urls_from_pylock(lock_toml: TOMLDocument) -> Dict[str, List[str]]: 90 | """Retrieve wheels url from a pylock toml. 91 | 92 | :param lock_toml: the pylock file 93 | :return: A mapping from package to a list of url. 94 | """ 95 | 96 | urls: Dict[str, List[str]] = {} 97 | 98 | if (packages := lock_toml.get("packages", None)) is None: 99 | return {} 100 | 101 | for package in packages: 102 | if "wheels" not in package: 103 | continue 104 | 105 | urls[package["name"]] = [wheels["url"] for wheels in package["wheels"]] 106 | 107 | return urls 108 | 109 | 110 | class AbstractURLFetcherStrategy(ABC): 111 | """Abstract base class for strategies that fetch URLs from a file. 112 | 113 | This class defines the structure for URL fetcher strategies that parse or retrieve 114 | URLs from a specified file. 115 | 116 | :param path: Path to the file where the URLs to be parsed or retrieved are. 117 | """ 118 | 119 | def __init__(self, path: Path, *arg: Any, **kwargs: Any) -> None: 120 | pass 121 | 122 | @abstractmethod 123 | def get_urls(self) -> Dict[str, List[str]]: 124 | """Fetch and return URLs determined from the file. 125 | 126 | The method should return a mapping where each key is a package name, and each 127 | value is a list of wheel URLs associated with that package. 128 | 129 | :return: A dictionary mapping package names to a list of URLs. 130 | """ 131 | 132 | raise NotImplementedError 133 | 134 | 135 | class UVLockUrlFetcher(AbstractURLFetcherStrategy): 136 | """URL fetcher that extracts wheel URLs from a uv.lock file. 137 | 138 | Parses the given uv.lock file and retrieves a list of wheel URLs for each package 139 | listed in the lock file. 140 | 141 | :param uv_lock: Path to the uv.lock file to extract wheel url from. 142 | """ 143 | 144 | def __init__(self, uv_lock: Path) -> None: 145 | self.uv_lock = uv_lock 146 | 147 | def get_urls(self) -> Dict[str, List[str]]: 148 | """Extract wheel URLs from the uv.lock file. 149 | 150 | Parses the uv.lock file and returns a mapping of package names to lists 151 | of wheel URLs. 152 | 153 | :return: Dictionary with package names as keys and lists of wheel URLs as values. 154 | """ 155 | 156 | lock_toml = TOMLFile(self.uv_lock).read() 157 | return _get_wheels_urls_from_uv_lock(lock_toml) 158 | 159 | 160 | class PyprojectUVLockFetcher(AbstractURLFetcherStrategy): 161 | """URL fetcher that retrieves wheel URLs from a pyproject.toml file. 162 | 163 | Temporarily modifies the pyproject.toml file to restrict dependencies to a 164 | Blender-compatible environment, generates a uv.lock file, and extracts the 165 | corresponding wheel URLs for each package. 166 | 167 | :param pyproject: Path to the pyproject.toml file to process. 168 | """ 169 | 170 | def __init__( 171 | self, 172 | pyproject: Path, 173 | *, 174 | excluded_dependencies: Optional[List[str]] = None, 175 | excluded_dependency_groups: Optional[List[str]] = None, 176 | ): 177 | self.pyproject = pyproject 178 | self.excluded_dependencies = excluded_dependencies 179 | self.excluded_dependency_groups = excluded_dependency_groups 180 | 181 | def get_urls(self) -> Dict[str, List[str]]: 182 | """Extract wheel URLs from the dependencies listed in pyproject.toml. 183 | 184 | This method temporarily adjusts the pyproject file to ensure compatibility, 185 | generates a uv.lock file using `uv`, and parses it to collect URLs of 186 | downloadable wheels for each package. 187 | 188 | :return: Dictionary with package names as keys and lists of wheel URLs as values. 189 | """ 190 | 191 | check_uv_version() 192 | 193 | # Temporarily modify the pyproject file to restrict to Blender-supported wheels 194 | with restore_file(self.pyproject): 195 | file = TOMLFile(self.pyproject) 196 | pyproject = PyprojectParser(file.read()) 197 | pyproject = update_requires_python(pyproject) 198 | if self.excluded_dependencies: 199 | pyproject = update_dependencies(pyproject, self.excluded_dependencies) 200 | if self.excluded_dependency_groups: 201 | pyproject = update_dependency_groups( 202 | pyproject, self.excluded_dependency_groups 203 | ) 204 | file.write(pyproject._document) 205 | 206 | # Generate a uv.lock file and extract wheel URLs 207 | with _generate_uv_lock(self.pyproject) as uv_lock: 208 | uv_lock_toml = TOMLFile(uv_lock).read() 209 | return _get_wheels_urls_from_uv_lock(uv_lock_toml) 210 | 211 | 212 | class PylockUrlFetcher(AbstractURLFetcherStrategy): 213 | """URL fetcher that extracts wheel URLs from a pylock like file. 214 | 215 | Parses the given pylock file and retrieves a list of wheel URLs for 216 | each package listed in the lock file. 217 | 218 | :param pylock: Path to the pylock file. 219 | """ 220 | 221 | def __init__(self, pylock: Path): 222 | self.pylock = pylock 223 | 224 | def get_urls(self) -> Dict[str, List[str]]: 225 | """Extract wheel URLs from the pylock file. 226 | 227 | :return: Dictionary with package names as keys and lists of wheel URLs as values. 228 | """ 229 | 230 | pylock_toml = TOMLFile(self.pylock).read() 231 | return _get_wheels_urls_from_pylock(pylock_toml) 232 | 233 | 234 | class UrlFetcherCreator: 235 | """Factory class to select the appropriate URL fetcher strategy based on the file name. 236 | 237 | This class inspects a given path (file or directory) and determines which 238 | URL fetcher strategy to use, depending on the presence of known files such as 239 | `pylock.toml` or `pylock.*.toml`, `pyproject.toml`, or `uv.lock`. 240 | 241 | The matching is based on regular expressions, evaluated in the declared order. 242 | 243 | :param path: Path to a file or a directory containing lock/config files. 244 | """ 245 | 246 | # The order of patterns matters (checked top to bottom) 247 | regexes_to_strategy: List[Tuple[str, Type[AbstractURLFetcherStrategy]]] = [ 248 | (r"^pylock.toml$", PylockUrlFetcher), 249 | (r"^pylock\.[^.]+\.toml$", PylockUrlFetcher), 250 | (r"^pyproject\.toml$", PyprojectUVLockFetcher), 251 | (r"^uv\.lock$", UVLockUrlFetcher), 252 | ] 253 | 254 | def __init__(self, path: Path) -> None: 255 | self.path = path 256 | 257 | def get_fetch_url_strategy( 258 | self, 259 | *, 260 | excluded_dependencies: Optional[List[str]] = None, 261 | excluded_dependency_groups: Optional[List[str]] = None, 262 | ) -> AbstractURLFetcherStrategy: 263 | """Select and return the appropriate URL fetcher strategy based on the given file or directory. 264 | 265 | If the path is a file, check whether it matches one of the known patterns. 266 | If the path is a directory, look for (in order): a pylock file, uv.lock, then pyproject.toml. 267 | 268 | :return: Instance of a subclass of AbstractURLFetcherStrategy. 269 | :raises ClickException: If no matching file is found. 270 | """ 271 | 272 | files = (self.path,) if not self.path.is_dir() else self.path.iterdir() 273 | 274 | if has_excluded_dependencies := bool( 275 | excluded_dependencies or excluded_dependency_groups 276 | ): 277 | # if there are excluded dependencies need to have a pyproject.toml file 278 | regex, strategy = self.regexes_to_strategy[2] 279 | for filepath in files: 280 | if re.match(regex, filepath.name): 281 | return strategy( 282 | filepath, 283 | excluded_dependencies=excluded_dependencies, 284 | excluded_dependency_groups=excluded_dependency_groups, 285 | ) 286 | else: 287 | for regex, strategy in self.regexes_to_strategy: 288 | for filepath in files: 289 | if re.match(regex, filepath.name): 290 | return strategy(filepath) 291 | 292 | if self.path.is_dir(): 293 | msg = f"No supported file found in {format_filename(self.path.resolve())}." 294 | else: 295 | msg = f"The file {format_filename(self.path.resolve())} is not a supported type." 296 | 297 | if has_excluded_dependencies: 298 | msg = f"{msg}\n Expected a `pyproject.toml` file to exclude dependencies" 299 | else: 300 | msg = f"{msg}\n" 301 | f"Expected one of the following:\n" 302 | f" - pylock.toml or pylock.*.toml\n" 303 | f" - uv.lock\n" 304 | f" - pyproject.toml" 305 | 306 | raise ClickException(msg) 307 | -------------------------------------------------------------------------------- /tests/peeler/wheels/test_download.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pytest 4 | 5 | from peeler.wheels.download import ( 6 | HasValidImplementation, 7 | PackageIsNotExcluded, 8 | PlatformIsNotExcluded, 9 | _parse_implementation_and_python_version, 10 | ) 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ["python_tag", "expected_implementation", "expected_python_version"], 15 | [ 16 | ("py2", "py", "2"), 17 | ("py3", "py", "3"), 18 | ("py25", "py", "25"), 19 | ("py26", "py", "26"), 20 | ("py27", "py", "27"), 21 | ("py31", "py", "31"), 22 | ("py32", "py", "32"), 23 | ("py33", "py", "33"), 24 | ("py34", "py", "34"), 25 | ("py35", "py", "35"), 26 | ("py36", "py", "36"), 27 | ("py37", "py", "37"), 28 | ("py38", "py", "38"), 29 | ("py39", "py", "39"), 30 | ("py310", "py", "310"), 31 | ("py311", "py", "311"), 32 | ("py312", "py", "312"), 33 | ("py313", "py", "313"), 34 | ("py314", "py", "314"), 35 | ("cp2", "cp", "2"), 36 | ("cp3", "cp", "3"), 37 | ("cp25", "cp", "25"), 38 | ("cp26", "cp", "26"), 39 | ("cp27", "cp", "27"), 40 | ("cp31", "cp", "31"), 41 | ("cp32", "cp", "32"), 42 | ("cp33", "cp", "33"), 43 | ("cp34", "cp", "34"), 44 | ("cp35", "cp", "35"), 45 | ("cp36", "cp", "36"), 46 | ("cp37", "cp", "37"), 47 | ("cp38", "cp", "38"), 48 | ("cp39", "cp", "39"), 49 | ("cp310", "cp", "310"), 50 | ("cp311", "cp", "311"), 51 | ("cp312", "cp", "312"), 52 | ("cp313", "cp", "313"), 53 | ("cp314", "cp", "314"), 54 | ("ip2", "ip", "2"), 55 | ("ip3", "ip", "3"), 56 | ("ip25", "ip", "25"), 57 | ("ip26", "ip", "26"), 58 | ("ip27", "ip", "27"), 59 | ("ip31", "ip", "31"), 60 | ("ip32", "ip", "32"), 61 | ("ip33", "ip", "33"), 62 | ("ip34", "ip", "34"), 63 | ("ip35", "ip", "35"), 64 | ("ip36", "ip", "36"), 65 | ("ip37", "ip", "37"), 66 | ("ip38", "ip", "38"), 67 | ("ip39", "ip", "39"), 68 | ("ip310", "ip", "310"), 69 | ("ip311", "ip", "311"), 70 | ("ip312", "ip", "312"), 71 | ("ip313", "ip", "313"), 72 | ("ip314", "ip", "314"), 73 | ("pp2", "pp", "2"), 74 | ("pp3", "pp", "3"), 75 | ("pp25", "pp", "25"), 76 | ("pp26", "pp", "26"), 77 | ("pp27", "pp", "27"), 78 | ("pp31", "pp", "31"), 79 | ("pp32", "pp", "32"), 80 | ("pp33", "pp", "33"), 81 | ("pp34", "pp", "34"), 82 | ("pp35", "pp", "35"), 83 | ("pp36", "pp", "36"), 84 | ("pp37", "pp", "37"), 85 | ("pp38", "pp", "38"), 86 | ("pp39", "pp", "39"), 87 | ("pp310", "pp", "310"), 88 | ("pp311", "pp", "311"), 89 | ("pp312", "pp", "312"), 90 | ("pp313", "pp", "313"), 91 | ("pp314", "pp", "314"), 92 | ("jy2", "jy", "2"), 93 | ("jy3", "jy", "3"), 94 | ("jy25", "jy", "25"), 95 | ("jy26", "jy", "26"), 96 | ("jy27", "jy", "27"), 97 | ("jy31", "jy", "31"), 98 | ("jy32", "jy", "32"), 99 | ("jy33", "jy", "33"), 100 | ("jy34", "jy", "34"), 101 | ("jy35", "jy", "35"), 102 | ("jy36", "jy", "36"), 103 | ("jy37", "jy", "37"), 104 | ("jy38", "jy", "38"), 105 | ("jy39", "jy", "39"), 106 | ("jy310", "jy", "310"), 107 | ("jy311", "jy", "311"), 108 | ("jy312", "jy", "312"), 109 | ("jy313", "jy", "313"), 110 | ("jy314", "jy", "314"), 111 | ], 112 | ) 113 | def test__parse_implementation_and_python_version( 114 | python_tag: str, expected_implementation: str, expected_python_version: str 115 | ) -> None: 116 | assert ( 117 | expected_implementation, 118 | expected_python_version, 119 | ) == _parse_implementation_and_python_version(python_tag) 120 | 121 | 122 | @pytest.mark.parametrize( 123 | "url", 124 | [ 125 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-cp310-abi3-macosx_11_0_arm64.whl", 126 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-py3-abi3-any.whl", 127 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-py3-abi3-win_amd64.whl", 128 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-py3-abi3-manylinux1_x86_64.whl", 129 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-py3-abi3-macosx_11_0_arm64.whl", 130 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-py3-none-macosx_11_0_arm64.whl", 131 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-py2.py3-abi3-any.whl", 132 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0+cpu-py2.py3-abi3-win_amd64.whl", 133 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0+cpu-py2.py3-abi3-manylinux1_x86_64.whl", 134 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0+cpu-py2.py3-abi3-macosx_11_0_arm64.whl", 135 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1+cpu-py2.py3-none-any.whl", 136 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1+cpu-py2.py3-none-win_amd64.whl", 137 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0+cpu-py2.py3.jy3.ip311-none-manylinux1_x86_64.whl", 138 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-py2.py3.ip3-none-macosx_11_0_arm64.whl", 139 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-cp36.cp37.jy38-abi3-any.whl", 140 | ], 141 | ) 142 | def test__has_valid_implementation(url: str) -> None: 143 | assert HasValidImplementation()([url]) 144 | 145 | 146 | @pytest.mark.parametrize( 147 | "url", 148 | [ 149 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-ip310-abi3-macosx_11_0_arm64.whl", 150 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-ip3-abi3-any.whl", 151 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-ip3-abi3-win_amd64.whl", 152 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-ip3-abi3-manylinux1_x86_64.whl", 153 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-jy3-abi3-macosx_11_0_arm64.whl", 154 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-jy3-none-macosx_11_0_arm64.whl", 155 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-jy2.jy3-abi3-any.whl", 156 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0+cpu-ip2.ip3-abi3-win_amd64.whl", 157 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0+cpu-ip2.ip3-abi3-manylinux1_x86_64.whl", 158 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0+cpu-ip2.ip3-abi3-macosx_11_0_arm64.whl", 159 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1+cpu-jy2.jy3-none-any.whl", 160 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1+cpu-jy2.jy3-none-win_amd64.whl", 161 | ], 162 | ) 163 | def test__has_valid_implementation_invalid(url: str) -> None: 164 | assert not HasValidImplementation()([url]) 165 | 166 | 167 | @pytest.mark.parametrize("package_name", ["friendly-bard"]) 168 | @pytest.mark.parametrize( 169 | "url", 170 | [ 171 | r"https://files.pythonhosted.org/packages/.../friendly-bard-1.0.0a1-ip310-abi3-macosx_11_0_arm64.whl", 172 | r"https://files.pythonhosted.org/packages/.../Friendly-Bard-1.0.0-ip3-abi3-any.whl", 173 | r"https://files.pythonhosted.org/packages/.../FRIENDLY-BARD-1.0.0-ip3-abi3-win_amd64.whl", 174 | r"https://files.pythonhosted.org/packages/.../friendly.bard-1.0.0a1-ip3-abi3-manylinux1_x86_64.whl", 175 | r"https://files.pythonhosted.org/packages/.../friendly_bard-1.0.0a1-jy3-abi3-macosx_11_0_arm64.whl", 176 | r"https://files.pythonhosted.org/packages/.../friendly--bard-1.0.0a1-jy3-none-macosx_11_0_arm64.whl", 177 | r"https://files.pythonhosted.org/packages/.../FrIeNdLy-._.-bArD-1.0.0a1-jy2.jy3-abi3-any.whl", 178 | ], 179 | ) 180 | @pytest.mark.parametrize( 181 | "excluded_packages", 182 | [ 183 | ["friendly-bard"], 184 | ["Friendly-Bard"], 185 | ["FRIENDLY-BARD"], 186 | ["friendly.bard"], 187 | ["friendly_bard"], 188 | ["friendly--bard"], 189 | ["FrIeNdLy-._.-bArD"], 190 | ], 191 | ) 192 | def test__is_excluded( 193 | package_name: str, url: str, excluded_packages: List[str] 194 | ) -> None: 195 | assert PackageIsNotExcluded(package_name, excluded_packages)([url]) == [] 196 | 197 | 198 | @pytest.mark.parametrize( 199 | ("supported_platform", "urls"), 200 | ( 201 | [ 202 | ( 203 | [], 204 | [ 205 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1+cpu-jy2.jy3-none-win_amd64.whl" 206 | ], 207 | ), 208 | ( 209 | ["windows-x64"], 210 | [ 211 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1+cpu-jy2.jy3-none-win_amd64.whl" 212 | ], 213 | ), 214 | ( 215 | ["windows-arm64"], 216 | [ 217 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-cp310-abi3-win32.whl" 218 | ], 219 | ), 220 | ( 221 | ["linux-x64"], 222 | [ 223 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-ip3-abi3-manylinux1_x86_64.whl" 224 | ], 225 | ), 226 | ( 227 | ["macos-arm64"], 228 | [ 229 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-jy3-none-macosx_11_0_arm64.whl" 230 | ], 231 | ), 232 | ( 233 | ["macos-x64"], 234 | [ 235 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-cp312-cp312-macosx_10_12_x86_64.whl" 236 | ], 237 | ), 238 | ( 239 | ["macos-x64", "linux-x64"], 240 | [ 241 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-cp312-cp312-macosx_10_12_x86_64.whl" 242 | ], 243 | ), 244 | ( 245 | ["windows-arm64", "linux-x64"], 246 | [ 247 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-cp310-abi3-win32.whl" 248 | ], 249 | ), 250 | ] 251 | ), 252 | ) 253 | def test_PlatformIsNotExcluded(supported_platform: List[str], urls: List[str]) -> None: 254 | assert PlatformIsNotExcluded(supported_platform)(urls) == urls 255 | 256 | 257 | @pytest.mark.parametrize( 258 | ("supported_platform", "urls"), 259 | ( 260 | [ 261 | ( 262 | ["macos-x64"], 263 | [ 264 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1+cpu-jy2.jy3-none-win_amd64.whl" 265 | ], 266 | ), 267 | ( 268 | ["linux-x64"], 269 | [ 270 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-cp313-cp313-musllinux_1_2_aarch64.whl" 271 | ], 272 | ), 273 | ] 274 | ), 275 | ) 276 | def test_PlatformIsNotExcluded_platform_excluded( 277 | supported_platform: List[str], urls: List[str] 278 | ) -> None: 279 | assert PlatformIsNotExcluded(supported_platform)(urls) == [] 280 | 281 | 282 | @pytest.mark.parametrize( 283 | ("supported_platform", "urls", "expected_urls"), 284 | ( 285 | [ 286 | ( 287 | ["macos-x64", "linux-x64"], 288 | [ 289 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-cp312-cp312-macosx_10_12_x86_64.whl", 290 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0a1-cp310-abi3-win32.whl", 291 | ], 292 | [ 293 | r"https://files.pythonhosted.org/packages/.../packagename-1.0.0-cp312-cp312-macosx_10_12_x86_64.whl" 294 | ], 295 | ), 296 | ] 297 | ), 298 | ) 299 | def test_PlatformIsNotExcluded_platform_mixed( 300 | supported_platform: List[str], urls: List[str], expected_urls: List[str] 301 | ) -> None: 302 | assert PlatformIsNotExcluded(supported_platform)(urls) == expected_urls 303 | -------------------------------------------------------------------------------- /peeler/wheels/download.py: -------------------------------------------------------------------------------- 1 | # # SPDX-FileCopyrightText: 2025 Maxime Letellier 2 | # 3 | # # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import sys 6 | from abc import ABC, abstractmethod 7 | from functools import reduce 8 | from os import fspath 9 | from pathlib import Path 10 | from subprocess import run 11 | from typing import Dict, Iterable, List, Optional, Protocol, Set, Tuple 12 | 13 | import typer 14 | from click import ClickException 15 | from typer import progressbar 16 | from wheel_filename import ParsedWheelFilename, parse_wheel_filename 17 | 18 | from peeler.utils import ( 19 | normalize_package_name, 20 | parse_blender_supported_platform, 21 | parse_package_platform_tag, 22 | ) 23 | from peeler.uv_utils import find_uv_bin, has_uv 24 | 25 | 26 | def _parse_implementation_and_python_version(python_tag: str) -> Tuple[str, str]: 27 | return python_tag[:2], python_tag[2:] 28 | 29 | 30 | def _wheel_path(destination_directory: Path, wheel_info: ParsedWheelFilename) -> Path: 31 | return destination_directory / str(wheel_info) 32 | 33 | 34 | def _pip_cmd(url: str, destination_directory: Path) -> List[str]: 35 | wheel_info = parse_wheel_filename(url) 36 | 37 | platform = wheel_info.platform_tags[0] 38 | implementation, python_version = _parse_implementation_and_python_version( 39 | wheel_info.python_tags[0] 40 | ) 41 | abi = wheel_info.abi_tags[0] 42 | 43 | _destination_directory = fspath(destination_directory.resolve()) 44 | 45 | cmd = [ 46 | "pip", 47 | "download", 48 | "-d", 49 | _destination_directory, 50 | "--no-deps", 51 | "--only-binary", 52 | ":all:", 53 | "--platform", 54 | platform, 55 | "--abi", 56 | abi, 57 | "--implementation", 58 | implementation, 59 | "--progress-bar", 60 | "off", 61 | url, 62 | ] 63 | 64 | if len(python_version) > 1: 65 | cmd.extend(["--python-version", python_version]) 66 | 67 | return cmd 68 | 69 | 70 | class UrlsFilter(Protocol): 71 | """ 72 | Protocol defining a callable that filters a list of wheel URLs. 73 | 74 | Implementations should return a list of URLs matching a specific criterion. 75 | """ 76 | 77 | def __call__(self, urls: Iterable[str]) -> List[str]: 78 | """ 79 | Filter the given list of URLs. 80 | 81 | :param urls: The list of wheel URLs to filter. 82 | :return: A filtered list of wheel URLs. 83 | """ 84 | ... 85 | 86 | 87 | class HasValidImplementation(UrlsFilter): 88 | """ 89 | Filter wheel URLs to retain only those with Blender compatible implementation tags. 90 | 91 | Valid implementations include `cp` (CPython) and `py` (generic Python). 92 | :param package_name: Optional name of the package, used in warning messages. 93 | """ 94 | 95 | _VALID_IMPLEMENTATIONS = {"cp", "py"} 96 | 97 | def __init__(self, package_name: str | None = None) -> None: 98 | self.package_name = package_name 99 | 100 | def has_valid_implementation(self, url: str) -> bool: 101 | """ 102 | Check if the wheel URL has a valid implementation tag. 103 | 104 | :param url: The wheel URL to check. 105 | :return: True if the implementation is valid, False otherwise. 106 | """ 107 | wheel_info = parse_wheel_filename(url) 108 | 109 | result = any( 110 | _parse_implementation_and_python_version(tag)[0].lower() 111 | in self._VALID_IMPLEMENTATIONS 112 | for tag in wheel_info.python_tags 113 | ) 114 | 115 | return result 116 | 117 | def __call__(self, urls: Iterable[str]) -> List[str]: 118 | """ 119 | Filter out URLs that do not match valid implementation tags. 120 | 121 | :param urls: List of wheel URLs to filter. 122 | :return: List of URLs matching the valid implementation criteria. 123 | """ 124 | 125 | if not urls: 126 | return [] 127 | 128 | urls = list(filter(self.has_valid_implementation, urls)) 129 | 130 | if not urls: 131 | if self.package_name: 132 | msg = f"No suitable implementation found for {self.package_name}, not downloading." 133 | typer.echo(f"Warning: {msg}") 134 | 135 | return urls 136 | 137 | 138 | class IsNotAlreadyDownloaded(UrlsFilter): 139 | """ 140 | Filter wheel URLs to exclude those already downloaded to a given directory. 141 | 142 | :param destination_directory: Directory where wheels are downloaded. 143 | """ 144 | 145 | def __init__(self, destination_directory: Path) -> None: 146 | self.destination_directory = destination_directory 147 | 148 | def _is_downloaded(self, url: str) -> bool: 149 | """ 150 | Check whether the wheel corresponding to the given URL is already downloaded. 151 | 152 | :param url: The wheel URL to check. 153 | :return: True if the wheel is not already downloaded, False otherwise. 154 | """ 155 | wheel_info = parse_wheel_filename(url) 156 | path = _wheel_path(self.destination_directory, wheel_info) 157 | 158 | return not path.is_file() 159 | 160 | def __call__(self, urls: Iterable[str]) -> List[str]: 161 | """ 162 | Filter out wheel URLs that are already downloaded. 163 | 164 | :param urls: Iterable of wheel URLs to filter. 165 | :return: List of URLs not yet downloaded. 166 | """ 167 | return list(filter(self._is_downloaded, urls)) 168 | 169 | 170 | class PackageIsNotExcluded(UrlsFilter): 171 | """Filter out URLs for excluded packages. 172 | 173 | :param package_name: Name of the package to check. 174 | :param excluded_packages: Set of package names to exclude. 175 | """ 176 | 177 | def __init__(self, package_name: str, excluded_packages: List[str]) -> None: 178 | self.package_name = normalize_package_name(package_name) 179 | self.excluded_packages = { 180 | normalize_package_name(package_name) for package_name in excluded_packages 181 | } 182 | 183 | def __call__(self, urls: Iterable[str]) -> List[str]: 184 | """Return URLs if the package is not excluded. 185 | 186 | :param urls: List of wheel URLs to filter. 187 | :return: List of URLs if the package is not excluded, else an empty list. 188 | """ 189 | 190 | if self.package_name in self.excluded_packages: 191 | msg = f"Excluded package `{self.package_name}`, not downloading." 192 | typer.echo(f"Info: {msg}") 193 | return [] 194 | 195 | return list(urls) 196 | 197 | 198 | class PlatformIsNotExcluded(UrlsFilter): 199 | """Filter out urls not supported by the given platforms. 200 | 201 | The platform have to be in the form of blender manifest: 202 | 203 | `windows-x64` 204 | `windows-arm64` 205 | `linux-x64` 206 | `macos-arm64` 207 | `macos-x64` 208 | 209 | :param platforms: List of supported platforms. 210 | """ 211 | 212 | def __init__(self, platforms: List[str]) -> None: 213 | self.platforms_arch = { 214 | parse_blender_supported_platform(platform) for platform in platforms 215 | } 216 | 217 | def _is_supported_platform(self, url: str) -> bool: 218 | wheel_info = parse_wheel_filename(url) 219 | 220 | for platform_tag in wheel_info.platform_tags: 221 | if platform_tag == "any": 222 | return True 223 | 224 | platform, version, arch = parse_package_platform_tag(platform_tag) 225 | if (platform, arch) in self.platforms_arch: 226 | return True 227 | 228 | return False 229 | 230 | def __call__(self, urls: Iterable[str]) -> List[str]: 231 | """Return URLs corresponding to the given platforms. 232 | 233 | Return all urls if no platform where given. 234 | 235 | :param urls: List of wheel URLs to filter. 236 | :return: List of filtered urls 237 | """ 238 | if not urls: 239 | return [] 240 | if not self.platforms_arch: 241 | return list(urls) 242 | 243 | package_names = {parse_wheel_filename(url).project for url in urls} 244 | 245 | urls = list(filter(self._is_supported_platform, urls)) 246 | 247 | if not urls: 248 | msg = f"No suitable platform found for {' '.join(package_names)}, not downloading." 249 | typer.echo(f"Warning: {msg}") 250 | 251 | return urls 252 | 253 | 254 | class AbstractWheelsDownloader(ABC): 255 | """ 256 | Abstract base class defining the interface for wheel downloaders. 257 | 258 | Subclasses must implement the `download_wheel` method to handle the 259 | download of Python wheels to a specified directory. 260 | """ 261 | 262 | @abstractmethod 263 | def download_wheel(self, url: str, destination_directory: Path) -> Path: 264 | """ 265 | Download a wheel file from the given URL and stores it in the destination directory. 266 | 267 | :param url: The URL pointing to the wheel file to download. 268 | :param destination_directory: The directory where the wheel should be saved. 269 | :return: The full path to the downloaded wheel file. 270 | """ 271 | raise NotImplementedError 272 | 273 | 274 | class UVPipWheelsDownloader(AbstractWheelsDownloader): 275 | """ 276 | Wheel downloader that uses `uv` to download wheels. 277 | 278 | This implementation runs `pip` through uv commands to download the specified 279 | wheel file to a given destination directory. 280 | """ 281 | 282 | def download_wheel(self, url: str, destination_directory: Path) -> Path: 283 | """ 284 | Download a wheel file from the given URL using `uv` and stores it in the destination directory. 285 | 286 | :param url: The URL pointing to the wheel file to download. 287 | :param destination_directory: The directory where the wheel should be saved. 288 | :return: The full path to the downloaded wheel file. 289 | :raises ClickException: If the wheel file is not found after download. 290 | """ 291 | 292 | uv_bin = find_uv_bin() 293 | cmd = [ 294 | uv_bin, 295 | "--isolated", 296 | "tool", 297 | "run", 298 | "--no-config", 299 | "--no-python-downloads", 300 | "--no-build", 301 | *_pip_cmd(url=url, destination_directory=destination_directory), 302 | ] 303 | 304 | result = run(cmd, capture_output=True, text=True) 305 | 306 | wheel_info = parse_wheel_filename(url) 307 | path = _wheel_path(destination_directory, wheel_info) 308 | 309 | if not path.is_file(): 310 | stderr = result.stderr 311 | platforms = wheel_info.platform_tags 312 | msg = f"Error when downloading wheel for package `{wheel_info.project}` for platform(s) `{' '.join(platforms)}`" 313 | raise ClickException(f"{msg}{stderr}") 314 | 315 | return path 316 | 317 | 318 | class PipWheelsDownloader(AbstractWheelsDownloader): 319 | """ 320 | Wheel downloader that uses the standard pip module to download wheels. 321 | 322 | This implementation constructs and runs a pip command to download the specified 323 | wheel file to a given destination directory. 324 | """ 325 | 326 | def download_wheel(self, url: str, destination_directory: Path) -> Path: 327 | """ 328 | Download a wheel file from the given URL using pip and stores it in the destination directory. 329 | 330 | :param url: The URL pointing to the wheel file to download. 331 | :param destination_directory: The directory where the wheel should be saved. 332 | :return: The full path to the downloaded wheel file. 333 | :raises ClickException: If the wheel file is not found after download. 334 | """ 335 | 336 | cmd = [sys.executable, "-m", *_pip_cmd(url, destination_directory)] 337 | 338 | result = run(cmd, capture_output=True, text=True) 339 | 340 | wheel_info = parse_wheel_filename(url) 341 | path = _wheel_path(destination_directory, wheel_info) 342 | 343 | if not path.is_file(): 344 | stderr = result.stderr 345 | platforms = wheel_info.platform_tags 346 | msg = f"Error when downloading wheel for package `{wheel_info.project}` for platform(s) `{' '.join(platforms)}`" 347 | raise ClickException(f"{msg}{stderr}") 348 | 349 | return path 350 | 351 | 352 | class WheelsDownloaderCreator: 353 | """ 354 | Factory class for selecting an appropriate wheel download strategy. 355 | 356 | Depending on the environment, this class decides whether to use the `uv`-based 357 | wheel downloader or the standard pip-based downloader. 358 | """ 359 | 360 | def get_wheel_download_strategy(self) -> AbstractWheelsDownloader: 361 | """ 362 | Return the appropriate wheel download strategy based on the current environment. 363 | 364 | If `uv` is available, returns a `UVPipWheelsDownloader` instance. 365 | Otherwise, returns a `PipWheelsDownloader` instance. 366 | 367 | :return: The selected wheel download strategy. 368 | """ 369 | if has_uv(): 370 | return UVPipWheelsDownloader() 371 | else: 372 | return PipWheelsDownloader() 373 | 374 | 375 | def download_wheels( 376 | wheels_directory: Path, 377 | urls: Dict[str, List[str]], 378 | *, 379 | excluded_packages: Optional[List[str]] = None, 380 | supported_platforms: Optional[List[str]] = None, 381 | ) -> List[Path]: 382 | """Download the wheels from urls with pip download into wheels_directory. 383 | 384 | :param wheels_directory: The directory to download wheels into 385 | :param urls: A Dict with package name as key and a list of package urls as values. 386 | :param excluded_packages: packages excluded from being downloaded 387 | :param supported_platforms: only download wheels for theses platforms 388 | :return: the list of the downloaded wheels path 389 | """ 390 | wheels_directory.mkdir(parents=True, exist_ok=True) 391 | 392 | wheels_paths: List[Path] = [] 393 | 394 | wheel_downloader = WheelsDownloaderCreator().get_wheel_download_strategy() 395 | 396 | _max_package_name_len = max( 397 | (len(package_name) for package_name in urls.keys()), default=0 398 | ) 399 | 400 | for package_name, package_urls in urls.items(): 401 | filters: Set[UrlsFilter] = {HasValidImplementation(package_name)} 402 | 403 | if excluded_packages: 404 | filters.add(PackageIsNotExcluded(package_name, excluded_packages)) 405 | 406 | if supported_platforms: 407 | filters.add(PlatformIsNotExcluded(supported_platforms)) 408 | 409 | package_urls = reduce(lambda acc, filter_: filter_(acc), filters, package_urls) 410 | 411 | if not package_urls: 412 | continue 413 | 414 | with progressbar( 415 | package_urls, 416 | label=package_name.ljust(_max_package_name_len), 417 | color=True, 418 | width=_max_package_name_len, 419 | ) as _package_urls: 420 | for url in _package_urls: 421 | destination_path = _wheel_path( 422 | wheels_directory, parse_wheel_filename(url) 423 | ) 424 | if not destination_path.exists(): 425 | wheel_downloader.download_wheel(url, wheels_directory) 426 | wheels_paths.append(destination_path) 427 | 428 | return wheels_paths 429 | -------------------------------------------------------------------------------- /peeler/data/blender_manifest_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "schema_version": { 6 | "type": "string", 7 | "description": "The schema version of the manifest file for a Blender extension", 8 | "default": "1.0.0" 9 | }, 10 | "id": { 11 | "type": "string", 12 | "description": "The identifier of your extension. Must be unique." 13 | }, 14 | "version": { 15 | "type": "string", 16 | "description": "The version of your addon. Must follow the semantic versioning scheme.", 17 | "default": "1.0.0" 18 | }, 19 | "name": { 20 | "type": "string", 21 | "description": "The name of your extension" 22 | }, 23 | "tagline": { 24 | "type": "string", 25 | "description": "A short description of your extension", 26 | "maxLength": 64 27 | }, 28 | "maintainer": { 29 | "type": "string", 30 | "description": "The name/pseudonym of the person who maintains this extension" 31 | }, 32 | "type": { 33 | "type": "string", 34 | "description": "What kind of extension you're contributing", 35 | "default": "add-on", 36 | "enum": ["add-on", "theme"] 37 | }, 38 | "website": { 39 | "type": "string", 40 | "description": "Optional link to documentation, support, source files, etc" 41 | }, 42 | "tags": { 43 | "type": "array", 44 | "description": "A list of tags for your extension", 45 | "items": { 46 | "type": "string", 47 | "enum": [ 48 | "3D View", 49 | "Add Curve", 50 | "Add Mesh", 51 | "Animation", 52 | "Bake", 53 | "Camera", 54 | "Compositing", 55 | "Development", 56 | "Game Engine", 57 | "Geometry Nodes", 58 | "Grease Pencil", 59 | "Import-Export", 60 | "Lighting", 61 | "Material", 62 | "Modeling", 63 | "Mesh", 64 | "Node", 65 | "Object", 66 | "Paint", 67 | "Pipeline", 68 | "Physics", 69 | "Render", 70 | "Rigging", 71 | "Scene", 72 | "Sculpt", 73 | "Sequencer", 74 | "System", 75 | "Text Editor", 76 | "Tracking", 77 | "User Interface", 78 | "UV", 79 | "Dark", 80 | "Light", 81 | "Colorful", 82 | "Inspired By", 83 | "Print", 84 | "Accessibility", 85 | "High Contrast" 86 | ] 87 | }, 88 | "minItems": 1, 89 | "uniqueItems": true 90 | }, 91 | "blender_version_min": { 92 | "type": "string", 93 | "description": "The oldest Blender version your extension is compatible with", 94 | "pattern": "\\d+\\.\\d+\\.\\d+" 95 | }, 96 | "blender_version_max": { 97 | "type": "string", 98 | "description": "The newest Blender version your extension is compatible with (in case your extension doesn't work in newer Blender versions)", 99 | "pattern": "\\d+\\.\\d+\\.\\d+" 100 | }, 101 | "license": { 102 | "type": "array", 103 | "description": "A list of licenses that apply for your extension. Must conform to https://spdx.org/licenses/ (use \"SPDX:\" prefix)", 104 | "items": { 105 | "type": "string", 106 | "enum": [ 107 | "SPDX:0BSD", 108 | "SPDX:3D-Slicer-1.0", 109 | "SPDX:AAL", 110 | "SPDX:Abstyles", 111 | "SPDX:AdaCore-doc", 112 | "SPDX:Adobe-2006", 113 | "SPDX:Adobe-Display-PostScript", 114 | "SPDX:Adobe-Glyph", 115 | "SPDX:Adobe-Utopia", 116 | "SPDX:ADSL", 117 | "SPDX:AFL-1.1", 118 | "SPDX:AFL-1.2", 119 | "SPDX:AFL-2.0", 120 | "SPDX:AFL-2.1", 121 | "SPDX:AFL-3.0", 122 | "SPDX:Afmparse", 123 | "SPDX:AGPL-1.0", 124 | "SPDX:AGPL-1.0-only", 125 | "SPDX:AGPL-1.0-or-later", 126 | "SPDX:AGPL-3.0", 127 | "SPDX:AGPL-3.0-only", 128 | "SPDX:AGPL-3.0-or-later", 129 | "SPDX:Aladdin", 130 | "SPDX:AMD-newlib", 131 | "SPDX:AMDPLPA", 132 | "SPDX:AML", 133 | "SPDX:AML-glslang", 134 | "SPDX:AMPAS", 135 | "SPDX:ANTLR-PD", 136 | "SPDX:ANTLR-PD-fallback", 137 | "SPDX:any-OSI", 138 | "SPDX:Apache-1.0", 139 | "SPDX:Apache-1.1", 140 | "SPDX:Apache-2.0", 141 | "SPDX:APAFML", 142 | "SPDX:APL-1.0", 143 | "SPDX:App-s2p", 144 | "SPDX:APSL-1.0", 145 | "SPDX:APSL-1.1", 146 | "SPDX:APSL-1.2", 147 | "SPDX:APSL-2.0", 148 | "SPDX:Arphic-1999", 149 | "SPDX:Artistic-1.0", 150 | "SPDX:Artistic-1.0-cl8", 151 | "SPDX:Artistic-1.0-Perl", 152 | "SPDX:Artistic-2.0", 153 | "SPDX:ASWF-Digital-Assets-1.0", 154 | "SPDX:ASWF-Digital-Assets-1.1", 155 | "SPDX:Baekmuk", 156 | "SPDX:Bahyph", 157 | "SPDX:Barr", 158 | "SPDX:bcrypt-Solar-Designer", 159 | "SPDX:Beerware", 160 | "SPDX:Bitstream-Charter", 161 | "SPDX:Bitstream-Vera", 162 | "SPDX:BitTorrent-1.0", 163 | "SPDX:BitTorrent-1.1", 164 | "SPDX:blessing", 165 | "SPDX:BlueOak-1.0.0", 166 | "SPDX:Boehm-GC", 167 | "SPDX:Borceux", 168 | "SPDX:Brian-Gladman-2-Clause", 169 | "SPDX:Brian-Gladman-3-Clause", 170 | "SPDX:BSD-1-Clause", 171 | "SPDX:BSD-2-Clause", 172 | "SPDX:BSD-2-Clause-Darwin", 173 | "SPDX:BSD-2-Clause-first-lines", 174 | "SPDX:BSD-2-Clause-FreeBSD", 175 | "SPDX:BSD-2-Clause-NetBSD", 176 | "SPDX:BSD-2-Clause-Patent", 177 | "SPDX:BSD-2-Clause-Views", 178 | "SPDX:BSD-3-Clause", 179 | "SPDX:BSD-3-Clause-acpica", 180 | "SPDX:BSD-3-Clause-Attribution", 181 | "SPDX:BSD-3-Clause-Clear", 182 | "SPDX:BSD-3-Clause-flex", 183 | "SPDX:BSD-3-Clause-HP", 184 | "SPDX:BSD-3-Clause-LBNL", 185 | "SPDX:BSD-3-Clause-Modification", 186 | "SPDX:BSD-3-Clause-No-Military-License", 187 | "SPDX:BSD-3-Clause-No-Nuclear-License", 188 | "SPDX:BSD-3-Clause-No-Nuclear-License-2014", 189 | "SPDX:BSD-3-Clause-No-Nuclear-Warranty", 190 | "SPDX:BSD-3-Clause-Open-MPI", 191 | "SPDX:BSD-3-Clause-Sun", 192 | "SPDX:BSD-4-Clause", 193 | "SPDX:BSD-4-Clause-Shortened", 194 | "SPDX:BSD-4-Clause-UC", 195 | "SPDX:BSD-4.3RENO", 196 | "SPDX:BSD-4.3TAHOE", 197 | "SPDX:BSD-Advertising-Acknowledgement", 198 | "SPDX:BSD-Attribution-HPND-disclaimer", 199 | "SPDX:BSD-Inferno-Nettverk", 200 | "SPDX:BSD-Protection", 201 | "SPDX:BSD-Source-beginning-file", 202 | "SPDX:BSD-Source-Code", 203 | "SPDX:BSD-Systemics", 204 | "SPDX:BSD-Systemics-W3Works", 205 | "SPDX:BSL-1.0", 206 | "SPDX:BUSL-1.1", 207 | "SPDX:bzip2-1.0.5", 208 | "SPDX:bzip2-1.0.6", 209 | "SPDX:C-UDA-1.0", 210 | "SPDX:CAL-1.0", 211 | "SPDX:CAL-1.0-Combined-Work-Exception", 212 | "SPDX:Caldera", 213 | "SPDX:Caldera-no-preamble", 214 | "SPDX:Catharon", 215 | "SPDX:CATOSL-1.1", 216 | "SPDX:CC-BY-1.0", 217 | "SPDX:CC-BY-2.0", 218 | "SPDX:CC-BY-2.5", 219 | "SPDX:CC-BY-2.5-AU", 220 | "SPDX:CC-BY-3.0", 221 | "SPDX:CC-BY-3.0-AT", 222 | "SPDX:CC-BY-3.0-AU", 223 | "SPDX:CC-BY-3.0-DE", 224 | "SPDX:CC-BY-3.0-IGO", 225 | "SPDX:CC-BY-3.0-NL", 226 | "SPDX:CC-BY-3.0-US", 227 | "SPDX:CC-BY-4.0", 228 | "SPDX:CC-BY-NC-1.0", 229 | "SPDX:CC-BY-NC-2.0", 230 | "SPDX:CC-BY-NC-2.5", 231 | "SPDX:CC-BY-NC-3.0", 232 | "SPDX:CC-BY-NC-3.0-DE", 233 | "SPDX:CC-BY-NC-4.0", 234 | "SPDX:CC-BY-NC-ND-1.0", 235 | "SPDX:CC-BY-NC-ND-2.0", 236 | "SPDX:CC-BY-NC-ND-2.5", 237 | "SPDX:CC-BY-NC-ND-3.0", 238 | "SPDX:CC-BY-NC-ND-3.0-DE", 239 | "SPDX:CC-BY-NC-ND-3.0-IGO", 240 | "SPDX:CC-BY-NC-ND-4.0", 241 | "SPDX:CC-BY-NC-SA-1.0", 242 | "SPDX:CC-BY-NC-SA-2.0", 243 | "SPDX:CC-BY-NC-SA-2.0-DE", 244 | "SPDX:CC-BY-NC-SA-2.0-FR", 245 | "SPDX:CC-BY-NC-SA-2.0-UK", 246 | "SPDX:CC-BY-NC-SA-2.5", 247 | "SPDX:CC-BY-NC-SA-3.0", 248 | "SPDX:CC-BY-NC-SA-3.0-DE", 249 | "SPDX:CC-BY-NC-SA-3.0-IGO", 250 | "SPDX:CC-BY-NC-SA-4.0", 251 | "SPDX:CC-BY-ND-1.0", 252 | "SPDX:CC-BY-ND-2.0", 253 | "SPDX:CC-BY-ND-2.5", 254 | "SPDX:CC-BY-ND-3.0", 255 | "SPDX:CC-BY-ND-3.0-DE", 256 | "SPDX:CC-BY-ND-4.0", 257 | "SPDX:CC-BY-SA-1.0", 258 | "SPDX:CC-BY-SA-2.0", 259 | "SPDX:CC-BY-SA-2.0-UK", 260 | "SPDX:CC-BY-SA-2.1-JP", 261 | "SPDX:CC-BY-SA-2.5", 262 | "SPDX:CC-BY-SA-3.0", 263 | "SPDX:CC-BY-SA-3.0-AT", 264 | "SPDX:CC-BY-SA-3.0-DE", 265 | "SPDX:CC-BY-SA-3.0-IGO", 266 | "SPDX:CC-BY-SA-4.0", 267 | "SPDX:CC-PDDC", 268 | "SPDX:CC0-1.0", 269 | "SPDX:CDDL-1.0", 270 | "SPDX:CDDL-1.1", 271 | "SPDX:CDL-1.0", 272 | "SPDX:CDLA-Permissive-1.0", 273 | "SPDX:CDLA-Permissive-2.0", 274 | "SPDX:CDLA-Sharing-1.0", 275 | "SPDX:CECILL-1.0", 276 | "SPDX:CECILL-1.1", 277 | "SPDX:CECILL-2.0", 278 | "SPDX:CECILL-2.1", 279 | "SPDX:CECILL-B", 280 | "SPDX:CECILL-C", 281 | "SPDX:CERN-OHL-1.1", 282 | "SPDX:CERN-OHL-1.2", 283 | "SPDX:CERN-OHL-P-2.0", 284 | "SPDX:CERN-OHL-S-2.0", 285 | "SPDX:CERN-OHL-W-2.0", 286 | "SPDX:CFITSIO", 287 | "SPDX:check-cvs", 288 | "SPDX:checkmk", 289 | "SPDX:ClArtistic", 290 | "SPDX:Clips", 291 | "SPDX:CMU-Mach", 292 | "SPDX:CMU-Mach-nodoc", 293 | "SPDX:CNRI-Jython", 294 | "SPDX:CNRI-Python", 295 | "SPDX:CNRI-Python-GPL-Compatible", 296 | "SPDX:COIL-1.0", 297 | "SPDX:Community-Spec-1.0", 298 | "SPDX:Condor-1.1", 299 | "SPDX:copyleft-next-0.3.0", 300 | "SPDX:copyleft-next-0.3.1", 301 | "SPDX:Cornell-Lossless-JPEG", 302 | "SPDX:CPAL-1.0", 303 | "SPDX:CPL-1.0", 304 | "SPDX:CPOL-1.02", 305 | "SPDX:Cronyx", 306 | "SPDX:Crossword", 307 | "SPDX:CrystalStacker", 308 | "SPDX:CUA-OPL-1.0", 309 | "SPDX:Cube", 310 | "SPDX:curl", 311 | "SPDX:cve-tou", 312 | "SPDX:D-FSL-1.0", 313 | "SPDX:DEC-3-Clause", 314 | "SPDX:diffmark", 315 | "SPDX:DL-DE-BY-2.0", 316 | "SPDX:DL-DE-ZERO-2.0", 317 | "SPDX:DOC", 318 | "SPDX:Dotseqn", 319 | "SPDX:DRL-1.0", 320 | "SPDX:DRL-1.1", 321 | "SPDX:DSDP", 322 | "SPDX:dtoa", 323 | "SPDX:dvipdfm", 324 | "SPDX:ECL-1.0", 325 | "SPDX:ECL-2.0", 326 | "SPDX:eCos-2.0", 327 | "SPDX:EFL-1.0", 328 | "SPDX:EFL-2.0", 329 | "SPDX:eGenix", 330 | "SPDX:Elastic-2.0", 331 | "SPDX:Entessa", 332 | "SPDX:EPICS", 333 | "SPDX:EPL-1.0", 334 | "SPDX:EPL-2.0", 335 | "SPDX:ErlPL-1.1", 336 | "SPDX:etalab-2.0", 337 | "SPDX:EUDatagrid", 338 | "SPDX:EUPL-1.0", 339 | "SPDX:EUPL-1.1", 340 | "SPDX:EUPL-1.2", 341 | "SPDX:Eurosym", 342 | "SPDX:Fair", 343 | "SPDX:FBM", 344 | "SPDX:FDK-AAC", 345 | "SPDX:Ferguson-Twofish", 346 | "SPDX:Frameworx-1.0", 347 | "SPDX:FreeBSD-DOC", 348 | "SPDX:FreeImage", 349 | "SPDX:FSFAP", 350 | "SPDX:FSFAP-no-warranty-disclaimer", 351 | "SPDX:FSFUL", 352 | "SPDX:FSFULLR", 353 | "SPDX:FSFULLRWD", 354 | "SPDX:FTL", 355 | "SPDX:Furuseth", 356 | "SPDX:fwlw", 357 | "SPDX:GCR-docs", 358 | "SPDX:GD", 359 | "SPDX:GFDL-1.1", 360 | "SPDX:GFDL-1.1-invariants-only", 361 | "SPDX:GFDL-1.1-invariants-or-later", 362 | "SPDX:GFDL-1.1-no-invariants-only", 363 | "SPDX:GFDL-1.1-no-invariants-or-later", 364 | "SPDX:GFDL-1.1-only", 365 | "SPDX:GFDL-1.1-or-later", 366 | "SPDX:GFDL-1.2", 367 | "SPDX:GFDL-1.2-invariants-only", 368 | "SPDX:GFDL-1.2-invariants-or-later", 369 | "SPDX:GFDL-1.2-no-invariants-only", 370 | "SPDX:GFDL-1.2-no-invariants-or-later", 371 | "SPDX:GFDL-1.2-only", 372 | "SPDX:GFDL-1.2-or-later", 373 | "SPDX:GFDL-1.3", 374 | "SPDX:GFDL-1.3-invariants-only", 375 | "SPDX:GFDL-1.3-invariants-or-later", 376 | "SPDX:GFDL-1.3-no-invariants-only", 377 | "SPDX:GFDL-1.3-no-invariants-or-later", 378 | "SPDX:GFDL-1.3-only", 379 | "SPDX:GFDL-1.3-or-later", 380 | "SPDX:Giftware", 381 | "SPDX:GL2PS", 382 | "SPDX:Glide", 383 | "SPDX:Glulxe", 384 | "SPDX:GLWTPL", 385 | "SPDX:gnuplot", 386 | "SPDX:GPL-1.0", 387 | "SPDX:GPL-1.0+", 388 | "SPDX:GPL-1.0-only", 389 | "SPDX:GPL-1.0-or-later", 390 | "SPDX:GPL-2.0", 391 | "SPDX:GPL-2.0+", 392 | "SPDX:GPL-2.0-only", 393 | "SPDX:GPL-2.0-or-later", 394 | "SPDX:GPL-2.0-with-autoconf-exception", 395 | "SPDX:GPL-2.0-with-bison-exception", 396 | "SPDX:GPL-2.0-with-classpath-exception", 397 | "SPDX:GPL-2.0-with-font-exception", 398 | "SPDX:GPL-2.0-with-GCC-exception", 399 | "SPDX:GPL-3.0", 400 | "SPDX:GPL-3.0+", 401 | "SPDX:GPL-3.0-only", 402 | "SPDX:GPL-3.0-or-later", 403 | "SPDX:GPL-3.0-with-autoconf-exception", 404 | "SPDX:GPL-3.0-with-GCC-exception", 405 | "SPDX:Graphics-Gems", 406 | "SPDX:gSOAP-1.3b", 407 | "SPDX:gtkbook", 408 | "SPDX:Gutmann", 409 | "SPDX:HaskellReport", 410 | "SPDX:hdparm", 411 | "SPDX:Hippocratic-2.1", 412 | "SPDX:HP-1986", 413 | "SPDX:HP-1989", 414 | "SPDX:HPND", 415 | "SPDX:HPND-DEC", 416 | "SPDX:HPND-doc", 417 | "SPDX:HPND-doc-sell", 418 | "SPDX:HPND-export-US", 419 | "SPDX:HPND-export-US-acknowledgement", 420 | "SPDX:HPND-export-US-modify", 421 | "SPDX:HPND-export2-US", 422 | "SPDX:HPND-Fenneberg-Livingston", 423 | "SPDX:HPND-INRIA-IMAG", 424 | "SPDX:HPND-Intel", 425 | "SPDX:HPND-Kevlin-Henney", 426 | "SPDX:HPND-Markus-Kuhn", 427 | "SPDX:HPND-merchantability-variant", 428 | "SPDX:HPND-MIT-disclaimer", 429 | "SPDX:HPND-Pbmplus", 430 | "SPDX:HPND-sell-MIT-disclaimer-xserver", 431 | "SPDX:HPND-sell-regexpr", 432 | "SPDX:HPND-sell-variant", 433 | "SPDX:HPND-sell-variant-MIT-disclaimer", 434 | "SPDX:HPND-sell-variant-MIT-disclaimer-rev", 435 | "SPDX:HPND-UC", 436 | "SPDX:HPND-UC-export-US", 437 | "SPDX:HTMLTIDY", 438 | "SPDX:IBM-pibs", 439 | "SPDX:ICU", 440 | "SPDX:IEC-Code-Components-EULA", 441 | "SPDX:IJG", 442 | "SPDX:IJG-short", 443 | "SPDX:ImageMagick", 444 | "SPDX:iMatix", 445 | "SPDX:Imlib2", 446 | "SPDX:Info-ZIP", 447 | "SPDX:Inner-Net-2.0", 448 | "SPDX:Intel", 449 | "SPDX:Intel-ACPI", 450 | "SPDX:Interbase-1.0", 451 | "SPDX:IPA", 452 | "SPDX:IPL-1.0", 453 | "SPDX:ISC", 454 | "SPDX:ISC-Veillard", 455 | "SPDX:Jam", 456 | "SPDX:JasPer-2.0", 457 | "SPDX:JPL-image", 458 | "SPDX:JPNIC", 459 | "SPDX:JSON", 460 | "SPDX:Kastrup", 461 | "SPDX:Kazlib", 462 | "SPDX:Knuth-CTAN", 463 | "SPDX:LAL-1.2", 464 | "SPDX:LAL-1.3", 465 | "SPDX:Latex2e", 466 | "SPDX:Latex2e-translated-notice", 467 | "SPDX:Leptonica", 468 | "SPDX:LGPL-2.0", 469 | "SPDX:LGPL-2.0+", 470 | "SPDX:LGPL-2.0-only", 471 | "SPDX:LGPL-2.0-or-later", 472 | "SPDX:LGPL-2.1", 473 | "SPDX:LGPL-2.1+", 474 | "SPDX:LGPL-2.1-only", 475 | "SPDX:LGPL-2.1-or-later", 476 | "SPDX:LGPL-3.0", 477 | "SPDX:LGPL-3.0+", 478 | "SPDX:LGPL-3.0-only", 479 | "SPDX:LGPL-3.0-or-later", 480 | "SPDX:LGPLLR", 481 | "SPDX:Libpng", 482 | "SPDX:libpng-2.0", 483 | "SPDX:libselinux-1.0", 484 | "SPDX:libtiff", 485 | "SPDX:libutil-David-Nugent", 486 | "SPDX:LiLiQ-P-1.1", 487 | "SPDX:LiLiQ-R-1.1", 488 | "SPDX:LiLiQ-Rplus-1.1", 489 | "SPDX:Linux-man-pages-1-para", 490 | "SPDX:Linux-man-pages-copyleft", 491 | "SPDX:Linux-man-pages-copyleft-2-para", 492 | "SPDX:Linux-man-pages-copyleft-var", 493 | "SPDX:Linux-OpenIB", 494 | "SPDX:LOOP", 495 | "SPDX:LPD-document", 496 | "SPDX:LPL-1.0", 497 | "SPDX:LPL-1.02", 498 | "SPDX:LPPL-1.0", 499 | "SPDX:LPPL-1.1", 500 | "SPDX:LPPL-1.2", 501 | "SPDX:LPPL-1.3a", 502 | "SPDX:LPPL-1.3c", 503 | "SPDX:lsof", 504 | "SPDX:Lucida-Bitmap-Fonts", 505 | "SPDX:LZMA-SDK-9.11-to-9.20", 506 | "SPDX:LZMA-SDK-9.22", 507 | "SPDX:Mackerras-3-Clause", 508 | "SPDX:Mackerras-3-Clause-acknowledgment", 509 | "SPDX:magaz", 510 | "SPDX:mailprio", 511 | "SPDX:MakeIndex", 512 | "SPDX:Martin-Birgmeier", 513 | "SPDX:McPhee-slideshow", 514 | "SPDX:metamail", 515 | "SPDX:Minpack", 516 | "SPDX:MirOS", 517 | "SPDX:MIT", 518 | "SPDX:MIT-0", 519 | "SPDX:MIT-advertising", 520 | "SPDX:MIT-CMU", 521 | "SPDX:MIT-enna", 522 | "SPDX:MIT-feh", 523 | "SPDX:MIT-Festival", 524 | "SPDX:MIT-Khronos-old", 525 | "SPDX:MIT-Modern-Variant", 526 | "SPDX:MIT-open-group", 527 | "SPDX:MIT-testregex", 528 | "SPDX:MIT-Wu", 529 | "SPDX:MITNFA", 530 | "SPDX:MMIXware", 531 | "SPDX:Motosoto", 532 | "SPDX:MPEG-SSG", 533 | "SPDX:mpi-permissive", 534 | "SPDX:mpich2", 535 | "SPDX:MPL-1.0", 536 | "SPDX:MPL-1.1", 537 | "SPDX:MPL-2.0", 538 | "SPDX:MPL-2.0-no-copyleft-exception", 539 | "SPDX:mplus", 540 | "SPDX:MS-LPL", 541 | "SPDX:MS-PL", 542 | "SPDX:MS-RL", 543 | "SPDX:MTLL", 544 | "SPDX:MulanPSL-1.0", 545 | "SPDX:MulanPSL-2.0", 546 | "SPDX:Multics", 547 | "SPDX:Mup", 548 | "SPDX:NAIST-2003", 549 | "SPDX:NASA-1.3", 550 | "SPDX:Naumen", 551 | "SPDX:NBPL-1.0", 552 | "SPDX:NCBI-PD", 553 | "SPDX:NCGL-UK-2.0", 554 | "SPDX:NCL", 555 | "SPDX:NCSA", 556 | "SPDX:Net-SNMP", 557 | "SPDX:NetCDF", 558 | "SPDX:Newsletr", 559 | "SPDX:NGPL", 560 | "SPDX:NICTA-1.0", 561 | "SPDX:NIST-PD", 562 | "SPDX:NIST-PD-fallback", 563 | "SPDX:NIST-Software", 564 | "SPDX:NLOD-1.0", 565 | "SPDX:NLOD-2.0", 566 | "SPDX:NLPL", 567 | "SPDX:Nokia", 568 | "SPDX:NOSL", 569 | "SPDX:Noweb", 570 | "SPDX:NPL-1.0", 571 | "SPDX:NPL-1.1", 572 | "SPDX:NPOSL-3.0", 573 | "SPDX:NRL", 574 | "SPDX:NTP", 575 | "SPDX:NTP-0", 576 | "SPDX:Nunit", 577 | "SPDX:O-UDA-1.0", 578 | "SPDX:OAR", 579 | "SPDX:OCCT-PL", 580 | "SPDX:OCLC-2.0", 581 | "SPDX:ODbL-1.0", 582 | "SPDX:ODC-By-1.0", 583 | "SPDX:OFFIS", 584 | "SPDX:OFL-1.0", 585 | "SPDX:OFL-1.0-no-RFN", 586 | "SPDX:OFL-1.0-RFN", 587 | "SPDX:OFL-1.1", 588 | "SPDX:OFL-1.1-no-RFN", 589 | "SPDX:OFL-1.1-RFN", 590 | "SPDX:OGC-1.0", 591 | "SPDX:OGDL-Taiwan-1.0", 592 | "SPDX:OGL-Canada-2.0", 593 | "SPDX:OGL-UK-1.0", 594 | "SPDX:OGL-UK-2.0", 595 | "SPDX:OGL-UK-3.0", 596 | "SPDX:OGTSL", 597 | "SPDX:OLDAP-1.1", 598 | "SPDX:OLDAP-1.2", 599 | "SPDX:OLDAP-1.3", 600 | "SPDX:OLDAP-1.4", 601 | "SPDX:OLDAP-2.0", 602 | "SPDX:OLDAP-2.0.1", 603 | "SPDX:OLDAP-2.1", 604 | "SPDX:OLDAP-2.2", 605 | "SPDX:OLDAP-2.2.1", 606 | "SPDX:OLDAP-2.2.2", 607 | "SPDX:OLDAP-2.3", 608 | "SPDX:OLDAP-2.4", 609 | "SPDX:OLDAP-2.5", 610 | "SPDX:OLDAP-2.6", 611 | "SPDX:OLDAP-2.7", 612 | "SPDX:OLDAP-2.8", 613 | "SPDX:OLFL-1.3", 614 | "SPDX:OML", 615 | "SPDX:OpenPBS-2.3", 616 | "SPDX:OpenSSL", 617 | "SPDX:OpenSSL-standalone", 618 | "SPDX:OpenVision", 619 | "SPDX:OPL-1.0", 620 | "SPDX:OPL-UK-3.0", 621 | "SPDX:OPUBL-1.0", 622 | "SPDX:OSET-PL-2.1", 623 | "SPDX:OSL-1.0", 624 | "SPDX:OSL-1.1", 625 | "SPDX:OSL-2.0", 626 | "SPDX:OSL-2.1", 627 | "SPDX:OSL-3.0", 628 | "SPDX:PADL", 629 | "SPDX:Parity-6.0.0", 630 | "SPDX:Parity-7.0.0", 631 | "SPDX:PDDL-1.0", 632 | "SPDX:PHP-3.0", 633 | "SPDX:PHP-3.01", 634 | "SPDX:Pixar", 635 | "SPDX:pkgconf", 636 | "SPDX:Plexus", 637 | "SPDX:pnmstitch", 638 | "SPDX:PolyForm-Noncommercial-1.0.0", 639 | "SPDX:PolyForm-Small-Business-1.0.0", 640 | "SPDX:PostgreSQL", 641 | "SPDX:PPL", 642 | "SPDX:PSF-2.0", 643 | "SPDX:psfrag", 644 | "SPDX:psutils", 645 | "SPDX:Python-2.0", 646 | "SPDX:Python-2.0.1", 647 | "SPDX:python-ldap", 648 | "SPDX:Qhull", 649 | "SPDX:QPL-1.0", 650 | "SPDX:QPL-1.0-INRIA-2004", 651 | "SPDX:radvd", 652 | "SPDX:Rdisc", 653 | "SPDX:RHeCos-1.1", 654 | "SPDX:RPL-1.1", 655 | "SPDX:RPL-1.5", 656 | "SPDX:RPSL-1.0", 657 | "SPDX:RSA-MD", 658 | "SPDX:RSCPL", 659 | "SPDX:Ruby", 660 | "SPDX:SAX-PD", 661 | "SPDX:SAX-PD-2.0", 662 | "SPDX:Saxpath", 663 | "SPDX:SCEA", 664 | "SPDX:SchemeReport", 665 | "SPDX:Sendmail", 666 | "SPDX:Sendmail-8.23", 667 | "SPDX:SGI-B-1.0", 668 | "SPDX:SGI-B-1.1", 669 | "SPDX:SGI-B-2.0", 670 | "SPDX:SGI-OpenGL", 671 | "SPDX:SGP4", 672 | "SPDX:SHL-0.5", 673 | "SPDX:SHL-0.51", 674 | "SPDX:SimPL-2.0", 675 | "SPDX:SISSL", 676 | "SPDX:SISSL-1.2", 677 | "SPDX:SL", 678 | "SPDX:Sleepycat", 679 | "SPDX:SMLNJ", 680 | "SPDX:SMPPL", 681 | "SPDX:SNIA", 682 | "SPDX:snprintf", 683 | "SPDX:softSurfer", 684 | "SPDX:Soundex", 685 | "SPDX:Spencer-86", 686 | "SPDX:Spencer-94", 687 | "SPDX:Spencer-99", 688 | "SPDX:SPL-1.0", 689 | "SPDX:ssh-keyscan", 690 | "SPDX:SSH-OpenSSH", 691 | "SPDX:SSH-short", 692 | "SPDX:SSLeay-standalone", 693 | "SPDX:SSPL-1.0", 694 | "SPDX:StandardML-NJ", 695 | "SPDX:SugarCRM-1.1.3", 696 | "SPDX:Sun-PPP", 697 | "SPDX:Sun-PPP-2000", 698 | "SPDX:SunPro", 699 | "SPDX:SWL", 700 | "SPDX:swrule", 701 | "SPDX:Symlinks", 702 | "SPDX:TAPR-OHL-1.0", 703 | "SPDX:TCL", 704 | "SPDX:TCP-wrappers", 705 | "SPDX:TermReadKey", 706 | "SPDX:TGPPL-1.0", 707 | "SPDX:threeparttable", 708 | "SPDX:TMate", 709 | "SPDX:TORQUE-1.1", 710 | "SPDX:TOSL", 711 | "SPDX:TPDL", 712 | "SPDX:TPL-1.0", 713 | "SPDX:TTWL", 714 | "SPDX:TTYP0", 715 | "SPDX:TU-Berlin-1.0", 716 | "SPDX:TU-Berlin-2.0", 717 | "SPDX:UCAR", 718 | "SPDX:UCL-1.0", 719 | "SPDX:ulem", 720 | "SPDX:UMich-Merit", 721 | "SPDX:Unicode-3.0", 722 | "SPDX:Unicode-DFS-2015", 723 | "SPDX:Unicode-DFS-2016", 724 | "SPDX:Unicode-TOU", 725 | "SPDX:UnixCrypt", 726 | "SPDX:Unlicense", 727 | "SPDX:UPL-1.0", 728 | "SPDX:URT-RLE", 729 | "SPDX:Vim", 730 | "SPDX:VOSTROM", 731 | "SPDX:VSL-1.0", 732 | "SPDX:W3C", 733 | "SPDX:W3C-19980720", 734 | "SPDX:W3C-20150513", 735 | "SPDX:w3m", 736 | "SPDX:Watcom-1.0", 737 | "SPDX:Widget-Workshop", 738 | "SPDX:Wsuipa", 739 | "SPDX:WTFPL", 740 | "SPDX:wxWindows", 741 | "SPDX:X11", 742 | "SPDX:X11-distribute-modifications-variant", 743 | "SPDX:Xdebug-1.03", 744 | "SPDX:Xerox", 745 | "SPDX:Xfig", 746 | "SPDX:XFree86-1.1", 747 | "SPDX:xinetd", 748 | "SPDX:xkeyboard-config-Zinoviev", 749 | "SPDX:xlock", 750 | "SPDX:Xnet", 751 | "SPDX:xpp", 752 | "SPDX:XSkat", 753 | "SPDX:xzoom", 754 | "SPDX:YPL-1.0", 755 | "SPDX:YPL-1.1", 756 | "SPDX:Zed", 757 | "SPDX:Zeeff", 758 | "SPDX:Zend-2.0", 759 | "SPDX:Zimbra-1.3", 760 | "SPDX:Zimbra-1.4", 761 | "SPDX:Zlib", 762 | "SPDX:zlib-acknowledgement", 763 | "SPDX:ZPL-1.1", 764 | "SPDX:ZPL-2.0", 765 | "SPDX:ZPL-2.1" 766 | ] 767 | }, 768 | "minItems": 1, 769 | "uniqueItems": true 770 | }, 771 | "copyright": { 772 | "type": "array", 773 | "description": "A list of copyright holders, required by some licenses", 774 | "items": { 775 | "type": "string" 776 | }, 777 | "minItems": 1, 778 | "uniqueItems": true 779 | }, 780 | "platforms": { 781 | "type": "array", 782 | "description": "Optional list of supported platforms. If omitted, the extension will be available in all operating systems", 783 | "items": { 784 | "type": "string", 785 | "enum": [ 786 | "windows-x64", 787 | "windows-arm64", 788 | "linux-x64", 789 | "macos-arm64", 790 | "macos-x64" 791 | ] 792 | }, 793 | "minItems": 1, 794 | "uniqueItems": true 795 | }, 796 | "wheels": { 797 | "type": "array", 798 | "description": "Optional: bundle 3rd party Python modules", 799 | "items": { 800 | "type": "string" 801 | }, 802 | "minItems": 1, 803 | "uniqueItems": true 804 | }, 805 | "permissions": { 806 | "type": "object", 807 | "description": "Optional: A list of resources an addon will require", 808 | "properties": { 809 | "network": { 810 | "type": "string", 811 | "description": "Describe, why your extension needs network access." 812 | }, 813 | "files": { 814 | "type": "string", 815 | "description": "Describe, why your extension needs file system access." 816 | }, 817 | "clipboard": { 818 | "type": "string", 819 | "description": "Describe, why your extension needs clipboard access." 820 | }, 821 | "camera": { 822 | "type": "string", 823 | "description": "Describe, why your extension needs access to the camera." 824 | }, 825 | "microphone": { 826 | "type": "string", 827 | "description": "Describe, why your extension needs access to the microphone." 828 | } 829 | } 830 | }, 831 | "build": { 832 | "type": "object", 833 | "description": "Optional: Build settings", 834 | "properties": { 835 | "paths_exclude_pattern": { 836 | "type": "array", 837 | "items": { "type": "string" }, 838 | "minItems": 1, 839 | "uniqueItems": true 840 | } 841 | } 842 | } 843 | }, 844 | "required": [ 845 | "schema_version", 846 | "id", 847 | "version", 848 | "name", 849 | "tagline", 850 | "maintainer", 851 | "type", 852 | "blender_version_min", 853 | "license" 854 | ], 855 | "additionalProperties": false, 856 | "title": "Blender extensions manifest" 857 | } 858 | --------------------------------------------------------------------------------