diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 493baa1..5817c79 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.0.17 +current_version = 0.0.18 [bumpversion:file:blender_downloader/__init__.py] diff --git a/.gitignore b/.gitignore index 63ae1af..e58258a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/__pycache__ +__pycache__ /venv blender-* !blender_downloader.py diff --git a/blender_downloader/__init__.py b/blender_downloader/__init__.py index 6d1d62b..f90925b 100644 --- a/blender_downloader/__init__.py +++ b/blender_downloader/__init__.py @@ -3,6 +3,7 @@ """blender-downloader""" import argparse +import json import math import os import re @@ -14,7 +15,6 @@ from appdirs import user_data_dir from diskcache import Cache, Timeout as CacheTimeout -from pkg_resources import parse_version from pkg_resources.extern.packaging.version import Version from tqdm import tqdm @@ -22,11 +22,12 @@ __author__ = "mondeja" __description__ = "Multiplatform Blender portable release downloader script." __title__ = "blender-downloader" -__version__ = "0.0.17" +__version__ = "0.0.18" QUIET = False SCRIPT_NEW_ISSUE_URL = f"https://github.com/{__author__}/{__title__}/issues/new" +BLENDER_MANUAL_VERSIONS_URL = "https://docs.blender.org/PROD/versions.json" MINIMUM_VERSION_SUPPPORTED = "2.57" SUPPORTED_FILETYPES_EXTRACTION = [".bz2", ".gz", ".xz", ".zip", ".dmg"] NIGHLY_RELEASES_CACHE_EXPIRATION = 60 * 60 * 24 # 1 day @@ -35,6 +36,10 @@ ) +class BlenderVersionNotFound(RuntimeError): + pass + + def get_running_os(): if sys.platform == "darwin": return "macos" @@ -73,9 +78,11 @@ def build_parser(): "blender_version", nargs="?", metavar="BLENDER_VERSION", - help="Blender version to download. Could be a version number," - " or the word 'stable' to download the current stable version." - f" The minium version supported is {MINIMUM_VERSION_SUPPPORTED}.", + help="Blender version to download. Could be a version number" + " or one of the words 'stable' (current stable version), 'lts'" + " (latest long term support version) and 'nightly' or 'daily'" + " (latest development release)." + f" The minimum version supported is {MINIMUM_VERSION_SUPPPORTED}.", ) parser.add_argument( "-d", @@ -93,7 +100,7 @@ def build_parser(): help="Extract the content of the zipped release file. If this option" " is passed, the content of the release file will be extracted" " in the same repository as '--output-directory' value argument." - " This is not supported for MacOS releases under Python3.6", + " This is not supported for MacOS releases using Python3.6.", ) parser.add_argument( "--remove-compressed", @@ -135,7 +142,7 @@ def build_parser(): default=64 if sys.maxsize > 2 ** 32 else 32, type=int, help="Operative system bits. Keep in mind that Blender v2.80 was the" - " latest release with support operative systems wit 32 bits.", + " latest release with support operative systems with 32 bits.", ) parser.add_argument( "--arch", @@ -174,7 +181,7 @@ def build_parser(): return parser -def unify_new_blender_version(version): +def normalize_version(version): if version.count(".") == 0: version = f"{version.rstrip('.')}.0.0" elif version.count(".") == 1: @@ -195,7 +202,8 @@ def parse_args(args): # operative system by function and assert that is valid if hasattr(opts.operative_system, "__call__"): opts.operative_system = opts.operative_system() - if opts.operative_system not in ["linux", "macos", "windows"]: + opts.operative_system = opts.operative_system.lower() + if opts.operative_system not in {"linux", "macos", "windows"}: sys.stderr.write( f"Invalid operative system '{opts.operative_system}'. Must be" " either 'linux', 'macos' or 'windows'.\n" @@ -213,13 +221,16 @@ def parse_args(args): if opts.blender_version is None: parser.print_help() sys.exit(1) + else: + opts.blender_version = opts.blender_version.lower() - if opts.blender_version == "stable": - opts.blender_version = get_stable_release_version_number( + if opts.blender_version in {"stable", "lts", "nightly", "daily"}: + opts.blender_version = discover_version_number_by_identifier( + opts.blender_version, use_cache=opts.use_cache, ) - elif parse_version(opts.blender_version) >= Version("2.83"): - opts.blender_version = unify_new_blender_version(opts.blender_version) + elif Version(opts.blender_version) >= Version("2.83"): + opts.blender_version = normalize_version(opts.blender_version) else: opts.blender_version = None @@ -240,10 +251,7 @@ def parse_args(args): sys.exit(1) # assert compatible bits - if opts.bits == 32 and ( - opts.blender_version == "stable" - or parse_version(opts.blender_version) > Version("2.80") - ): + if opts.bits == 32 and Version(opts.blender_version) > Version("2.80"): sys.stderr.write( "The latest Blender version with support for 32 bits systems is" " v2.80. Please, specify a more recent version of Blender.\n" @@ -259,32 +267,58 @@ def parse_args(args): return opts -def get_stable_release_version_number(use_cache=True): - """Retrieves the latest Blender stable release version number from their - website. +def discover_version_number_by_identifier(identifier, use_cache=True): + """Discover a Blender version number given an identifier. Parameters ---------- + identifier : str + Version identifier. Can be either 'stable', 'lts' or 'nightly'. use_cache : bool - Use cache requesting Blender repositories. + Use cache requesting Blender versions from manual. """ res = GET( - "https://www.blender.org/download/", + BLENDER_MANUAL_VERSIONS_URL, expire=NIGHLY_RELEASES_CACHE_EXPIRATION, use_cache=use_cache, ) - try: - return re.search(r"blender-(\d+\.\d+\.\d+)-", res).group(1) - except AttributeError as err: - if "'NoneType' object has no attribute 'group'" in str(err): + versions_json = json.loads(res) + + if identifier == "stable": + latest_Version = None + for minor_version, version_data in versions_json.items(): + if "dev" in version_data: + continue + + minor_Version = Version(minor_version) + if latest_Version is None or minor_Version > latest_Version: + latest_Version = minor_Version + + if latest_Version is None: sys.stderr.write( - "Failed to obtain the stable release version of Blender.\n" - " Please, report this issue using the next URL:" - f" {SCRIPT_NEW_ISSUE_URL}\n" + "Error trying to retrieve the stable release from Blender" + " repositories. Please, submit an issue to" + " {SCRIPT_NEW_ISSUE_URL}.\n" ) sys.exit(1) - raise err + elif identifier in {"lts", "nightly", "daily"}: + expected_substr_in_data = "lts" if identifier == "lts" else "dev" + latest_Version = None + + for minor_version, version_data in versions_json.items(): + if expected_substr_in_data not in version_data.lower(): + continue + minor_Version = Version(minor_version) + if latest_Version is None or minor_Version > latest_Version: + latest_Version = minor_Version + else: + sys.stderr.write( + f"Invalid identifier '{identifier}' for Blender version. Possible" + " values are 'stable', 'lts' and 'nightly'.\n" + ) + sys.exit(1) + return normalize_version(str(latest_Version)) def _build_download_repo_expected_os_identifier( @@ -383,7 +417,7 @@ def valid_release_file(filename): def get_legacy_release_download_url( blender_version, operative_system, bits, arch, use_cache=True ): - """Retrieves the download URL for a specifc release version of Blender. + """Retrieves the download URL for a specific legacy release of Blender. Parameters ---------- @@ -418,15 +452,6 @@ def get_legacy_release_download_url( url = "https://download.blender.org/release/" - version_not_found_error_message = lambda: ( - f"The release '{blender_version}' can't be located in official" - " Blender repositories.\nMake sure that you are passing a valid" - f" version.\nYou can check all valid releases at: {url}\n\n" - f"If you think that '{blender_version}' is a valid release and" - " this is a problem with the downloader,\nplease, report it to" - f" {SCRIPT_NEW_ISSUE_URL}\n" - ) - res = GET(url, use_cache=use_cache) version_path = f"Blender{major_minor_blender_version}/" @@ -437,8 +462,7 @@ def get_legacy_release_download_url( break if not _version_path_found: - sys.stderr.write(version_not_found_error_message()) - sys.exit(1) + raise BlenderVersionNotFound() major_minor_blender_release_url = f"{url}{version_path}" @@ -474,8 +498,70 @@ def get_legacy_release_download_url( break if download_url is None: - sys.stderr.write(version_not_found_error_message()) - sys.exit(1) + raise BlenderVersionNotFound() + + return download_url + + +def get_nightly_release_download_url( + blender_version, operative_system, arch, use_cache=True +): + """Retrieves the download URL for a specific nightly release of Blender. + + Parameters + ---------- + + blender_version : str + Version for which the URL will be discovered. Should be a valid version + of Blender, otherwise shows an error an the script will exit with 1 code. + + operative_system : str + Operative system correspondent to the release. + + bits : str + Number of bits of the system for the release. + + arch : str + Identifier of the architecture for which the release will be retrieved. + + use_cache : bool + Use cache requesting Blender repositories. + """ + url = "https://builder.blender.org/download/daily/archive/" + res = GET(url, use_cache=use_cache) + urls = re.findall(rf'"({url}[^"]+)"', res) + + expected_os_identifier = ( + "darwin" if operative_system == "macos" else operative_system + ) + expected_extension = { + "linux": "tar.xz", + "windows": "zip", + "macos": "dmg", + }[operative_system] + + download_url = None + + for url in urls: + if expected_os_identifier not in url: + continue + if f"/blender-{blender_version}-" not in url: + continue + + arch_ext_split = url.split(f"{expected_os_identifier}.")[1].split("-") + version_ext = ".".join(arch_ext_split[-1].split(".")[1:]) + if version_ext != expected_extension: + continue + + version_arch = arch_ext_split[0] + if arch is not None and arch != version_arch: + continue + + download_url = url + break + + if download_url is None: + raise BlenderVersionNotFound() return download_url @@ -620,7 +706,7 @@ def extract_release(zipped_filepath, quiet=False): with dmglib.attachedDiskImage(zipped_filepath) as mounted_dmg: contents_parent_dirpath = None - for dirpath, dirnames, files in os.walk(mounted_dmg[0]): + for dirpath, _, _ in os.walk(mounted_dmg[0]): if ( os.path.basename(dirpath) == "Contents" and "blender.app" in dirpath.lower() @@ -741,7 +827,7 @@ def print_executables( python_executable_filepath ): sys.stderr.write( - "Builtin Blender Python intepreter executable filepath not found\n" + "Builtin Blender Python interpreter executable filepath not found\n" ) error = True else: @@ -775,13 +861,28 @@ def list_available_blender_versions( use_cache : bool Use cache requesting Blender repositories. """ - n_versions, versions_found = (1, []) + n_versions, versions_found = (0, []) + + # Nightly version number + nightly_version = discover_version_number_by_identifier( + "nightly", + use_cache=use_cache, + ) + sys.stdout.write(f"{nightly_version}\n") + versions_found.append(nightly_version) + n_versions += 1 + if maximum_versions < 2: + return 0 # Stable version number - stable_version = get_stable_release_version_number(use_cache=use_cache) + stable_version = discover_version_number_by_identifier( + "stable", + use_cache=use_cache, + ) sys.stdout.write(f"{stable_version}\n") versions_found.append(stable_version) - if maximum_versions < 2: + n_versions += 1 + if maximum_versions < 3: return 0 url = "https://download.blender.org/release/" @@ -866,7 +967,7 @@ def run(args=[]): CACHE.clear() except CacheTimeout: sys.stderr.write( - "An error ocurred clearing blender-downloader's cache.\n" + "An error happen clearing blender-downloader's cache.\n" f"Please, submit an issue to {SCRIPT_NEW_ISSUE_URL} if the" " problem persists.\n" ) @@ -883,14 +984,33 @@ def run(args=[]): opts.arch, use_cache=opts.use_cache, ) - - download_url = get_legacy_release_download_url( - opts.blender_version, - opts.operative_system, - opts.bits, - opts.arch, - use_cache=opts.use_cache, - ) + try: + download_url = get_legacy_release_download_url( + opts.blender_version, + opts.operative_system, + opts.bits, + opts.arch, + use_cache=opts.use_cache, + ) + except BlenderVersionNotFound: + try: + download_url = get_nightly_release_download_url( + opts.blender_version, + opts.operative_system, + opts.arch, + use_cache=opts.use_cache, + ) + except BlenderVersionNotFound: + version_not_found_error_message = ( + f"The release '{opts.blender_version}' can't be located in official" + " Blender repositories.\nMake sure that you are passing a valid" + f" version.\n\n" + f"If you think that '{opts.blender_version}' is a valid release and" + " this is a problem with blender-downloader, please, report it to" + f" {SCRIPT_NEW_ISSUE_URL}\n" + ) + sys.stderr.write(version_not_found_error_message) + sys.exit(1) downloaded_release_filepath = download_release( download_url, opts.output_directory, diff --git a/setup.cfg b/setup.cfg index ca60fa6..0e4569c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = blender_downloader -version = 0.0.17 +version = 0.0.18 description = Multiplatorm Blender downloader. long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/test_discover_version_number_by_identifier.py b/tests/test_discover_version_number_by_identifier.py new file mode 100644 index 0000000..c974d3f --- /dev/null +++ b/tests/test_discover_version_number_by_identifier.py @@ -0,0 +1,33 @@ +"""Test that the stable version number can be retrieved from Blender website.""" + +import re +import sys + +from pkg_resources.extern.packaging.version import Version + +from blender_downloader import discover_version_number_by_identifier + + +NUMBER_VERSION_REGEX = re.compile(r"\d+\.\d+\.\d+") + + +def test_discover_version_number_by_identifier(): + lts_version = discover_version_number_by_identifier("lts") + sys.stdout.write("\n") + sys.stdout.write(f"LTS release: {lts_version}\n") + assert re.match(NUMBER_VERSION_REGEX, lts_version) + + stable_version = discover_version_number_by_identifier("stable") + sys.stdout.write(f"Stable release: {stable_version}\n") + assert re.match(NUMBER_VERSION_REGEX, stable_version) + assert Version(stable_version) >= Version(lts_version) + + nightly_version = discover_version_number_by_identifier("nightly") + sys.stdout.write(f"Nightly/daily release: {nightly_version}\n") + assert re.match(NUMBER_VERSION_REGEX, nightly_version) + assert Version(nightly_version) > Version(lts_version) + assert Version(nightly_version) > Version(stable_version) + + daily_version = discover_version_number_by_identifier("daily") + assert re.match(NUMBER_VERSION_REGEX, nightly_version) + assert Version(nightly_version) == Version(daily_version) diff --git a/tests/test_get_legacy_release_download_url.py b/tests/test_get_legacy_release_download_url.py index 04f70aa..1d66916 100644 --- a/tests/test_get_legacy_release_download_url.py +++ b/tests/test_get_legacy_release_download_url.py @@ -21,6 +21,7 @@ @pytest.mark.parametrize( "blender_version", ( + "3.0.0", "2.93.1", "2.93.0", # change in release formats "2.92.0", @@ -149,7 +150,7 @@ def assert_url(url_end_schema): assert_url("{blender_version}-OSX_10.6-x86_64.zip") elif blender_Version < Version("2.79"): assert_url("{blender_version}-OSX_10.6-x86_64.zip") - else: # blender_Version < Version("2.71"): + else: # Version("2.71") < blender_Version < Version("2.79") if bits == 32: assert_url("{blender_version}-OSX_10.6-i386.zip") else: @@ -165,10 +166,10 @@ def assert_url(url_end_schema): assert_url("{blender_version}-windows64.zip") elif blender_Version > Version("2.65"): assert_url("{blender_version}-windows{bits}.zip") - elif blender_Version < Version("2.61"): - assert_url("{blender_version}-windows{bits}.zip") - else: + elif blender_Version > Version("2.60"): assert_url("{blender_version}-release-windows{bits}.zip") + else: # blender_Version < Version("2.61") + assert_url("{blender_version}-windows{bits}.zip") else: # operative_system == "linux": if blender_Version >= Version("2.93"): assert_url("{blender_version}-linux-x64.tar.xz") @@ -195,7 +196,7 @@ def assert_url(url_end_schema): assert_url("{blender_version}-linux-glibc27-i686.tar.bz2") else: assert_url("{blender_version}-linux-glibc27-x86_64.tar.bz2") - else: + else: # Version("2.64") < blender_Version < Version("2.79") if bits == 32: assert_url("{blender_version}-linux-glibc211-i686.tar.bz2") else: diff --git a/tests/test_get_nightly_release_download_url.py b/tests/test_get_nightly_release_download_url.py new file mode 100644 index 0000000..23421b3 --- /dev/null +++ b/tests/test_get_nightly_release_download_url.py @@ -0,0 +1,29 @@ +import pytest + +from blender_downloader import ( + BlenderVersionNotFound, + discover_version_number_by_identifier, + get_nightly_release_download_url, +) + + +@pytest.mark.parametrize("operative_system", ("linux", "macos", "windows")) +@pytest.mark.parametrize("arch", ("arm64", None)) +def test_get_nightly_release_download_url(operative_system, arch): + blender_version = discover_version_number_by_identifier("nightly") + + def get_url(): + return get_nightly_release_download_url( + blender_version, + operative_system, + arch, + ) + + if arch == "arm64" and operative_system != "macos": + with pytest.raises(BlenderVersionNotFound): + get_url() + else: + url = get_url() + assert f"/blender-{blender_version}-" in url + assert url.startswith("https://") + assert "." in url diff --git a/tests/test_get_stable_release_version_number.py b/tests/test_get_stable_release_version_number.py deleted file mode 100644 index 1e2200a..0000000 --- a/tests/test_get_stable_release_version_number.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Test that the stable version number can be retrieved from Blender website.""" - -import re - -from blender_downloader import get_stable_release_version_number - - -def test_get_stable_release_version_number(): - assert re.match(r"^\d+\.\d+\.\d+", get_stable_release_version_number()) diff --git a/tests/test_list_available_blender_versions.py b/tests/test_list_available_blender_versions.py index 9d39793..6696bad 100644 --- a/tests/test_list_available_blender_versions.py +++ b/tests/test_list_available_blender_versions.py @@ -19,7 +19,7 @@ class BlenderVersion: def __init__(self, raw): self.raw = raw - self.version_info = list() + self.version_info = [] for i, partial in enumerate(raw.split(".")): if i > 1: for ch in partial: @@ -41,10 +41,10 @@ def __repr__(self): return self.raw -@pytest.mark.parametrize("arch", (None, "arm64")) @pytest.mark.parametrize("maximum_versions", (1, 2, random.randint(5, 10), math.inf)) @pytest.mark.parametrize("operative_system", ("linux", "windows", "macos")) @pytest.mark.parametrize("bits", (64, 32)) +@pytest.mark.parametrize("arch", (None, "arm64")) def test_list_available_blender_versions( maximum_versions, operative_system, bits, arch ):