updates
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from .main import Poetry
|
||||
|
||||
__all__ = [
|
||||
"Poetry",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user