Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #191: improve handling of dependencies #201

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion pythonFiles/include/blender_vscode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ def startup(editor_address, addons_to_load: List[AddonInfo]):
from . import installation

# blender 2.80 'ssl' module is compiled with 'OpenSSL 1.1.0h' what breaks with requests >2.29.0
installation.ensure_packages_are_installed(["debugpy", "requests<=2.29.0", "werkzeug<=3.0.3", "flask<=3.0.3"])
installation.ensure_packages_are_installed(
[
"debugpy<=1.7.0",
# debugpy 1.7.0 is last version that officially supports 3.7
"requests<=2.29.0", # blender 2.80 'ssl' module is compiled with 'OpenSSL 1.1.0h' what breaks with requests >2.29.0
# requests is shipped with blender so it it should not be even installed
"werkzeug<=2.2.3",
# wergzeug 2.2.3 is last version that officially supports 3.7
"flask<=2.2.5", # keep flask version pinned until werkzeug and underlying multiprocessing issue is fixed: https://github.com/JacquesLucke/blender_vscode/issues/191
# flask 2.2.5 is last version that officially supports 3.7
]
)

from . import load_addons

Expand Down
1 change: 0 additions & 1 deletion pythonFiles/include/blender_vscode/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import sys
import addon_utils
from pathlib import Path
import platform
import os

# binary_path_python was removed in blender 2.92
Expand Down
162 changes: 125 additions & 37 deletions pythonFiles/include/blender_vscode/installation.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,129 @@
import glob
import importlib.metadata
import os
import shutil
import sys
import subprocess

import textwrap
import bpy

from pathlib import Path
from typing import List, Optional, Tuple

from . import handle_fatal_error
from .environment import python_path
from semver import Version

_CWD_FOR_SUBPROCESSES = python_path.parent


def ensure_packages_are_installed(package_names):
if packages_are_installed(package_names):
return

install_packages(package_names)

def assert_packages_are_installed(package_names: List[str]):
from . import handle_fatal_error

def packages_are_installed(package_names):
return all(module_can_be_imported(name) for name in package_names)
for package in package_names:
if not is_module_installed(package):
handle_fatal_error(
textwrap.dedent(
f"""\
Package can not be imported: {package}
If you are experiencing PermissionError please scroll up to the line `Execute:` and execute the command manually.
Also close all Blender instances.
For old system wide Blender installations you might to reinstall Blender."""
)
)


def install_packages(package_names):
if not module_can_be_imported("pip"):
def ensure_packages_are_installed(package_names: List[str]):
if not is_module_installed("pip"):
install_pip()

# Perform precise checks if any dependency changes are needed.
# Manual checks prevent from unnecessary updates which on <0.21 installations may require admin rights
# Moreover upgrading when using pip install --target is not possible.
requires_reinstall = False
for name in package_names:
ensure_package_is_installed(name)

assert packages_are_installed(package_names)


def ensure_package_is_installed(name: str):
if not module_can_be_imported(name):
install_package(name)


def install_package(name: str):
_name, requested_version = _split_package_version(name)
if requested_version is None:
continue
requested_version = Version.parse(requested_version)
real_version = package_version(name)
if real_version is None:
requires_reinstall = f"{name} is not installed"
break
real_version = Version.parse(real_version)
assert isinstance(requested_version, Version), requested_version
assert isinstance(real_version, Version), real_version
if "==" in name and not (real_version == requested_version):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no builtin library that helps with these kinds of checks? It feels a bit weird to have to implement it manually.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are stepping into the boots of pip. I am forcing pip to do very specific things to my dependencies:

  • dont touch deps when they are in acceptable range and
  • oh, thats it, nothing more.

If you read about the --target option you will see that it is not well supported...

The typical way of using pip is lock file or requirement file. Lock file is way to restrictive in our case, and if you use requirement file with -U you will force everyone to upgrade.

Sooo... yes, it is messy. I am not saying it is the best, this is what I came up with

requires_reinstall = f"{name}, got {real_version}"
break
elif "<=" in name and not (real_version <= requested_version):
requires_reinstall = f"{name}, got {real_version}"
break
elif "<=" not in name and "<" in name and not (real_version < requested_version):
requires_reinstall = f"{name}, got {real_version}"
break
elif ">=" not in name and ">" in name and not (real_version > requested_version):
requires_reinstall = f"{name}, got {real_version}"
break

if requires_reinstall:
print(f"INFO: dependencies require update because of {requires_reinstall}, reinstalling...")
gracefully_remove_packages(bpy.utils.user_resource("SCRIPTS", path="modules"))
install_packages(package_names)

assert_packages_are_installed(package_names)


def gracefully_remove_packages(target_dir: str):
"""Carefully remove packages from target_dir that were installed with this extension.

Known dependencies are produced by manually running dependency tree of dependencies:
>>> pip install --target . --ignore-installed <dependencies>
"""
print("INFO: Removing installed dependencies and metadata")
known_dependencies = [
"blinker",
"certifi",
"charset_normalizer",
"click",
"colorama",
"debugpy",
"flask",
"idna",
"itsdangerous",
"jinja2",
"markupsafe",
"requests",
"urllib3",
"werkzeug",
]
for package in known_dependencies:
installed_package = os.path.join(target_dir, package)
if os.path.exists(installed_package):
print(f"DEBUG: remove {installed_package}")
shutil.rmtree(installed_package)
for metadata in glob.glob(os.path.join(target_dir, package + "-*.dist-info")):
if os.path.exists(metadata):
print(f"DEBUG: remove {metadata}")
shutil.rmtree(metadata)


def install_packages(names: List[str]):
target = get_package_install_directory()
command = [str(python_path), "-m", "pip", "install", name, "--target", target]
print("Execute: ", " ".join(command))
command = [str(python_path), "-m", "pip", "install", "--disable-pip-version-check", "--target", target, *names]

print("INFO: execute:", " ".join(_escape_space(c) for c in command))
subprocess.run(command, cwd=_CWD_FOR_SUBPROCESSES)

if not module_can_be_imported(name):
handle_fatal_error(f"could not install {name}")

def _escape_space(name):
return f'"{name}"' if " " in name else name


def install_pip():
# try ensurepip before get-pip.py
if module_can_be_imported("ensurepip"):
if is_module_installed("ensurepip"):
command = [str(python_path), "-m", "ensurepip", "--upgrade"]
print("Execute: ", " ".join(command))
print("INFO: execute:", " ".join(command))
subprocess.run(command, cwd=_CWD_FOR_SUBPROCESSES)
return
# pip can not necessarily be imported into Blender after this
Expand All @@ -69,14 +141,30 @@ def get_package_install_directory() -> str:
return modules_path


def module_can_be_imported(name: str):
def _split_package_version(name: str) -> Tuple[str, Optional[str]]:
name_and_maybe_version = name.replace(">", "=").replace("<", "=").replace("==", "=").split("=")
if len(name_and_maybe_version) == 0:
return name, None
elif len(name_and_maybe_version) == 1:
return name_and_maybe_version[0], None
else:
return name_and_maybe_version[0], name_and_maybe_version[1]


def _strip_package_version(name: str) -> str:
return _split_package_version(name)[0]


def package_version(package: str) -> Optional[str]:
name = _strip_package_version(package)
try:
__import__(_strip_pip_version(name))
return True
except ModuleNotFoundError:
return False
return importlib.metadata.version(_strip_package_version(name))
except AttributeError: # python <3.8
import pkg_resources

return pkg_resources.get_distribution(name).version
except importlib.metadata.PackageNotFoundError:
return None


def _strip_pip_version(name: str) -> str:
name_strip_comparison_sign = name.replace(">", "=").replace("<", "=")
return name_strip_comparison_sign.split("=")[0]
is_module_installed = package_version
37 changes: 37 additions & 0 deletions pythonFiles/include/semver/__about__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
Metadata about semver.

Contains information about semver's version, the implemented version
of the semver specifictation, author, maintainers, and description.

.. autodata:: __author__

.. autodata:: __description__

.. autodata:: __maintainer__

.. autodata:: __version__

.. autodata:: SEMVER_SPEC_VERSION
"""

#: Semver version
__version__ = "3.0.2"

#: Original semver author
__author__ = "Kostiantyn Rybnikov"

#: Author's email address
__author_email__ = "[email protected]"

#: Current maintainer
__maintainer__ = ["Sebastien Celles", "Tom Schraitle"]

#: Maintainer's email address
__maintainer_email__ = "[email protected]"

#: Short description about semver
__description__ = "Python helper for Semantic Versioning (https://semver.org)"

#: Supported semver specification
SEMVER_SPEC_VERSION = "2.0.0"
72 changes: 72 additions & 0 deletions pythonFiles/include/semver/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""
Semver package major release 3.

A Python module for semantic versioning. Simplifies comparing versions.
"""

from ._deprecated import (
bump_build,
bump_major,
bump_minor,
bump_patch,
compare,
bump_prerelease,
finalize_version,
format_version,
match,
max_ver,
min_ver,
parse,
parse_version_info,
replace,
cmd_bump,
cmd_compare,
cmd_nextver,
cmd_check,
createparser,
process,
main,
)
from .version import Version, VersionInfo
from .__about__ import (
__version__,
__author__,
__maintainer__,
__author_email__,
__description__,
__maintainer_email__,
SEMVER_SPEC_VERSION,
)

__all__ = [
"bump_build",
"bump_major",
"bump_minor",
"bump_patch",
"compare",
"bump_prerelease",
"finalize_version",
"format_version",
"match",
"max_ver",
"min_ver",
"parse",
"parse_version_info",
"replace",
"cmd_bump",
"cmd_compare",
"cmd_nextver",
"cmd_check",
"createparser",
"process",
"main",
"Version",
"VersionInfo",
"__version__",
"__author__",
"__maintainer__",
"__author_email__",
"__description__",
"__maintainer_email__",
"SEMVER_SPEC_VERSION",
]
28 changes: 28 additions & 0 deletions pythonFiles/include/semver/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
Module to support call with :file:`__main__.py`. Used to support the following
call::

$ python3 -m semver ...

This makes it also possible to "run" a wheel like in this command::

$ python3 semver-3*-py3-none-any.whl/semver -h

"""
import os.path
import sys
from typing import List, Optional

from semver import cli


def main(cliargs: Optional[List[str]] = None) -> int:
if __package__ == "":
path = os.path.dirname(os.path.dirname(__file__))
sys.path[0:0] = [path]

return cli.main(cliargs)


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Loading