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,15 @@
from .base import Event, PayloadBase
from .context import EventContext
from .types import EventTypeBase, EventType, ParamSource, SourceType
__all__ = [
"Event",
"PayloadBase",
"EventContext",
"EventTypeBase",
"EventType",
"ParamSource",
"SourceType",
]

View File

@@ -0,0 +1,39 @@
from typing import Generic, Optional, TypeVar
from typing_extensions import Annotated
import uuid
from pydantic import UUID4, BaseModel, BeforeValidator, Field
from .types import EventTypeBase, SourceType
class PayloadBase(BaseModel):
"""
Base class for all event payloads
"""
pass
# Generics
PayloadT = TypeVar("PayloadT", bound=PayloadBase)
EventTypeT = TypeVar("EventTypeT", bound=EventTypeBase)
def convert_to_source_type(v):
if isinstance(v, str):
try:
return SourceType(v)
except ValueError:
pass
return v
class Event(BaseModel, Generic[EventTypeT, PayloadT]):
id: UUID4 = Field(default_factory=lambda: uuid.uuid4())
timestamp: int
type: EventTypeT
source: Annotated[SourceType, BeforeValidator(convert_to_source_type)]
correlation_id: Optional[str] = Field(
description="Unique identifier for tracing related events",
default=None,
)
payload: PayloadT

View File

@@ -0,0 +1,9 @@
SAFETY_NAMESPACE = "safetycli"
PRODUCT_CLI = "cli"
GITHUB = "github"
PYPI = "pypi"
DOCKER = "docker"
ACTION = "action"
APP = "app"
CLI_SOURCE = f"urn:{SAFETY_NAMESPACE}:{PRODUCT_CLI}"

View File

@@ -0,0 +1,114 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from .types import SourceType
from typing_extensions import Annotated
from pydantic.types import StringConstraints
class ClientInfo(BaseModel):
"""
Information about the client application.
"""
identifier: SourceType = Field(description="Client source identifier name")
version: str = Field(description="Client application version")
path: str = Field(description="Path to the client executable")
class ProjectInfo(BaseModel):
"""
Information about the project context.
"""
id: str = Field(default="unknown", description="Project identifier")
url: Optional[str] = Field(default=None, description="Project URL")
class UserInfo(BaseModel):
"""
Information about the user.
"""
name: str = Field(description="Username")
home_dir: str = Field(description="User's home directory")
class OsInfo(BaseModel):
"""
Information about the operating system.
"""
architecture: Annotated[str, StringConstraints(to_lower=True)] = Field(description="Machine architecture")
platform: Annotated[str, StringConstraints(to_lower=True)] = Field(description="Operating system platform")
name: Annotated[Optional[str], StringConstraints(to_lower=True)] = Field(description="Operating system name")
version: Annotated[Optional[str], StringConstraints(to_lower=True)] = Field(description="Operating system version")
kernel_version: Annotated[Optional[str], StringConstraints(to_lower=True)] = Field(
default=None, description="Kernel version if available"
)
class HostInfo(BaseModel):
"""
Information about the host machine.
"""
name: str = Field(description="Hostname")
ipv4: Optional[str] = Field(default=None, description="IPv4 address")
ipv6: Optional[str] = Field(default=None, description="IPv6 address")
timezone: Optional[str] = Field(default=None, description="Timezone")
class PythonInfo(BaseModel):
"""
Detailed information about the Python environment.
"""
version: str = Field(description="Python version (major.minor)")
path: str = Field(description="Path to the Python executable")
sys_path: List[str] = Field(description="Python sys.path")
implementation: Optional[str] = Field(
default=None, description="Python implementation (e.g., 'CPython')"
)
implementation_version: Optional[str] = Field(
default=None, description="Python implementation version"
)
sys_prefix: str = Field(description="sys.prefix location")
site_packages: List[str] = Field(description="List of site-packages directories")
user_site_enabled: bool = Field(
description="Whether user site-packages are enabled for imports"
)
user_site_packages: Optional[str] = Field(
default=None, description="User site-packages directory path if available"
)
encoding: str = Field(description="Default string encoding")
filesystem_encoding: str = Field(description="Filesystem encoding")
class RuntimeInfo(BaseModel):
"""
Information about the runtime environment.
"""
workdir: str = Field(description="Working directory")
user: UserInfo = Field(description="User information")
os: OsInfo = Field(description="Operating system information")
host: HostInfo = Field(description="Host information")
python: Optional[PythonInfo] = Field(default=None, description="Python information")
class EventContext(BaseModel):
"""
Complete context information for an event.
Contains details about the client, project, and runtime environment.
"""
client: ClientInfo = Field(description="Client application information")
runtime: RuntimeInfo = Field(description="Runtime environment information")
project: Optional[ProjectInfo] = Field(
default=None, description="Project information"
)
tags: Optional[List[str]] = Field(
default=None, description="Event tags for categorization"
)

View File

@@ -0,0 +1,68 @@
from .main import (
CommandParam,
CommandExecutedPayload,
CommandErrorPayload,
PackagePayloadBase,
SingleVersionPackagePayload,
ToolCommandExecutedPayload,
PackageInstalledPayload,
PackageUninstalledPayload,
PackageUpdatedPayload,
HealthCheckResult,
IndexConfig,
AliasConfig,
ToolStatus,
FirewallConfiguredPayload,
FirewallDisabledPayload,
FirewallHeartbeatPayload,
ProcessStatus,
)
from .onboarding import (
InitStartedPayload,
AuthStartedPayload,
AuthCompletedPayload,
FirewallSetupResponseCreatedPayload,
FirewallSetupCompletedPayload,
CodebaseSetupResponseCreatedPayload,
CodebaseSetupCompletedPayload,
DependencyFile,
CodebaseDetectionStatusPayload,
InitScanCompletedPayload,
InitExitStep,
InitExitedPayload,
)
__all__ = [
"CommandParam",
"CommandExecutedPayload",
"CommandErrorPayload",
"PackagePayloadBase",
"SingleVersionPackagePayload",
"PackageInstalledPayload",
"PackageUninstalledPayload",
"PackageUpdatedPayload",
"HealthCheckResult",
"IndexConfig",
"AliasConfig",
"ToolStatus",
"FirewallConfiguredPayload",
"FirewallDisabledPayload",
"FirewallHeartbeatPayload",
"ProcessStatus",
"ToolCommandExecutedPayload",
# Onboarding
"InitStartedPayload",
"AuthStartedPayload",
"AuthCompletedPayload",
"FirewallSetupResponseCreatedPayload",
"FirewallSetupCompletedPayload",
"CodebaseSetupResponseCreatedPayload",
"CodebaseSetupCompletedPayload",
"DependencyFile",
"CodebaseDetectionStatusPayload",
"InitScanCompletedPayload",
"InitExitStep",
"InitExitedPayload",
]

View File

@@ -0,0 +1,223 @@
from typing import Any, List, Optional
from pydantic import BaseModel, Field
from ..base import PayloadBase
from ..types import LimitedStr, ParamSource, StackTrace, StdErr, StdOut, ToolType
class CommandParam(BaseModel):
position: int = Field(description="Position in the original command")
name: Optional[LimitedStr] = Field(
default=None, description="Name of the option, None for positional arguments"
)
value: Any = Field(description="Value of the argument or option")
source: ParamSource = Field(
ParamSource.UNKNOWN,
description="Source of the parameter value (commandline, environment, config, default, prompt)",
)
@property
def is_option(self) -> bool:
"""
Return True if this is a named option, False if positional argument
"""
return self.name is not None
class ProcessStatus(BaseModel):
stdout: Optional[StdOut] = Field(
default=None, description="Standard output of the process"
)
stderr: Optional[StdErr] = Field(
default=None, description="Standard error of the process"
)
return_code: int = Field(description="Return code of the process")
class CommandExecutedPayload(PayloadBase):
command_name: str = Field(
description="Primary command name (e.g., 'status', 'scan')"
)
command_path: List[LimitedStr] = Field(
description="Command path as a list (e.g., ['safety', 'auth', 'login'])"
)
raw_command: List[LimitedStr] = Field(
description="Complete command as a list (equivalent to sys.argv)"
)
parameters: List[CommandParam] = Field(
description="Parameters defined by the us", default_factory=list
)
duration_ms: int = Field(
gt=0,
description="Execution time in milliseconds for the full command "
"including any tool call",
)
status: ProcessStatus = Field(
description="Status data (stdout/stderr/return_code) when applicable"
)
class ToolCommandExecutedPayload(PayloadBase):
"""
Information about a wrapped command execution.
"""
tool: ToolType = Field(
description="Tool Type (e.g., 'pip', 'uv', 'poetry', 'npm')"
)
tool_path: Optional[str] = Field(default=None, description="Absolute path to the tool's executable")
raw_command: List[LimitedStr] = Field(
description="Complete command as a list (equivalent to sys.argv)"
)
duration_ms: int = Field(
gt=0,
description="Execution time in milliseconds",
)
status: ProcessStatus = Field(
description="Status data (stdout/stderr/return_code) when applicable"
)
class CommandErrorPayload(PayloadBase):
command_name: Optional[LimitedStr] = Field(
description="Name of the command that failed"
)
command_path: Optional[List[LimitedStr]] = Field(
description="Command path as a list (e.g., ['safety', 'auth', 'login'])"
)
raw_command: List[LimitedStr] = Field(
description="Complete command as a list (equivalent to sys.argv)"
)
error_message: str = Field(description="Error message")
stacktrace: Optional[StackTrace] = Field(
default=None, description="Stack trace if available"
)
class PackagePayloadBase(PayloadBase):
package_name: str = Field(description="Name of the package")
tool: ToolType = Field(description="ToolType used (e.g., pip, conda)")
tool_path: Optional[str] = Field(default=None, description="Absolute path to the tool's executable")
location: Optional[str] = Field(default=None, description="Location of the package")
class SingleVersionPackagePayload(PackagePayloadBase):
version: str = Field(description="Version of the package")
class PackageInstalledPayload(SingleVersionPackagePayload):
pass
class PackageUninstalledPayload(SingleVersionPackagePayload):
pass
class PackageUpdatedPayload(PackagePayloadBase):
previous_version: str = Field(description="Previous package version")
current_version: str = Field(description="Current package version")
class HealthCheckResult(BaseModel):
"""
Generic health check result structure.
"""
is_alive: bool = Field(description="Whether the entity is alive and responding")
response_time_ms: Optional[int] = Field(
None, description="Response time in milliseconds"
)
error_message: Optional[LimitedStr] = Field(
None, description="Error message if any"
)
timestamp: str = Field(description="When the health check was performed")
class IndexConfig(BaseModel):
"""
Configuration details for the package index.
"""
is_configured: bool = Field(
description="Whether the index configuration is in place"
)
index_url: Optional[LimitedStr] = Field(
default=None, description="URL of the configured package index"
)
health_check: Optional[HealthCheckResult] = Field(
default=None, description="Health check for the index"
)
class AliasConfig(BaseModel):
"""
Configuration details for the command alias.
"""
is_configured: bool = Field(description="Whether the alias is configured")
alias_content: Optional[LimitedStr] = Field(
default=None, description="Content of the alias"
)
health_check: Optional[HealthCheckResult] = Field(
default=None, description="Health check for the alias"
)
class ToolStatus(BaseModel):
"""
Status of a single package manager tool. A single package manager tool is
being identified by its executable path.
"""
type: ToolType = Field(description="Tool type")
command_path: str = Field(description="Absolute path to the tool's executable")
version: str = Field(description="Version of the tool")
reachable: bool = Field(
description="Whether the tool's package manager is reachable bypassing any firewall setup"
)
# Configuration information
alias_config: Optional[AliasConfig] = Field(
default=None, description="Details about the alias configuration"
)
index_config: Optional[IndexConfig] = Field(
default=None, description="Details about the index configuration"
)
@property
def alias_configured(self) -> bool:
"""
Whether the alias is configured.
"""
return self.alias_config is not None and self.alias_config.is_configured
@property
def index_configured(self) -> bool:
"""
Whether the index is configured.
"""
return self.index_config is not None and self.index_config.is_configured
@property
def is_configured(self) -> bool:
"""
Returns whether the tool is fully configured (both alias and index).
"""
return self.alias_configured and self.index_configured
class FirewallConfiguredPayload(PayloadBase):
tools: List[ToolStatus] = Field(
description="Status of all detected package manager tools"
)
class FirewallDisabledPayload(PayloadBase):
reason: Optional[LimitedStr] = Field(
description="Reason for disabling the firewall"
)
class FirewallHeartbeatPayload(PayloadBase):
tools: List[ToolStatus] = Field(
description="Status of all detected package manager tools"
)

View File

@@ -0,0 +1,136 @@
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from ..base import PayloadBase
from ..types import LimitedStr, ToolType
from .main import ToolStatus
from enum import Enum
class InitStartedPayload(PayloadBase):
"""
Payload for the Init Started event.
This is emitted when the init command is started.
Note: This event is typically delayed until the user completes authentication.
"""
# This is an empty payload as the timestamp is already in the event
pass
class AuthStartedPayload(PayloadBase):
"""
Payload for the Auth Started event.
This is emitted when the authentication flow is initiated and a URL is shown to the user.
"""
auth_url: Optional[LimitedStr] = Field(
default=None, description="URL provided to the user for authentication"
)
class AuthCompletedPayload(PayloadBase):
"""
Payload for the Auth Completed event.
This is emitted when the authentication flow is completed.
"""
success: bool = Field(description="Whether authentication was successful")
error_message: Optional[LimitedStr] = Field(
default=None, description="Error message if authentication failed"
)
class FirewallSetupResponseCreatedPayload(PayloadBase):
"""
Payload for the Firewall Setup Response Created event.
This captures the user's choice to install the firewall (Y/N).
"""
user_consent_requested: bool = Field(
description="Whether the user was asked for consent to install the firewall"
)
user_consent: Optional[bool] = Field(
default=None, description="User's consent to install the firewall (True for yes, False for no, None if unknown)"
)
class FirewallSetupCompletedPayload(PayloadBase):
"""
Payload for the Firewall Setup Completed event.
This is emitted when the firewall is configured. This payload has the current status of all tools.
"""
tools: List[ToolStatus] = Field(
description="Status of all configured package manager tools"
)
class DependencyFile(BaseModel):
"""
Information about a detected dependency file.
"""
file_path: str = Field(description="Path to the detected dependency file")
class CodebaseDetectionStatusPayload(PayloadBase):
"""
Payload for the Codebase Detection Status event.
This is emitted when the codebase is detected.
"""
detected: bool = Field(description="Whether a codebase was detected")
dependency_files: Optional[List[DependencyFile]] = Field(
default=None, description="List of detected dependency files"
)
class CodebaseSetupResponseCreatedPayload(PayloadBase):
"""
Payload for the Codebase Setup Response Created event.
This captures the user's choice to add a codebase (Y/N).
"""
user_consent_requested: bool = Field(
description="Whether the user was asked for consent to add a codebase"
)
user_consent: Optional[bool] = Field(
default=None, description="User's consent to add a codebase (True for yes, False for no, None if unknown)"
)
class CodebaseSetupCompletedPayload(PayloadBase):
"""
Payload for the Codebase Setup Completed event.
This is emitted when a codebase is successfully created or verified.
"""
is_created: bool = Field(description="Whether the codebase was created")
codebase_id: Optional[str] = Field(default=None, description="ID of the codebase")
class InitScanCompletedPayload(PayloadBase):
"""
Payload for the Init Scan Completed event.
This is emitted when the initial scan completes.
"""
scan_id: Optional[str] = Field(default=None, description="ID of the completed scan")
class InitExitStep(str, Enum):
"""
Possible steps where the init process could be exited.
"""
PRE_AUTH = "pre_authentication"
POST_AUTH = "post_authentication"
PRE_FIREWALL_SETUP = "pre_firewall_setup"
POST_FIREWALL_SETUP = "post_firewall_setup"
PRE_CODEBASE_SETUP = "pre_codebase_setup"
POST_CODEBASE_SETUP = "post_codebase_setup"
PRE_SCAN = "pre_scan"
POST_SCAN = "post_scan"
COMPLETED = "completed"
UNKNOWN = "unknown"
class InitExitedPayload(PayloadBase):
"""
Payload for the Init Exited event.
This is emitted when the user exits the init process (e.g., via Ctrl+C).
"""
exit_step: InitExitStep = Field(
description="The last step known before the user exited"
)

View File

@@ -0,0 +1,187 @@
from enum import Enum
from functools import partial
from typing import Any, Optional, Union
from pydantic import BeforeValidator
from typing_extensions import Annotated
from .constants import CLI_SOURCE, GITHUB, ACTION, PYPI, DOCKER, APP
class SourceType(str, Enum):
"""
Define the source types using URN format for product identification.
"""
SAFETY_CLI_GITHUB_ACTION = f"{CLI_SOURCE}:{GITHUB}:{ACTION}"
SAFETY_CLI_PYPI = f"{CLI_SOURCE}:{PYPI}"
SAFETY_CLI_DOCKER = f"{CLI_SOURCE}:{DOCKER}"
SAFETY_CLI_GITHUB_APP = f"{CLI_SOURCE}:{GITHUB}:{APP}"
@property
def description(self) -> str:
"""
Return a human-readable description for this source type.
"""
descriptions = {
self.SAFETY_CLI_GITHUB_ACTION: "Safety CLI via GitHub Action",
self.SAFETY_CLI_PYPI: "Safety CLI via Python Package Index (PyPI)",
self.SAFETY_CLI_DOCKER: "Safety CLI via Docker",
self.SAFETY_CLI_GITHUB_APP: "Safety CLI via GitHub App",
}
return descriptions[self]
@classmethod
def choices(cls):
"""
Return this Enum as choices format (value, display_name).
"""
return [(item.value, item.description) for item in cls]
class EventTypeBase(str, Enum):
"""
Base class for all event types
"""
pass
class EventType(EventTypeBase):
"""
Enumeration for different types of events.
"""
COMMAND_ERROR = "com.safetycli.command.error"
COMMAND_EXECUTED = "com.safetycli.command.executed"
TOOL_COMMAND_EXECUTED = "com.safetycli.tool.command.executed"
PACKAGE_INSTALLED = "com.safetycli.package.installed"
PACKAGE_UPDATED = "com.safetycli.package.updated"
PACKAGE_UNINSTALLED = "com.safetycli.package.uninstalled"
PACKAGE_BLOCKED = "com.safetycli.package.blocked"
FIREWALL_HEARTBEAT = "com.safetycli.firewall.heartbeat"
FIREWALL_CONFIGURED = "com.safetycli.firewall.configured"
FIREWALL_DISABLED = "com.safetycli.firewall.disabled"
INIT_STARTED = "com.safetycli.init.started"
AUTH_STARTED = "com.safetycli.auth.started"
AUTH_COMPLETED = "com.safetycli.auth.completed"
FIREWALL_SETUP_RESPONSE_CREATED = "com.safetycli.firewall.setup.response.created"
FIREWALL_SETUP_COMPLETED = "com.safetycli.firewall.setup.completed"
CODEBASE_DETECTION_STATUS = "com.safetycli.codebase.detection.status"
CODEBASE_SETUP_RESPONSE_CREATED = "com.safetycli.codebase.setup.response.created"
CODEBASE_SETUP_COMPLETED = "com.safetycli.codebase.setup.completed"
INIT_SCAN_COMPLETED = "com.safetycli.init.scan.completed"
INIT_EXITED = "com.safetycli.init.exited"
@property
def description(self) -> str:
"""
Return a human-readable description for this event type.
"""
descriptions = {
self.COMMAND_ERROR: "Command Error",
self.COMMAND_EXECUTED: "Command Executed",
self.TOOL_COMMAND_EXECUTED: "Tool Command Executed",
self.PACKAGE_INSTALLED: "Package Installed",
self.PACKAGE_UPDATED: "Package Updated",
self.PACKAGE_UNINSTALLED: "Package Uninstalled",
self.PACKAGE_BLOCKED: "Package Blocked",
self.FIREWALL_HEARTBEAT: "Firewall Heartbeat",
self.FIREWALL_CONFIGURED: "Firewall Configured",
self.FIREWALL_DISABLED: "Firewall Disabled",
self.INIT_STARTED: "Init Started",
self.AUTH_STARTED: "Auth Started",
self.AUTH_COMPLETED: "Auth Completed",
self.FIREWALL_SETUP_RESPONSE_CREATED: "Firewall Setup Response Created",
self.FIREWALL_SETUP_COMPLETED: "Firewall Setup Completed",
self.CODEBASE_DETECTION_STATUS: "Codebase Detection Status",
self.CODEBASE_SETUP_RESPONSE_CREATED: "Codebase Setup Response Created",
self.CODEBASE_SETUP_COMPLETED: "Codebase Setup Completed",
self.INIT_SCAN_COMPLETED: "Init Scan Completed",
self.INIT_EXITED: "Init Exited",
}
return descriptions[self]
@classmethod
def choices(cls):
"""
Return this Enum as choices format (value, display_name).
"""
return [(item.value, item.description) for item in cls]
class ParamSource(str, Enum):
"""
Matches Click's parameter sources
"""
COMMANDLINE = "commandline"
ENVIRONMENT = "environment"
CONFIG = "config"
DEFAULT = "default"
PROMPT = "prompt"
# Useful for tracking when we couldn't determine the source
UNKNOWN = "unknown"
class ToolType(str, Enum):
"""
Supported tools.
"""
PIP = "pip"
POETRY = "poetry"
UV = "uv"
CONDA = "conda"
NPM = "npm"
DEFAULT_MAX_BYTES: int = 32 * 1024 # 32 KB
DEFAULT_ENCODING = "utf-8"
def truncate_by_chars(
value: Union[str, bytes, Any],
max_chars: int,
encoding: str = DEFAULT_ENCODING
) -> str:
"""
Truncates a value to a maximum number of characters.
"""
# Convert to string if needed
if isinstance(value, bytes):
value = value.decode(encoding, errors="replace")
elif not isinstance(value, str):
value = str(value)
return value[:max_chars]
def truncate_by_bytes(
value: Union[str, bytes, Any],
max_bytes: int,
encoding: str = DEFAULT_ENCODING
) -> str:
"""
Truncates a value to a maximum byte size.
"""
# Convert to bytes if needed
if not isinstance(value, bytes):
value = str(value).encode(encoding, errors="replace")
# Truncate and convert back to string
return value[:max_bytes].decode(encoding, errors="ignore")
StdOut = Annotated[
str, BeforeValidator(partial(truncate_by_bytes, max_bytes=DEFAULT_MAX_BYTES))
]
StdErr = Annotated[
str, BeforeValidator(partial(truncate_by_bytes, max_bytes=DEFAULT_MAX_BYTES))
]
StackTrace = Annotated[
str, BeforeValidator(partial(truncate_by_bytes, max_bytes=DEFAULT_MAX_BYTES))
]
LimitedStr = Annotated[str, BeforeValidator(partial(truncate_by_chars, max_chars=200))]