diff --git a/README.md b/README.md index 014c8d7..020bcf2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ in an audit log. | Scoring | Configurable score aggregation and level propagation across observable and Finding links | | Composition | Deterministic merging, shared context for parallel tasks, and investigation comparison | | Serialization | Versioned JSON schema, JSON/Markdown export, migration from v5, and generated TypeScript types | -| Tooling | CLI inspection, statistics, IOC extraction, Rich output, and optional graph visualization | +| Tooling | CLI inspection, statistics, IOC extraction, and Rich output | Cyvest 6 uses a strict `schema_version: "6.0.0"`. Existing v5 integrations should follow the [migration guide](docs/migration-v5-to-v6.md). @@ -41,11 +41,8 @@ uv pip install -e . pip install -e . ``` -The optional visualization dependencies are available with: - -```bash -pip install -e ".[visualization]" -``` +Graph visualization is provided by the `@cyvest/cyvest-vis` React package, see +[docs/js-packages.md](docs/js-packages.md). ## Quick Start @@ -538,7 +535,7 @@ See the `examples/` directory for complete examples: - **02_urls_and_ips.py**: Network investigation with URLs and IPs - **03_merge_demo.py**: Multi-process investigation merging - **04_email.py**: Multi-threaded investigation with SharedInvestigationContext -- **05_visualization.py**: Interactive HTML visualization showcasing scores, levels, and relationship flows +- **05_graph_dataset.py**: Rich investigation exported as JSON for the `@cyvest/cyvest-vis` graph renderer - **06_compare_investigations.py**: Compare investigations with tolerance rules and visual diff output Run an example: @@ -546,7 +543,7 @@ Run an example: ```bash python examples/01_email_basic.py python examples/04_email.py -python examples/05_visualization.py +python examples/05_graph_dataset.py ``` ## CLI Usage @@ -572,9 +569,6 @@ cyvest merge inv1.json inv2.json -o merged.json --stats # Merge and display rich summary cyvest merge inv1.json inv2.json -o merged.json -f rich --stats -# Generate an interactive visualization (requires visualization extra) -cyvest visualize investigation.json --min-level SUSPICIOUS --group-by-type - # Extract observables (IOCs) from text echo "Check IP 192.168.1.1 and https://evil.com" | cyvest extract cyvest extract report.txt -t url -t ip -o iocs.txt diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 16a1f2d..94fa4af 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -77,7 +77,7 @@ When dependencies change upstream, re-run `uv sync --all-extras` (or `pip instal ## Optional Integrations - `mkdocs` + `mkdocs-material` for local docs previews (`mkdocs serve`) -- `pyvis` via `pip install "cyvest[visualization]"` for the interactive network graph CLI +- `@cyvest/cyvest-vis` for interactive graph rendering, see [JS packages](../js-packages.md) - Any asyncio, queue, or orchestration library—Cyvest stays synchronous but interoperates through shared context managers --- diff --git a/examples/04_email.py b/examples/04_email.py index eb4e7f2..86b1b9d 100644 --- a/examples/04_email.py +++ b/examples/04_email.py @@ -218,9 +218,7 @@ def run(self, cy: Cyvest) -> None: cy.enrichment_create("receiver", {"receiver": ["ok"]}, context="from splunk") receiver = cy.observable(cy.OBS.EMAIL, "user@company.com") cy.root().relate_to(receiver, cy.REL.EXTRACTION) - cy.finding("receiver", "description", "> receiver").with_score(0.1).link_observable(receiver).tagged( - "emails" - ) + cy.finding("receiver", "description", "> receiver").with_score(0.1).link_observable(receiver).tagged("emails") logger.info("Email receiver analysis complete") @@ -364,9 +362,7 @@ def run(self, cy: Cyvest) -> None: # Build file observable with hash observables file_obs = cy.observable(cy.OBS.FILE, filename) - hash_obs = cy.observable(cy.OBS.HASH, f"MD5:{md5_hash}").with_ti( - "VT", score, "MD5 hash analysis" - ) + hash_obs = cy.observable(cy.OBS.HASH, f"MD5:{md5_hash}").with_ti("VT", score, "MD5 hash analysis") cy.root().relate_to(file_obs, cy.REL.EXTRACTION) hash_obs.relate_to(file_obs, cy.REL.EXTRACTION, direction=cy.DIR.INBOUND) @@ -492,7 +488,6 @@ def run(self, cy: Cyvest) -> None: @click.command() @click_logger_params @click.option("-w", "--workers", type=int, default=1) -@click.option("--browser", "browser", is_flag=True, default=False) @click.option("--stats", "stats", is_flag=True, default=False) @click.option("--audit", "audit", is_flag=True, default=False) @click.option( @@ -503,7 +498,7 @@ def run(self, cy: Cyvest) -> None: help="Exclude audit log from JSON output for deterministic output", ) @click.option("-o", "--output", type=click.Path(dir_okay=False, path_type=Path), default=None) -def main(workers, browser, stats, audit, no_audit_log, output): +def main(workers, stats, audit, no_audit_log, output): """Main execution demonstrating multi-threaded investigation.""" # Prepare input data @@ -561,7 +556,6 @@ def main(workers, browser, stats, audit, no_audit_log, output): cy.display_summary(show_audit_log=audit) if stats: cy.display_statistics() - cy.display_network(open_browser=browser) if output is not None: logger.info("[bold cyan]Generating json...[/bold cyan]") diff --git a/examples/05_visualization.py b/examples/05_graph_dataset.py similarity index 86% rename from examples/05_visualization.py rename to examples/05_graph_dataset.py index 0c1f120..50391dc 100644 --- a/examples/05_visualization.py +++ b/examples/05_graph_dataset.py @@ -1,15 +1,9 @@ """ -Example: Network Visualization with Pyvis +Example: Graph dataset for the @cyvest/cyvest-vis renderer -This example demonstrates the network visualization feature that generates -an interactive HTML graph showing observables and their relationships. - -The visualization uses the Rich color scheme: -- Nodes are colored by security level (red=malicious, yellow=suspicious, green=safe, etc.) -- Node sizes are based on scores (higher scores = larger nodes) -- Node shapes represent observable types (diamonds=domains, dots=IPs, boxes=URLs, etc.) -- Edges are colored by relationship direction (blue=outbound, pink=inbound, purple=bidirectional) -- Edge labels show the relationship type +Builds a rich investigation covering scores, levels, and relationship flows, then +exports it as JSON. Feed the resulting file to the `@cyvest/cyvest-vis` React +component to explore the graph interactively. """ import tempfile @@ -169,11 +163,6 @@ def main(no_audit_log: bool = False, output: Path | None = None) -> None: size_kb = Path(json_path).stat().st_size / 1024 logger.info("[green]✓ Full json saved to: %s (%.2f KB)[/green]", json_path, size_kb) - # Generate and open network visualization - logger.info("[bold cyan]Generating Network Visualization...[/bold cyan]") - html_path = cv.display_network(open_browser=False) - logger.info(f"[green]✓ Full html visualization saved to: {html_path}[/green]") - # Example: Export to markdown (with optional sections) logger.info("[bold cyan]Exporting to Markdown...[/bold cyan]") # Export with tags and enrichments included diff --git a/js/packages/cyvest-app/package.json b/js/packages/cyvest-app/package.json index 08f2ca9..95c8def 100644 --- a/js/packages/cyvest-app/package.json +++ b/js/packages/cyvest-app/package.json @@ -1,6 +1,6 @@ { "name": "@cyvest/cyvest-app", - "version": "6.2.0", + "version": "7.0.0", "private": true, "scripts": { "dev": "vite", diff --git a/js/packages/cyvest-app/src/investigations/cyvest_email.json b/js/packages/cyvest-app/src/investigations/cyvest_email.json index 85f41fd..1eaab1d 100644 --- a/js/packages/cyvest-app/src/investigations/cyvest_email.json +++ b/js/packages/cyvest-app/src/investigations/cyvest_email.json @@ -916,4 +916,4 @@ "score_mode_obs": "max" }, "score_display": "36.10" -} \ No newline at end of file +} diff --git a/js/packages/cyvest-app/src/investigations/cyvest_visual.json b/js/packages/cyvest-app/src/investigations/cyvest_visual.json index aa157bf..376a7ee 100644 --- a/js/packages/cyvest-app/src/investigations/cyvest_visual.json +++ b/js/packages/cyvest-app/src/investigations/cyvest_visual.json @@ -754,4 +754,4 @@ "score_mode_obs": "max" }, "score_display": "37.50" -} \ No newline at end of file +} diff --git a/js/packages/cyvest-js/package.json b/js/packages/cyvest-js/package.json index e1df85b..7057433 100644 --- a/js/packages/cyvest-js/package.json +++ b/js/packages/cyvest-js/package.json @@ -1,6 +1,6 @@ { "name": "@cyvest/cyvest-js", - "version": "6.2.0", + "version": "7.0.0", "type": "module", "files": [ "dist" diff --git a/js/packages/cyvest-vis/package.json b/js/packages/cyvest-vis/package.json index 4b1bac8..6b06345 100644 --- a/js/packages/cyvest-vis/package.json +++ b/js/packages/cyvest-vis/package.json @@ -1,6 +1,6 @@ { "name": "@cyvest/cyvest-vis", - "version": "6.2.0", + "version": "7.0.0", "type": "module", "files": [ "dist" diff --git a/pyproject.toml b/pyproject.toml index 6b7f2a6..9ab5b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cyvest" -version = "6.2.0" +version = "7.0.0" description = "Cybersecurity investigation model" readme = {file = "README.md", content-type = "text/markdown"} requires-python = ">=3.10" @@ -57,9 +57,6 @@ docs = [ "pymdown-extensions>=10.11", ] -[project.optional-dependencies] -visualization = ["pyvis>=0.3.2"] - [tool.ruff] target-version = "py310" line-length = 120 diff --git a/scripts/generate.sh b/scripts/generate.sh index 585fc7d..47304f9 100755 --- a/scripts/generate.sh +++ b/scripts/generate.sh @@ -4,6 +4,6 @@ uv run cyvest schema -o ./schema/cyvest.schema.json pnpm -C js/packages/cyvest-js run generate:types uv run examples/04_email.py --no-audit-log -o ./js/packages/cyvest-app/src/investigations/cyvest_email.json -uv run examples/05_visualization.py --no-audit-log -o ./js/packages/cyvest-app/src/investigations/cyvest_visual.json +uv run examples/05_graph_dataset.py --no-audit-log -o ./js/packages/cyvest-app/src/investigations/cyvest_visual.json pnpm -C js run build pnpm -C js run test:ci diff --git a/src/cyvest/__init__.py b/src/cyvest/__init__.py index 2b04d2c..6bb7ad1 100644 --- a/src/cyvest/__init__.py +++ b/src/cyvest/__init__.py @@ -31,7 +31,7 @@ from cyvest.proxies import EnrichmentProxy, EvidenceProxy, FindingProxy, ObservableProxy, TagProxy, ThreatIntelProxy from cyvest.resolvers import ObservableResolution, ObservableResolver -__version__ = "6.2.0" +__version__ = "7.0.0" __all__ = [ # Core class diff --git a/src/cyvest/cli.py b/src/cyvest/cli.py index 97758b3..acc347b 100644 --- a/src/cyvest/cli.py +++ b/src/cyvest/cli.py @@ -30,7 +30,6 @@ from cyvest.io_rich import display_diff, display_finding_query, display_observable_query, display_threat_intel_query from cyvest.io_schema import get_investigation_schema from cyvest.io_serialization import load_investigation_json, migrate_v5_to_v6 -from cyvest.io_visualization import VisualizationDependencyMissingError from cyvest.keys import parse_key_type from cyvest.model_enums import ObservableType @@ -282,116 +281,6 @@ def migrate(input: Path, output: Path) -> None: logger.info(f"[green]Migrated investigation written to: {output_path}[/green]") -@cli.command() -@click.argument("input", type=click.Path(exists=True, dir_okay=False, path_type=Path)) -@click.option( - "--output-dir", - type=click.Path(file_okay=False, path_type=Path), - help="Directory to save HTML file (defaults to temporary directory).", -) -@click.option( - "--no-browser", - is_flag=True, - help="Do not automatically open the visualization in a browser.", -) -@click.option( - "--min-level", - type=click.Choice(["TRUSTED", "INFO", "SAFE", "NOTABLE", "SUSPICIOUS", "MALICIOUS"], case_sensitive=False), - help="Minimum security level to include in the visualization.", -) -@click.option( - "--types", - help="Comma-separated list of observable types to include (e.g., 'ipv4,domain,url').", -) -@click.option( - "--title", - default="Cyvest Investigation Network", - show_default=True, - help="Title for the network graph.", -) -@click.option( - "--physics", - is_flag=True, - help="Enable physics simulation for organic layout (default: static layout).", -) -@click.option( - "--group-by-type", - is_flag=True, - help="Group observables by type using hierarchical layout.", -) -def visualize( - input: Path, - output_dir: Path | None, - no_browser: bool, - min_level: str | None, - types: str | None, - title: str, - physics: bool, - group_by_type: bool, -) -> None: - """ - Generate an interactive network graph visualization of an investigation. - - This command creates an HTML file with a pyvis network graph showing - observables as nodes (colored by level, sized by score, shaped by type) - and relationships as edges (colored by direction, labeled by type). - - The visualization is saved to a temporary directory by default, or to - the specified output directory. The HTML file automatically opens in - your default browser unless --no-browser is specified. - """ - from cyvest.levels import Level - from cyvest.model_enums import ObservableType - - cv = load_investigation_json(input) - - # Parse min_level if provided - min_level_enum = None - if min_level is not None: - min_level_enum = Level[min_level.upper()] - - # Parse observable types if provided - observable_types = None - if types is not None: - parsed_types: list[ObservableType] = [] - for token in types.split(","): - token = token.strip() - if not token: - continue - try: - parsed_types.append(ObservableType(token.lower())) - except ValueError: - try: - parsed_types.append(ObservableType[token.upper()]) - except KeyError as exc: - raise click.ClickException(f"Unknown observable type: {token}") from exc - observable_types = parsed_types or None - - # Convert output_dir to string if provided - output_dir_str = str(output_dir.resolve()) if output_dir is not None else None - - # Generate visualization - logger.info(f"[cyan]Generating network visualization for: {input}[/cyan]") - - try: - html_path = cv.display_network( - output_dir=output_dir_str, - open_browser=not no_browser, - min_level=min_level_enum, - observable_types=observable_types, - title=title, - physics=physics, - group_by_type=group_by_type, - ) - except VisualizationDependencyMissingError as exc: - raise click.ClickException(str(exc)) from exc - - logger.info(f"[green]✓ Visualization saved to: {html_path}[/green]") - - if not no_browser: - logger.info("[cyan]Opening visualization in browser...[/cyan]") - - @cli.command() @click.argument("actual", type=click.Path(exists=True, dir_okay=False, path_type=Path)) @click.argument("expected", type=click.Path(exists=True, dir_okay=False, path_type=Path)) diff --git a/src/cyvest/cyvest.py b/src/cyvest/cyvest.py index b4b8749..794377c 100644 --- a/src/cyvest/cyvest.py +++ b/src/cyvest/cyvest.py @@ -39,7 +39,6 @@ save_investigation_markdown, serialize_investigation, ) -from cyvest.io_visualization import generate_network_graph from cyvest.levels import Level from cyvest.model import ( Enrichment, @@ -1676,55 +1675,6 @@ def rich_print(renderables: Any) -> None: display_threat_intel_query(self, ti_key, rich_print) - def display_network( - self, - output_dir: str | None = None, - open_browser: bool = True, - min_level: Level | None = None, - observable_types: list[ObservableType] | None = None, - physics: bool = True, - group_by_type: bool = False, - max_label_length: int = 60, - title: str = "Cyvest Investigation Network", - ) -> str: - """ - Generate and display an interactive network graph visualization. - - Creates an HTML file with a pyvis network graph showing observables as nodes - (colored by level, sized by score, shaped by type) and relationships as edges - (colored by direction, labeled by type). - - Args: - output_dir: Directory to save HTML file (defaults to temp directory) - open_browser: Whether to automatically open the HTML file in a browser - min_level: Minimum security level to include (filters out lower levels) - observable_types: List of observable types to include (filters out others) - physics: Enable physics simulation for organic layout (default: False for static layout) - group_by_type: Group observables by type using hierarchical layout (default: False) - max_label_length: Maximum length for node labels before truncation (default: 60) - title: Title displayed in the generated HTML visualization - - Returns: - Path to the generated HTML file - - Examples: - >>> cv = Cyvest() - >>> # Create investigation with observables - >>> cv.display_network() - '/tmp/cyvest_12345/cyvest_network.html' - """ - return generate_network_graph( - self, - output_dir=output_dir, - open_browser=open_browser, - min_level=min_level, - observable_types=observable_types, - physics=physics, - group_by_type=group_by_type, - max_label_length=max_label_length, - title=title, - ) - # Fluent helper entrypoints def taxonomy(self, *, level: Level, name: str, value: str) -> Taxonomy: diff --git a/src/cyvest/io_visualization.py b/src/cyvest/io_visualization.py deleted file mode 100644 index d3e005d..0000000 --- a/src/cyvest/io_visualization.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -Network graph visualization for Cyvest investigations using pyvis. - -Provides interactive HTML network visualization of observables and their relationships. -""" - -from __future__ import annotations - -import tempfile -import webbrowser -from pathlib import Path -from typing import TYPE_CHECKING - -from cyvest.levels import LEVEL_COLORS, Level -from cyvest.model import ObservableType, RelationshipDirection, RelationshipType - -if TYPE_CHECKING: - from pyvis.network import Network - - from cyvest.cyvest import Cyvest - -try: - from pyvis.network import Network # type: ignore[assignment] - - PYVIS_AVAILABLE = True -except ImportError: # pragma: no cover - optional dependency not installed - Network = None # type: ignore[assignment] - PYVIS_AVAILABLE = False - - -VISUALIZATION_INSTALL_HINT = ( - "Network visualization requires the 'pyvis' optional dependency. " - 'Install via `pip install "cyvest[visualization]"`.' -) - - -class VisualizationDependencyMissingError(RuntimeError): - """Raised when the optional visualization extra is not installed.""" - - def __init__(self) -> None: - super().__init__(VISUALIZATION_INSTALL_HINT) - - -# Pyvis color mapping from Rich color names -PYVIS_COLOR_MAP = { - "white": "#FFFFFF", - "green": "#00FF00", - "cyan": "#00FFFF", - "bright_green": "#90EE90", - "yellow": "#FFFF00", - "orange3": "#FFA500", - "red": "#FF0000", -} - -# Observable type to shape mapping for visual distinction -OBSERVABLE_SHAPES = { - ObservableType.IPV4: "dot", - ObservableType.IPV6: "dot", - ObservableType.DOMAIN: "diamond", - ObservableType.URL: "diamond", - ObservableType.EMAIL: "diamond", - ObservableType.HASH: "square", - ObservableType.FILE: "square", - ObservableType.ARTIFACT: "square", - ObservableType.HOST: "dot", - ObservableType.PROCESS: "dot", - ObservableType.USER: "diamond", - ObservableType.COMMAND_LINE: "box", - ObservableType.CLOUD_RESOURCE: "square", -} - - -def get_node_color(level: Level) -> str: - """ - Get pyvis-compatible color for a security level. - - Args: - level: Security level - - Returns: - Hex color code for pyvis - """ - rich_color = LEVEL_COLORS.get(level, "white") - return PYVIS_COLOR_MAP.get(rich_color, "#FFFFFF") - - -def get_edge_color(direction: RelationshipDirection) -> str: - """ - Get edge color based on relationship direction. - - Args: - direction: Relationship direction - - Returns: - Hex color code for edge - """ - if direction == RelationshipDirection.OUTBOUND: - return "#4A90E2" # Blue for outbound - elif direction == RelationshipDirection.INBOUND: - return "#E24A90" # Pink for inbound - else: # BIDIRECTIONAL - return "#9B59B6" # Purple for bidirectional - - -def get_edge_arrows(direction: RelationshipDirection) -> str: - """ - Get arrow configuration for relationship direction. - - Args: - direction: Relationship direction - - Returns: - Arrow configuration string - """ - if direction == RelationshipDirection.OUTBOUND: - return "to" - elif direction == RelationshipDirection.INBOUND: - return "from" - else: # BIDIRECTIONAL - return "to;from" - - -def truncate_middle(text: str, max_length: int) -> str: - """Truncate text in the middle with an ellipsis when exceeding ``max_length``. - - Keeps both the start and end of the string visible for context while - respecting the provided length budget. - """ - if max_length < 4 or len(text) <= max_length: - return text - - reserved = max_length - 3 - head = reserved // 2 + reserved % 2 - tail = reserved - head - - return f"{text[:head]}...{text[-tail:]}" - - -def generate_network_graph( - cv: Cyvest, - output_dir: str | None = None, - open_browser: bool = True, - min_level: Level | None = None, - observable_types: list[ObservableType] | None = None, - physics: bool = True, - group_by_type: bool = False, - max_label_length: int = 60, - title: str = "Cyvest Investigation Network", -) -> str: - """ - Generate an interactive network graph visualization of observables and relationships. - - Creates an HTML file with a pyvis network graph showing observables as nodes - (colored by level, sized by score, shaped by type) and relationships as edges - (colored by direction, labeled by type). - - Args: - cv: Cyvest investigation to visualize - output_dir: Directory to save HTML file (defaults to temp directory) - open_browser: Whether to automatically open the HTML file in a browser - min_level: Minimum security level to include (filters out lower levels) - observable_types: List of observable types to include (filters out others) - physics: Enable physics simulation for organic layout (default: False for static layout) - group_by_type: Group observables by type using hierarchical layout (default: False) - max_label_length: Maximum length for node labels before truncation (default: 60) - title: Title displayed in the HTML visualization - - Returns: - Path to the generated HTML file - - Examples: - >>> from cyvest import Cyvest - >>> from cyvest.io_visualization import generate_network_graph - >>> cv = Cyvest() - >>> # Create investigation with observables - >>> generate_network_graph(cv) - '/tmp/cyvest_network_12345.html' - """ - if not PYVIS_AVAILABLE or Network is None: # pragma: no branch - both change together - raise VisualizationDependencyMissingError() - - normalized_min_level = min_level - - # Create pyvis network with physics enabled for organic layout - net = Network( - height="800px", - width="100%", - bgcolor="#FFFFFF", - font_color="#1E1E1E", - directed=True, - ) - net.heading = title - - # Configure physics and interaction options - physics_enabled = "true" if physics else "false" - - # Build layout configuration - if group_by_type: - layout_config = """ - "layout": { - "hierarchical": { - "enabled": true, - "direction": "UD", - "sortMethod": "directed", - "levelSeparation": 200, - "nodeSpacing": 150, - "treeSpacing": 200 - } - },""" - else: - layout_config = "" - - net.set_options( - f""" - {{ - {layout_config} - "physics": {{ - "enabled": {physics_enabled}, - "stabilization": {{ - "enabled": true, - "iterations": 200 - }}, - "barnesHut": {{ - "gravitationalConstant": -8000, - "centralGravity": 0.3, - "springLength": 95, - "springConstant": 0.04, - "damping": 0.09, - "avoidOverlap": 0.1 - }} - }}, - "interaction": {{ - "navigationButtons": false, - "keyboard": true - }} - }} - """ - ) - - # Get all observables - observables = cv.observable_get_all() - - # Filter observables based on criteria - filtered_observables = {} - for key, obs in observables.items(): - # Filter by minimum level - if normalized_min_level is not None and obs.level < normalized_min_level: - continue - - # Filter by observable types - if observable_types is not None and obs.obs_type not in observable_types: - continue - - filtered_observables[key] = obs - - # Get root observable key for special positioning - root_key = cv.observable_get_root().key if cv.observable_get_root() else None - - # Add nodes for each observable - for key, obs in filtered_observables.items(): - # Get color based on level - color = get_node_color(obs.level) - - # Get shape based on observable type - shape = OBSERVABLE_SHAPES.get(obs.obs_type, "dot") - - # Build label with type, value, and level - obs_type_str = obs.obs_type.value if isinstance(obs.obs_type, ObservableType) else str(obs.obs_type) - obs_value = f"{obs.value}" - label = truncate_middle(obs_value, max_label_length) - - # Build title (hover text) with detailed info - title_parts = [ - f"Type: {obs_type_str}", - f"Value: {obs.value}", - f"Level: {obs.level.name}", - f"Score: {obs.score_display}", - f"Key: {key}", - ] - if obs.subtype: - subtype = obs.subtype.value if hasattr(obs.subtype, "value") else obs.subtype - title_parts.append(f"Subtype: {subtype}") - if obs.namespace: - title_parts.append(f"Namespace: {obs.namespace}") - - if obs.internal: - title_parts.append("Internal: Yes") - if obs.whitelisted: - title_parts.append("Whitelisted: Yes") - if obs.threat_intels: - title_parts.append(f"Threat Intel Sources: {len(obs.threat_intels)}") - if obs.finding_links: - title_parts.append(f"Linked findings: {len(obs.finding_links)}") - - title_text = "\n".join(title_parts) - - # Prepare node options - node_options = { - "label": label, - "title": title_text, - "color": color, - "size": 10, - "shape": shape, - "font": {"size": 10, "color": "#FFFFFF"}, - } - - # Add group attribute for type-based grouping - if group_by_type: - node_options["group"] = ( - obs.obs_type.value if isinstance(obs.obs_type, ObservableType) else str(obs.obs_type) - ) - - # Fix root node at center position if physics is disabled - if key == root_key and not physics: - node_options["label"] = "ROOT" - node_options["x"] = 0 - node_options["y"] = 0 - node_options["fixed"] = True - node_options["size"] = 20 # Make root node slightly larger - - # Add node to network - net.add_node(key, **node_options) - - # Add edges for relationships - for key, obs in filtered_observables.items(): - for rel in obs.relationships: - # Only add edge if target is also in filtered observables - if rel.target_key not in filtered_observables: - continue - - # Get edge properties based on direction - edge_color = get_edge_color(rel.direction) - arrows = get_edge_arrows(rel.direction) - - # Build edge label - rel_type_str = ( - rel.relationship_type.value - if isinstance(rel.relationship_type, RelationshipType) - else str(rel.relationship_type) - ) - edge_label = rel_type_str - - # Build edge title (hover text) - edge_title = f"{rel_type_str}\nDirection: {rel.direction.name}" - - # Add edge to network - net.add_edge( - key, - rel.target_key, - label=edge_label, - title=edge_title, - color=edge_color, - arrows=arrows, - font={"size": 8}, - ) - - # Determine output path - if output_dir is None: - output_dir = tempfile.mkdtemp(prefix="cyvest_") - - output_path = Path(output_dir) / "cyvest_network.html" - - # Save to HTML file - net.save_graph(str(output_path)) - - # Open in browser if requested - if open_browser: - webbrowser.open(f"file://{output_path.absolute()}") - - return str(output_path) diff --git a/tests/test_score_algorithm.py b/tests/test_score_algorithm.py index a6fc360..9e7283e 100644 --- a/tests/test_score_algorithm.py +++ b/tests/test_score_algorithm.py @@ -1025,7 +1025,7 @@ def _build_shared_subtree(cv: Cyvest, reversed_branches: bool) -> tuple[Decimal, cv.observable_add_threat_intel(leaf.key, source="vt", score=Decimal("10.0")) cv.observable_add_relationship(shared.key, leaf.key, "pivot", Cyvest.DIR.OUTBOUND) - for branch in ([branch_b, branch_a] if reversed_branches else [branch_a, branch_b]): + for branch in [branch_b, branch_a] if reversed_branches else [branch_a, branch_b]: cv.observable_add_relationship(parent.key, branch.key, "extraction", Cyvest.DIR.OUTBOUND) cv.observable_add_relationship(branch.key, shared.key, "pivot", Cyvest.DIR.OUTBOUND) diff --git a/uv.lock b/uv.lock index 4c90746..6df4edf 100644 --- a/uv.lock +++ b/uv.lock @@ -16,15 +16,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "asttokens" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -305,7 +296,7 @@ toml = [ [[package]] name = "cyvest" -version = "6.2.0" +version = "7.0.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -315,11 +306,6 @@ dependencies = [ { name = "typing-extensions" }, ] -[package.optional-dependencies] -visualization = [ - { name = "pyvis" }, -] - [package.dev-dependencies] dev = [ { name = "debugpy" }, @@ -340,11 +326,9 @@ requires-dist = [ { name = "click", specifier = ">=8" }, { name = "logurich", extras = ["click"], git = "https://github.com/PakitoSec/logurich.git?rev=e40c371f705459b58d9f57c0249a357aacd0b74e" }, { name = "pydantic", specifier = ">=2.12.5" }, - { name = "pyvis", marker = "extra == 'visualization'", specifier = ">=0.3.2" }, { name = "rich", specifier = ">=13" }, { name = "typing-extensions", specifier = ">=4.15" }, ] -provides-extras = ["visualization"] [package.metadata.requires-dev] dev = [ @@ -390,15 +374,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, ] -[[package]] -name = "decorator" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, -] - [[package]] name = "distlib" version = "0.4.3" @@ -420,15 +395,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - [[package]] name = "filelock" version = "3.29.4" @@ -477,82 +443,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "ipython" -version = "8.39.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, -] - -[[package]] -name = "ipython" -version = "9.14.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", -] -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'emscripten'" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/22/58818a63eaf8982b67632b1bc20585c811611b15a8da19d6012323dc76a5/ipython-9.14.1-py3-none-any.whl", hash = "sha256:5d4a9ecaa3b10e6e5f269dd0948bdb58ca9cb851899cd23e07c320d3eb11613c", size = 627770, upload-time = "2026-06-05T08:12:33.045Z" }, -] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, -] - -[[package]] -name = "jedi" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -565,15 +455,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jsonpickle" -version = "4.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/c0/dde9b4b42cc415b9579573f967f12efbb034e427a2a37e93ad5139891d87/jsonpickle-4.1.2.tar.gz", hash = "sha256:8afed18aa189fd81e2e833b426bb4af485594921f0b1d36c2001fc5637a2f210", size = 319120, upload-time = "2026-05-28T03:50:11.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/7b/fd3c7a09649aea9da1d3587aea624d8f9b29963dfd84a1bdb2aa93b36dac/jsonpickle-4.1.2-py3-none-any.whl", hash = "sha256:7ffe34426bc797684dbf1dc84185558bd864cd25b1ff5fb01b7405e392d0a937", size = 47203, upload-time = "2026-05-28T03:50:10.605Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -721,18 +602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "matplotlib-inline" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -820,31 +689,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] -[[package]] -name = "networkx" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, -] - -[[package]] -name = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - [[package]] name = "nodeenv" version = "1.10.0" @@ -872,15 +716,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] -[[package]] -name = "parso" -version = "0.8.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -890,18 +725,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] -[[package]] -name = "pexpect" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, -] - [[package]] name = "platformdirs" version = "4.10.0" @@ -936,64 +759,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - -[[package]] -name = "ptyprocess" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" @@ -1204,22 +969,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, ] -[[package]] -name = "pyvis" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jinja2" }, - { name = "jsonpickle" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/4b/e37e4e5d5ee1179694917b445768bdbfb084f5a59ecd38089d3413d4c70f/pyvis-0.3.2-py3-none-any.whl", hash = "sha256:5720c4ca8161dc5d9ab352015723abb7a8bb8fb443edeb07f7a322db34a97555", size = 756038, upload-time = "2023-02-24T20:29:46.758Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -1639,20 +1388,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "stack-data" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, -] - [[package]] name = "tomli" version = "2.4.1" @@ -1707,15 +1442,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] -[[package]] -name = "traitlets" -version = "5.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -1793,12 +1519,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] - -[[package]] -name = "wcwidth" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, -]