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

View File

@@ -0,0 +1,178 @@
from pathlib import Path
import sys
from typing import TYPE_CHECKING, List, Optional, Tuple
import logging
import typer
from safety.tool.utils import PoetryPyprojectConfigurator
from .constants import MSG_SAFETY_SOURCE_ADDED, MSG_SAFETY_SOURCE_NOT_ADDED
from .parser import PoetryParser
from ..auth import index_credentials
from ..base import BaseCommand, ToolIntentionType
from ..mixins import InstallationAuditMixin
from ..environment_diff import EnvironmentDiffTracker, PipEnvironmentDiffTracker
from safety_schemas.models.events.types import ToolType
from safety.console import main_console as console
from safety.models import ToolResult
if TYPE_CHECKING:
from ..environment_diff import EnvironmentDiffTracker
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
logger = logging.getLogger(__name__)
class PoetryCommand(BaseCommand):
"""
Main class for hooks into poetry commands.
"""
def get_tool_type(self) -> ToolType:
return ToolType.POETRY
def get_command_name(self) -> List[str]:
return ["poetry"]
def get_diff_tracker(self) -> "EnvironmentDiffTracker":
# pip diff tracker will be enough for poetry
return PipEnvironmentDiffTracker()
def get_package_list_command(self) -> List[str]:
"""
Get the package list of a poetry virtual environment.
This implementation uses pip to list packages.
Returns:
List[str]: Command to list packages in JSON format
"""
return ["poetry", "run", "pip", "list", "-v", "--format=json"]
def _has_safety_source_in_pyproject(self) -> bool:
"""
Check if 'safety' source exists in pyproject.toml
"""
if not Path("pyproject.toml").exists():
return False
try:
# Parse the TOML file
with open("pyproject.toml", "rb") as f:
pyproject = tomllib.load(f)
poetry_config = pyproject.get("tool", {}).get("poetry", {})
sources = poetry_config.get("source", [])
if isinstance(sources, dict):
return "safety" in sources
else:
return any(source.get("name") == "safety" for source in sources)
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
return False
def before(self, ctx: typer.Context):
super().before(ctx)
if self._intention and self._intention.intention_type in [
ToolIntentionType.SYNC_PACKAGES,
ToolIntentionType.ADD_PACKAGE,
]:
if not self._has_safety_source_in_pyproject():
org_slug = None
try:
data = ctx.obj.auth.client.initialize()
org_slug = data.get("organization-data", {}).get("slug")
except Exception:
logger.exception(
"Unable to pull the org slug from the initialize endpoint."
)
try:
configurator = PoetryPyprojectConfigurator()
prj_slug = ctx.obj.project.id if ctx.obj.project else None
if configurator.configure(
Path("pyproject.toml"), org_slug, prj_slug
):
console.print(
MSG_SAFETY_SOURCE_ADDED,
)
except Exception:
logger.exception("Unable to configure the pyproject.toml file.")
console.print(
MSG_SAFETY_SOURCE_NOT_ADDED,
)
def env(self, ctx: typer.Context) -> dict:
env = super().env(ctx)
env.update(
{
"POETRY_HTTP_BASIC_SAFETY_USERNAME": "user",
"POETRY_HTTP_BASIC_SAFETY_PASSWORD": index_credentials(ctx),
}
)
return env
@classmethod
def from_args(cls, args: List[str], **kwargs):
parser = PoetryParser()
if intention := parser.parse(args):
kwargs["intention"] = intention
if intention.modifies_packages():
return AuditablePoetryCommand(args, **kwargs)
return PoetryCommand(args, **kwargs)
class AuditablePoetryCommand(PoetryCommand, InstallationAuditMixin):
def patch_source_option(
self, args: List[str], new_source: str = "safety"
) -> Tuple[Optional[str], List[str]]:
"""
Find --source argument and its value in a list of args, create a modified copy
with your custom source, and return both.
Args:
args: List[str] - Command line arguments
Returns:
tuple: (source_value, modified_args, original_args)
"""
source_value = None
modified_args = args.copy()
for i in range(len(args)):
if args[i].startswith("--source="):
# Handle --source=value format
source_value = args[i].split("=", 1)[1]
modified_args[i] = f"--source={new_source}"
break
elif args[i] == "--source" and i < len(args) - 1:
# Handle --source value format
source_value = args[i + 1]
modified_args[i + 1] = new_source
break
return source_value, modified_args
def before(self, ctx: typer.Context):
super().before(ctx)
_, modified_args = self.patch_source_option(self._args)
self._args = modified_args
def after(self, ctx: typer.Context, result: ToolResult):
super().after(ctx, result)
self.handle_installation_audit(ctx, result)

View File

@@ -0,0 +1,4 @@
MSG_SAFETY_SOURCE_NOT_ADDED = "\nError: Safety Firewall could not be added as a source in your pyproject.toml file. You will not be protected from malicious or insecure packages. Please run `safety init` to fix this."
MSG_SAFETY_SOURCE_ADDED = (
"\nSafety Firewall has been added as a source to protect this codebase"
)

View File

@@ -0,0 +1,103 @@
import logging
import shutil
import subprocess
from pathlib import Path
import sys
from typing import Optional
from rich.console import Console
from safety.console import main_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
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
logger = logging.getLogger(__name__)
class Poetry:
@classmethod
def is_installed(cls) -> bool:
"""
Checks if the Poetry program is installed
Returns:
True if Poetry is installed on system, or false otherwise
"""
return shutil.which("poetry", path=get_path()) is not None
@classmethod
def is_poetry_project_file(cls, file: Path) -> bool:
try:
cfg = tomllib.loads(file.read_text())
# First check: tool.poetry section (most definitive)
if "tool" in cfg and "poetry" in cfg.get("tool", {}):
return True
# Extra check on build-system section
build_backend = cfg.get("build-system", {}).get("build-backend", "")
if build_backend and "poetry.core" in build_backend:
return True
return False
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 requirements file.
Args:
file (Path): Path to requirements.txt file.
org_slug (Optional[str]): Organization slug.
project_id (Optional[str]): Project ID.
console (Console): Console instance.
"""
if not cls.is_installed():
logger.error("Poetry 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
)
)
result = subprocess.run(
[
get_unwrapped_command(name="poetry"),
"source",
"add",
"safety",
repository_url,
],
capture_output=True,
env=get_env(),
)
if result.returncode != 0:
logger.error(f"Failed to configure {file} file")
return None
return file

View File

@@ -0,0 +1,134 @@
from typing import Dict, Optional, Union, Set
from ..base import ToolCommandLineParser
from ..intents import Dependency, ToolIntentionType
class PoetryParser(ToolCommandLineParser):
def get_tool_name(self) -> str:
return "poetry"
def get_command_hierarchy(self) -> Dict[str, Union[ToolIntentionType, Dict]]:
"""
Allow base parser to recognize poetry commands and intentions.
"""
return {
"add": ToolIntentionType.ADD_PACKAGE,
"remove": ToolIntentionType.REMOVE_PACKAGE,
"update": ToolIntentionType.UPDATE_PACKAGE,
"install": ToolIntentionType.SYNC_PACKAGES,
"build": ToolIntentionType.BUILD_PROJECT,
"show": ToolIntentionType.LIST_PACKAGES,
"init": ToolIntentionType.INIT_PROJECT,
}
def get_known_flags(self) -> Dict[str, Set[str]]:
"""
Flags that DO NOT take a value, derived from `poetry --help` and subcommand helps.
"""
return {
"global": {
"help",
"h",
"quiet",
"q",
"version",
"V",
"ansi",
"no-ansi",
"no-interaction",
"n",
"no-plugins",
"no-cache",
"verbose",
"v",
"vv",
"vvv",
},
"add": {
"dev",
"D",
"editable",
"e",
"allow-prereleases",
"dry-run",
"lock",
},
"remove": {
"dev",
"D",
"dry-run",
"lock",
},
"update": {
"sync",
"dry-run",
"lock",
},
"install": {
"sync",
"no-root",
"no-directory",
"dry-run",
"all-extras",
"all-groups",
"only-root",
"compile",
},
"build": {
"clean",
},
}
def _parse_package_spec(
self, spec_str: str, arg_index: int
) -> Optional[Dependency]:
"""
Parse a package specification string into a Dependency object.
Handles various formats including Poetry-specific syntax and standard PEP 508 requirements.
Args:
spec_str: Package specification string (e.g. "requests>=2.25.0[security]")
Returns:
Dependency: Parsed dependency information
Raises:
ValueError: If the specification cannot be parsed
"""
try:
# TODO: This is a very basic implementation and not well tested
# our main target for now is to get the package name.
from packaging.requirements import Requirement
include_specifier = False
# Handle @ operator (package@version)
if "@" in spec_str and not spec_str.startswith("git+"):
name = spec_str.split("@")[0]
# Handle caret requirements (package^version)
elif "^" in spec_str:
name = spec_str.split("^")[0]
# Handle tilde requirements (package~version)
elif "~" in spec_str and "~=" not in spec_str:
name = spec_str.split("~")[0]
else:
# Common PEP 440 cases
name = spec_str
include_specifier = True
req = Requirement(name)
return Dependency(
name=req.name,
version_constraint=str(req.specifier) if include_specifier else None,
extras=req.extras,
arg_index=arg_index,
original_text=spec_str,
)
except Exception:
# If spec parsing fails, just ignore for now
return None