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,3 @@
from .main import Pip
__all__ = ["Pip"]

View File

@@ -0,0 +1,116 @@
from typing import TYPE_CHECKING, List, Optional
import logging
import typer
from safety.models import ToolResult
from .parser import PipParser
from ..base import BaseCommand
from safety_schemas.models.events.types import ToolType
from ..environment_diff import EnvironmentDiffTracker, PipEnvironmentDiffTracker
from ..mixins import InstallationAuditMixin
from .main import Pip
if TYPE_CHECKING:
from ..environment_diff import EnvironmentDiffTracker
logger = logging.getLogger(__name__)
class PipCommand(BaseCommand):
"""
Main class for hooks into pip commands.
"""
def get_tool_type(self) -> ToolType:
return ToolType.PIP
def get_command_name(self) -> List[str]:
"""
This uses command alias if available, with this we support
pip3.13, pip3.12, etc.
"""
cmd_name = ["pip"]
if self._command_alias_used:
cmd_name = [self._command_alias_used]
return cmd_name
def get_diff_tracker(self) -> "EnvironmentDiffTracker":
return PipEnvironmentDiffTracker()
@classmethod
def from_args(cls, args: List[str], **kwargs):
parser = PipParser()
if intention := parser.parse(args):
kwargs["intention"] = intention
if intention.modifies_packages():
return AuditablePipCommand(args, **kwargs)
if intention.queries_packages():
return SearchCommand(args, **kwargs)
return PipCommand(args, **kwargs)
class PipIndexEnvMixin:
"""
Mixin to inject Safety's default index URL into pip's environment.
Expects implementers to define `self._index_url` (Optional[str]).
"""
def env(self, ctx: typer.Context) -> dict:
env = super().env(ctx) # pyright: ignore[reportAttributeAccessIssue]
default_index_url = Pip.build_index_url(ctx, getattr(self, "_index_url", None))
env["PIP_INDEX_URL"] = default_index_url
env["PIP_PYPI_URL"] = default_index_url
return env
class SearchCommand(PipIndexEnvMixin, PipCommand):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._index_url = None
class AuditablePipCommand(PipIndexEnvMixin, PipCommand, 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)

View File

@@ -0,0 +1,150 @@
import logging
import re
import shutil
import subprocess
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from safety.tool.constants import (
PYPI_PUBLIC_REPOSITORY_URL,
PYPI_ORGANIZATION_REPOSITORY_URL,
PYPI_PROJECT_REPOSITORY_URL,
)
from safety.tool.resolver import get_unwrapped_command
from safety.utils.pyapp_utils import get_path, get_env
from safety.console import main_console
from safety.tool.auth import build_index_url
from ...encoding import detect_encoding
logger = logging.getLogger(__name__)
class Pip:
@classmethod
def is_installed(cls) -> bool:
"""
Checks if the PIP program is installed
Returns:
True if PIP is installed on system, or false otherwise
"""
return shutil.which("pip", path=get_path()) is not None
@classmethod
def configure_requirements(
cls,
file: Path,
org_slug: Optional[str],
project_id: Optional[str],
console: Console = main_console,
) -> Optional[Path]:
"""
Configures Safety index url for specified requirements file.
Args:
file (Path): Path to requirements.txt file.
org_slug (str): Organization slug.
project_id (str): Project identifier.
console (Console): Console instance.
"""
with open(file, "r+", encoding=detect_encoding(file)) as f:
content = f.read()
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
)
)
index_config = f"-i {repository_url}\n"
if content.find(index_config) == -1:
f.seek(0)
f.write(index_config + content)
logger.info(f"Configured {file} file")
return file
else:
logger.info(f"{file} is already configured. Skipping.")
return None
@classmethod
def configure_system(
cls, org_slug: Optional[str], console: Console = main_console
) -> Optional[Path]:
"""
Configures PIP 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
)
result = subprocess.run(
[
get_unwrapped_command(name="pip"),
"config",
"--user",
"set",
"global.index-url",
repository_url,
],
capture_output=True,
env=get_env(),
)
if result.returncode != 0:
logger.error(
f"Failed to configure PIP global settings: {result.stderr.decode('utf-8')}"
)
return None
output = result.stdout.decode("utf-8")
match = re.search(r"Writing to (.+)", output)
if match:
config_file_path = match.group(1).strip()
return Path(config_file_path)
logger.error("Failed to match the config file path written by pip.")
return Path()
except Exception:
logger.exception("Failed to configure PIP global settings.")
return None
@classmethod
def reset_system(cls, console: Console = main_console):
# TODO: Move this logic and implement it in a more robust way
try:
subprocess.run(
[
get_unwrapped_command(name="pip"),
"config",
"--user",
"unset",
"global.index-url",
],
capture_output=True,
env=get_env(),
)
except Exception:
console.print("Failed to reset PIP global settings.")
@classmethod
def default_index_url(cls) -> str:
return "https://pypi.org/simple/"
@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,105 @@
from typing import Dict
from ..base import ToolCommandLineParser
from ..intents import ToolIntentionType
from typing import Union, Set
class PipParser(ToolCommandLineParser):
def get_tool_name(self) -> str:
return "pip"
def get_command_hierarchy(self) -> Dict[str, Union[ToolIntentionType, Dict]]:
"""
Context for command hierarchy parsing
"""
return {
"install": ToolIntentionType.ADD_PACKAGE,
"uninstall": ToolIntentionType.REMOVE_PACKAGE,
"download": ToolIntentionType.DOWNLOAD_PACKAGE,
"wheel": ToolIntentionType.DOWNLOAD_PACKAGE,
"query": ToolIntentionType.SEARCH_PACKAGES,
"index": {
"versions": ToolIntentionType.SEARCH_PACKAGES,
},
}
def get_known_flags(self) -> Dict[str, Set[str]]:
"""
Define flags that DON'T take values to avoid consuming packages
"""
return {
# Global flags (available for all commands)
"global": {
"help",
"h",
"debug",
"isolated",
"require-virtualenv",
"verbose",
"v",
"version",
"V",
"quiet",
"q",
"no-input",
"no-cache-dir",
"disable-pip-version-check",
"no-color",
# Index specific
"no-index",
},
# install-specific flags
"install": {
"no-deps",
"pre",
"dry-run",
"user",
"upgrade",
"U",
"force-reinstall",
"ignore-installed",
"I",
"ignore-requires-python",
"no-build-isolation",
"use-pep517",
"no-use-pep517",
"check-build-dependencies",
"break-system-packages",
"compile",
"no-compile",
"no-warn-script-location",
"no-warn-conflicts",
"prefer-binary",
"require-hashes",
"no-clean",
},
# uninstall-specific flags
"uninstall": {
"yes",
"y",
"break-system-packages",
},
# download-specific flags
"download": {
"no-deps",
"no-binary",
"only-binary",
"prefer-binary",
"pre",
"require-hashes",
"no-build-isolation",
"use-pep517",
"no-use-pep517",
"check-build-dependencies",
"ignore-requires-python",
"no-clean",
},
"index.versions": {
"ignore-requires-python",
"pre",
"json",
"no-index",
},
}