Home Assistant Unofficial Reference 2024.12.1
package.py
Go to the documentation of this file.
1 """Helpers to install PyPi packages."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from functools import cache
7 from importlib.metadata import PackageNotFoundError, version
8 import logging
9 import os
10 from pathlib import Path
11 import site
12 from subprocess import PIPE, Popen
13 import sys
14 from urllib.parse import urlparse
15 
16 from packaging.requirements import InvalidRequirement, Requirement
17 
18 _LOGGER = logging.getLogger(__name__)
19 
20 
21 def is_virtual_env() -> bool:
22  """Return if we run in a virtual environment."""
23  # Check supports venv && virtualenv
24  return getattr(sys, "base_prefix", sys.prefix) != sys.prefix or hasattr(
25  sys, "real_prefix"
26  )
27 
28 
29 @cache
30 def is_docker_env() -> bool:
31  """Return True if we run in a docker env."""
32  return Path("/.dockerenv").exists()
33 
34 
35 def get_installed_versions(specifiers: set[str]) -> set[str]:
36  """Return a set of installed packages and versions."""
37  return {specifier for specifier in specifiers if is_installed(specifier)}
38 
39 
40 def is_installed(requirement_str: str) -> bool:
41  """Check if a package is installed and will be loaded when we import it.
42 
43  expected input is a pip compatible package specifier (requirement string)
44  e.g. "package==1.0.0" or "package>=1.0.0,<2.0.0"
45 
46  For backward compatibility, it also accepts a URL with a fragment
47  e.g. "git+https://github.com/pypa/pip#pip>=1"
48 
49  Returns True when the requirement is met.
50  Returns False when the package is not installed or doesn't meet req.
51  """
52  try:
53  req = Requirement(requirement_str)
54  except InvalidRequirement:
55  if "#" not in requirement_str:
56  _LOGGER.error("Invalid requirement '%s'", requirement_str)
57  return False
58 
59  # This is likely a URL with a fragment
60  # example: git+https://github.com/pypa/pip#pip>=1
61 
62  # fragment support was originally used to install zip files, and
63  # we no longer do this in Home Assistant. However, custom
64  # components started using it to install packages from git
65  # urls which would make it would be a breaking change to
66  # remove it.
67  try:
68  req = Requirement(urlparse(requirement_str).fragment)
69  except InvalidRequirement:
70  _LOGGER.error("Invalid requirement '%s'", requirement_str)
71  return False
72 
73  try:
74  if (installed_version := version(req.name)) is None:
75  # This can happen when an install failed or
76  # was aborted while in progress see
77  # https://github.com/home-assistant/core/issues/47699
78  _LOGGER.error( # type: ignore[unreachable]
79  "Installed version for %s resolved to None", req.name
80  )
81  return False
82  return req.specifier.contains(installed_version, prereleases=True)
83  except PackageNotFoundError:
84  return False
85 
86 
87 _UV_ENV_PYTHON_VARS = (
88  "UV_SYSTEM_PYTHON",
89  "UV_PYTHON",
90 )
91 
92 
94  package: str,
95  upgrade: bool = True,
96  target: str | None = None,
97  constraints: str | None = None,
98  timeout: int | None = None,
99 ) -> bool:
100  """Install a package on PyPi. Accepts pip compatible package strings.
101 
102  Return boolean if install successful.
103  """
104  _LOGGER.info("Attempting install of %s", package)
105  env = os.environ.copy()
106  args = [
107  sys.executable,
108  "-m",
109  "uv",
110  "pip",
111  "install",
112  "--quiet",
113  package,
114  # We need to use unsafe-first-match for custom components
115  # which can use a different version of a package than the one
116  # we have built the wheel for.
117  "--index-strategy",
118  "unsafe-first-match",
119  ]
120  if timeout:
121  env["HTTP_TIMEOUT"] = str(timeout)
122  if upgrade:
123  args.append("--upgrade")
124  if constraints is not None:
125  args += ["--constraint", constraints]
126  if target:
127  abs_target = os.path.abspath(target)
128  args += ["--target", abs_target]
129  elif (
130  not is_virtual_env()
131  and not (any(var in env for var in _UV_ENV_PYTHON_VARS))
132  and (abs_target := site.getusersitepackages())
133  ):
134  # Pip compatibility
135  # Uv has currently no support for --user
136  # See https://github.com/astral-sh/uv/issues/2077
137  # Using workaround to install to site-packages
138  # https://github.com/astral-sh/uv/issues/2077#issuecomment-2150406001
139  args += ["--python", sys.executable, "--target", abs_target]
140 
141  _LOGGER.debug("Running uv pip command: args=%s", args)
142  with Popen(
143  args,
144  stdin=PIPE,
145  stdout=PIPE,
146  stderr=PIPE,
147  env=env,
148  close_fds=False, # required for posix_spawn
149  ) as process:
150  _, stderr = process.communicate()
151  if process.returncode != 0:
152  _LOGGER.error(
153  "Unable to install package %s: %s",
154  package,
155  stderr.decode("utf-8").lstrip().strip(),
156  )
157  return False
158 
159  return True
160 
161 
162 async def async_get_user_site(deps_dir: str) -> str:
163  """Return user local library path.
164 
165  This function is a coroutine.
166  """
167  env = os.environ.copy()
168  env["PYTHONUSERBASE"] = os.path.abspath(deps_dir)
169  args = [sys.executable, "-m", "site", "--user-site"]
170  process = await asyncio.create_subprocess_exec(
171  *args,
172  stdin=asyncio.subprocess.PIPE,
173  stdout=asyncio.subprocess.PIPE,
174  stderr=asyncio.subprocess.DEVNULL,
175  env=env,
176  close_fds=False, # required for posix_spawn
177  )
178  stdout, _ = await process.communicate()
179  return stdout.decode().strip()
str async_get_user_site(str deps_dir)
Definition: package.py:162
set[str] get_installed_versions(set[str] specifiers)
Definition: package.py:35
bool is_installed(str requirement_str)
Definition: package.py:40
bool install_package(str package, bool upgrade=True, str|None target=None, str|None constraints=None, int|None timeout=None)
Definition: package.py:99