This commit is contained in:
Iliyan Angelov
2025-12-01 06:50:10 +02:00
parent 91f51bc6fe
commit 62c1fe5951
4682 changed files with 544807 additions and 31208 deletions

View File

@@ -0,0 +1,121 @@
from typing import List, Optional
from pathlib import Path
from .main import Uv
import typer
from safety.tool.auth import index_credentials
from ..base import BaseCommand
from ..environment_diff import EnvironmentDiffTracker, PipEnvironmentDiffTracker
from ..mixins import InstallationAuditMixin
from safety_schemas.models.events.types import ToolType
from safety.models import ToolResult
from .parser import UvParser
class UvCommand(BaseCommand):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def get_command_name(self) -> List[str]:
return ["uv"]
def get_diff_tracker(self) -> "EnvironmentDiffTracker":
return PipEnvironmentDiffTracker()
def get_tool_type(self) -> ToolType:
return ToolType.UV
def get_package_list_command(self) -> List[str]:
# uv --active flag would ignore the uv project virtual environment,
# by passing the --active flag then we can list the packages for the
# correct environment.
active = (
["--active"]
if self._intention and self._intention.options.get("active")
else []
)
list_pkgs = Path(__file__).parent / "list_pkgs.py"
# --no-project flag is used to avoid uv to create the venv or lock file if it doesn't exist
return [
*self.get_command_name(),
"run",
*active,
"--no-sync",
"python",
str(list_pkgs),
]
@classmethod
def from_args(cls, args: List[str], **kwargs):
if uv_intention := UvParser().parse(args):
kwargs["intention"] = uv_intention
if uv_intention.modifies_packages():
return AuditableUvCommand(args, **kwargs)
return UvCommand(args, **kwargs)
class AuditableUvCommand(UvCommand, InstallationAuditMixin):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.__index_url = None
def before(self, ctx: typer.Context):
super().before(ctx)
args: List[Optional[str]] = self._args.copy() # type: ignore
if self._intention:
if index_opt := self._intention.options.get(
"index-url"
) or self._intention.options.get("i"):
index_value = index_opt["value"]
if index_value and index_value.startswith("https://pkgs.safetycli.com"):
self.__index_url = index_value
arg_index = index_opt["arg_index"]
value_index = index_opt["value_index"]
if (
arg_index
and value_index
and arg_index < len(args)
and value_index < len(args)
):
args[arg_index] = None
args[value_index] = None
self._args = [arg for arg in args if arg is not None]
def after(self, ctx: typer.Context, result: ToolResult):
super().after(ctx, result)
self.handle_installation_audit(ctx, result)
def env(self, ctx: typer.Context) -> dict:
env = super().env(ctx)
default_index_url = Uv.build_index_url(ctx, self.__index_url)
# uv config precedence:
# 1. Command line args -> We rewrite the args if the a default index is provided via command line args.
# 2. Environment variables -> We set the default index to the Safety index
# 3. Config files
env.update(
{
# Default index URL
# When the package manager is wrapped, we provide a default index so the search always falls back to the Safety index
# UV_INDEX_URL is deprecated by UV, we comment it out to avoid a anoying warning, UV_DEFAULT_INDEX is available since uv 0.4.23
# So we decided to support only UV_DEFAULT_INDEX, as we don't inject the uv version in the command pipeline yet.
#
# "UV_INDEX_URL": default_index_url,
#
"UV_DEFAULT_INDEX": default_index_url,
# Credentials for the named index in case of being set in the pyproject.toml
"UV_INDEX_SAFETY_USERNAME": "user",
"UV_INDEX_SAFETY_PASSWORD": index_credentials(ctx),
}
)
return env

View File

@@ -0,0 +1,40 @@
import importlib.metadata as md
import json
import os
def get_package_location(dist):
"""
Get the installation location of a package distribution.
"""
try:
if hasattr(dist, "locate_file") and callable(dist.locate_file):
root = dist.locate_file("")
if root:
return os.path.abspath(str(root))
except (AttributeError, OSError, TypeError):
pass
return ""
def main() -> int:
"""
List all installed packages with their versions and locations.
"""
packages = []
for dist in md.distributions():
packages.append(
{
"name": dist.metadata.get("Name", ""),
"version": dist.version,
"location": get_package_location(dist),
}
)
print(json.dumps(packages, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,239 @@
import logging
import os
from pathlib import Path
import shutil
import sys
from typing import Any, Dict, Optional
import tomlkit
import typer
from rich.console import Console
from safety.console import main_console
from safety.tool.auth import build_index_url
from safety.tool.constants import (
PYPI_ORGANIZATION_REPOSITORY_URL,
PYPI_PUBLIC_REPOSITORY_URL,
PYPI_PROJECT_REPOSITORY_URL,
)
from safety.utils.pyapp_utils import get_path
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
logger = logging.getLogger(__name__)
def backup_file(path: Path) -> None:
"""
Create backup of file if it exists
"""
if path.exists():
backup_path = path.with_name(f"{path.name}.backup")
shutil.copy2(path, backup_path)
class Uv:
@classmethod
def is_installed(cls) -> bool:
"""
Checks if the UV program is installed
Returns:
True if UV is installed on system, or false otherwise
"""
return shutil.which("uv", path=get_path()) is not None
@classmethod
def is_uv_project_file(cls, file: Path) -> bool:
try:
cfg = tomllib.loads(file.read_text())
return (
cfg.get("tool", {}).get("uv") is not None
or (file.parent / "uv.lock").exists()
)
except (IOError, ValueError):
return False
@classmethod
def configure_pyproject(
cls,
file: Path,
org_slug: Optional[str],
project_id: Optional[str] = None,
console: Console = main_console,
) -> Optional[Path]:
"""
Configures index url for specified pyproject.toml file.
Args:
file (Path): Path to pyproject.toml file.
org_slug (Optional[str]): Organization slug.
project_id (Optional[str]): Project ID.
console (Console): Console instance.
"""
if not cls.is_installed():
logger.error("UV is not installed.")
return None
repository_url = (
PYPI_PROJECT_REPOSITORY_URL.format(org_slug, project_id)
if project_id and org_slug
else (
PYPI_ORGANIZATION_REPOSITORY_URL.format(org_slug)
if org_slug
else PYPI_PUBLIC_REPOSITORY_URL
)
)
try:
content = file.read_text()
doc: Dict[str, Any] = tomlkit.loads(content)
if "tool" not in doc:
doc["tool"] = tomlkit.table()
if "uv" not in doc["tool"]: # type: ignore
doc["tool"]["uv"] = tomlkit.table() # type: ignore
if "index" not in doc["tool"]["uv"]: # type: ignore
doc["tool"]["uv"]["index"] = tomlkit.aot() # type: ignore
index_container = doc["tool"]["uv"] # type: ignore
cls.filter_out_safety_index(index_container)
safety_index = {
"name": "safety",
"url": repository_url,
# In UV default:
# True = lowest priority
# False = highest priority
"default": False,
}
non_safety_indexes = (
doc.get("tool", {}).get("uv", {}).get("index", tomlkit.aot())
)
# Add safety index as first priority
index_container["index"] = tomlkit.aot() # type: ignore
index_container["index"].append(safety_index) # type: ignore
index_container["index"].extend(non_safety_indexes) # type: ignore
# Write back to file
file.write_text(tomlkit.dumps(doc))
return file
except (IOError, ValueError, Exception) as e:
logger.error(f"Failed to configure {file} file: {e}")
return None
@classmethod
def get_user_config_path(cls) -> Path:
"""
Returns the path to the user config file for UV.
This logic is based on the uv documentation:
https://docs.astral.sh/uv/configuration/files/
"uv will also discover user-level configuration at
~/.config/uv/uv.toml (or $XDG_CONFIG_HOME/uv/uv.toml) on macOS and Linux,
or %APPDATA%\\uv\\uv.toml on Windows; ..."
Returns:
Path: The path to the user config file.
"""
if sys.platform == "win32":
return Path(os.environ.get("APPDATA", ""), "uv", "uv.toml")
else:
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
if xdg_config_home:
return Path(xdg_config_home, "uv", "uv.toml")
else:
return Path(Path.home(), ".config", "uv", "uv.toml")
@classmethod
def filter_out_safety_index(cls, index_container: Any):
if "index" not in index_container:
return
indexes = list(index_container["index"])
index_container["index"] = tomlkit.aot()
for index in indexes:
index_url = index.get("url", "")
if ".safetycli.com" in index_url:
continue
index_container["index"].append(index)
@classmethod
def configure_system(
cls, org_slug: Optional[str], console: Console = main_console
) -> Optional[Path]:
"""
Configures UV system to use to Safety index url.
"""
try:
repository_url = (
PYPI_ORGANIZATION_REPOSITORY_URL.format(org_slug)
if org_slug
else PYPI_PUBLIC_REPOSITORY_URL
)
user_config_path = cls.get_user_config_path()
if not user_config_path.exists():
user_config_path.parent.mkdir(parents=True, exist_ok=True)
content = ""
else:
backup_file(user_config_path)
content = user_config_path.read_text()
doc = tomlkit.loads(content)
if "index" not in doc:
doc["index"] = tomlkit.aot()
cls.filter_out_safety_index(index_container=doc)
safety_index = tomlkit.aot()
safety_index.append(
{
"name": "safety",
"url": repository_url,
# In UV default:
# True = lowest priority
# False = highest priority
"default": False,
}
)
non_safety_indexes = doc.get("index", tomlkit.aot())
# Add safety index as first priority
doc["index"] = tomlkit.aot()
doc.append("index", safety_index)
doc.append("index", non_safety_indexes)
user_config_path.write_text(tomlkit.dumps(doc))
return user_config_path
except Exception as e:
logger.error(f"Failed to configure UV system: {e}")
return None
@classmethod
def reset_system(cls, console: Console = main_console):
try:
user_config_path = cls.get_user_config_path()
if user_config_path.exists():
backup_file(user_config_path)
content = user_config_path.read_text()
doc = tomlkit.loads(content)
cls.filter_out_safety_index(index_container=doc)
user_config_path.write_text(tomlkit.dumps(doc))
except Exception as e:
msg = "Failed to reset UV global settings"
logger.error(f"{msg}: {e}")
@classmethod
def build_index_url(cls, ctx: typer.Context, index_url: Optional[str]) -> str:
return build_index_url(ctx, index_url, "pypi")

View File

@@ -0,0 +1,160 @@
from typing import Dict, Union, Set
from ..base import ToolCommandLineParser
from ..intents import ToolIntentionType
UV_CACHE_FLAGS = {
"no-cache",
"n",
"refresh",
}
UV_PYTHON_FLAGS = {
"managed-python",
"no-managed-python",
"no-python-downloads",
}
UV_INDEX_FLAGS = {
"no-index",
}
UV_RESOLVER_FLAGS = {
"upgrade",
"U",
"no-sources",
}
UV_INSTALLER_FLAGS = {
"reinstall",
"compile-bytecode",
}
UV_BUILD_FLAGS = {
"no-build-isolation",
"no-build",
"no-binary",
}
UV_GLOBAL_FLAGS = {
"quiet",
"q",
"verbose",
"v",
"native-tls",
"offline",
"no-progress",
"no-config",
"help",
"h",
"version",
"V",
}
UV_PIP_INSTALL_FLAGS = {
"all-extras",
"no-deps",
"require-hashes",
"no-verify-hashes",
"system",
"break-system-packages",
"no-break-system-packages",
"no-build",
"exact",
"strict",
"dry-run",
"user",
}
UV_PIP_UNINSTALL_FLAGS = {
"system",
"break-system-packages",
"no-break-system-packages",
"dry-run",
}
UV_KNOWN_FLAGS: Dict[str, Set[str]] = {
"global": UV_GLOBAL_FLAGS
| UV_CACHE_FLAGS
| UV_PYTHON_FLAGS
| UV_INDEX_FLAGS
| UV_RESOLVER_FLAGS
| UV_INSTALLER_FLAGS
| UV_BUILD_FLAGS,
# 2-level commands
"add": {
# From `uv add --help`
"dev",
"editable",
"raw",
"no-sync",
"locked",
"frozen",
"active",
"workspace",
"no-workspace",
"no-install-project",
"no-install-workspace",
},
"remove": {
"dev",
"no-sync",
"active",
"locked",
"frozen",
},
"sync": {
"all-extras",
"no-dev",
"only-dev",
"no-default-groups",
"all-groups",
"no-editable",
"inexact",
"active",
"no-install-project",
"no-install-workspace",
"locked",
"frozen",
"dry-run",
"all-packages",
"check",
},
# 3-level pip commands
"pip.install": UV_PIP_INSTALL_FLAGS,
"pip.uninstall": UV_PIP_UNINSTALL_FLAGS,
}
class UvParser(ToolCommandLineParser):
def get_tool_name(self) -> str:
return "uv"
def get_command_hierarchy(self) -> Dict[str, Union[ToolIntentionType, Dict]]:
"""
Context for command hierarchy parsing
"""
return {
# 2-level commands
"add": ToolIntentionType.ADD_PACKAGE,
"remove": ToolIntentionType.REMOVE_PACKAGE,
"build": ToolIntentionType.BUILD_PROJECT,
"sync": ToolIntentionType.SYNC_PACKAGES,
# 3-level commands
"pip": {
"install": ToolIntentionType.ADD_PACKAGE,
"uninstall": ToolIntentionType.REMOVE_PACKAGE,
"download": ToolIntentionType.DOWNLOAD_PACKAGE,
"list": ToolIntentionType.LIST_PACKAGES,
},
}
def get_known_flags(self) -> Dict[str, Set[str]]:
"""
Define flags that DON'T take values for uv.
These were derived from `uv --help` and subcommand helps.
"""
return UV_KNOWN_FLAGS