diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ed8c1..bce7384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-04-05 + +### Added +- **9 new REPL commands** (PRD-004: REPL to Production) + - `bench ` — benchmark action performance (req/sec, min/avg/max latency) + - `cache keys/clear` — cache management (list keys, clear by pattern) + - `cls` — clear terminal screen + - `destroy ` — destroy a local service at runtime + - `env` — display environment variables + - `listener add/remove/list` — subscribe to events and print live + - `load ` — load service from .py file at runtime + - `metrics [-f pattern]` — display collected metrics + - `quit` / `exit` — graceful broker stop and exit +- Total commands: 18 (was 10) — 95%+ parity with Node.js moleculer-repl (19 commands) +- Python-unique commands: `dcall` (direct call), `loglevel` (runtime log level) + ## [0.14.1] - 2026-02-11 ### Added diff --git a/pyproject.toml b/pyproject.toml index f335022..ca9ca81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "moleculerpy-repl" -version = "0.2.0" +version = "0.3.0" description = "Interactive CLI shell for MoleculerPy microservices framework" readme = "README.md" license = "MIT" diff --git a/src/moleculerpy_repl/commands/__init__.py b/src/moleculerpy_repl/commands/__init__.py index 1c918eb..2b04405 100644 --- a/src/moleculerpy_repl/commands/__init__.py +++ b/src/moleculerpy_repl/commands/__init__.py @@ -6,13 +6,22 @@ # Import all command classes from .actions import ActionsCommand from .base import BaseCommand, CommandRegistry, CommandResult +from .bench import BenchCommand +from .cache import CacheCommand from .call import CallCommand +from .cls import ClsCommand from .dcall import DirectCallCommand +from .destroy import DestroyCommand from .emit import BroadcastCommand, EmitCommand +from .env import EnvCommand from .events import EventsCommand from .info import InfoCommand +from .listener import ListenerCommand +from .load import LoadCommand from .loglevel import LogLevelCommand +from .metrics import MetricsCommand from .nodes import NodesCommand +from .quit import QuitCommand from .services import ServicesCommand @@ -24,14 +33,23 @@ def get_builtin_commands() -> list[type[BaseCommand]]: """ return [ ActionsCommand, + BenchCommand, BroadcastCommand, + CacheCommand, CallCommand, + ClsCommand, + DestroyCommand, DirectCallCommand, EmitCommand, + EnvCommand, EventsCommand, InfoCommand, + ListenerCommand, + LoadCommand, LogLevelCommand, + MetricsCommand, NodesCommand, + QuitCommand, ServicesCommand, ] @@ -56,13 +74,22 @@ def create_default_registry() -> CommandRegistry: "create_default_registry", # Individual commands "ActionsCommand", + "BenchCommand", "BroadcastCommand", + "CacheCommand", "CallCommand", + "ClsCommand", + "DestroyCommand", "DirectCallCommand", "EmitCommand", + "EnvCommand", "EventsCommand", "InfoCommand", + "ListenerCommand", + "LoadCommand", "LogLevelCommand", + "MetricsCommand", "NodesCommand", + "QuitCommand", "ServicesCommand", ] diff --git a/src/moleculerpy_repl/commands/bench.py b/src/moleculerpy_repl/commands/bench.py new file mode 100644 index 0000000..78893a3 --- /dev/null +++ b/src/moleculerpy_repl/commands/bench.py @@ -0,0 +1,139 @@ +"""Bench command — Benchmark a service action. + +Node.js equivalent: moleculer-repl/src/commands/bench.js +""" + +from __future__ import annotations + +import time +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + +_DEFAULT_NUM = 1000 +# Node.js: if neither --num nor --time given, defaults to 5 seconds +_DEFAULT_TIME = 5.0 + + +class BenchCommand(BaseCommand): + """Benchmark a service action with timing statistics.""" + + name = "bench" + description = "Benchmark a service action" + usage = "bench [jsonParams] [--num N] [--time T] [--nodeID id]" + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the bench command.""" + if not args.positional: + return CommandResult( + success=False, + error="Action name required. Usage: bench [params] [--num N] [--time T]", + ) + + action_name = args.positional[0] + + # Parse flags + has_num = "num" in args.flags + has_time = "time" in args.flags + num = int(args.flags.get("num", _DEFAULT_NUM)) + duration = float(args.flags.get("time", _DEFAULT_TIME)) + + # Node.js: if --num given explicitly, use count-based mode + # If --time given, use time-based mode + # If neither, default to time-based (5s) + use_time = not has_num or has_time + + # Build params + params = dict(args.payload) if args.payload else {} + if len(args.positional) > 1: + import json # noqa: PLC0415 + + try: + extra = json.loads(args.positional[1]) + if isinstance(extra, dict): + params.update(extra) + except (json.JSONDecodeError, ValueError): + pass + + try: + output = await _run_benchmark( + broker, action_name, params, num if not use_time else 0, duration if use_time else 0 + ) + return CommandResult(success=True, output=output) + except Exception as e: + return CommandResult(success=False, error=f"Benchmark failed: {e}") + + +async def _run_benchmark( + broker: Any, + action: str, + params: dict[str, Any], + num: int, + duration: float, +) -> str: + """Run the benchmark loop and return formatted statistics.""" + timings: list[float] = [] + errors = 0 + + print(f"Benchmarking '{action}'...") + + # Track total wall-clock time for real RPS calculation + wall_start = time.perf_counter() + + if duration > 0: + # Time-based: run for `duration` seconds + end_time = wall_start + duration + while time.perf_counter() < end_time: + t0 = time.perf_counter() + try: + await broker.call(action, params) + except Exception: + errors += 1 + # Record time for ALL calls including errors (audit fix H3/M3) + timings.append((time.perf_counter() - t0) * 1000) + else: + # Count-based: run `num` iterations + for _ in range(num): + t0 = time.perf_counter() + try: + await broker.call(action, params) + except Exception: + errors += 1 + timings.append((time.perf_counter() - t0) * 1000) + + wall_total = time.perf_counter() - wall_start + return _format_stats(action, timings, errors, wall_total) + + +def _format_stats(action: str, timings: list[float], errors: int, wall_seconds: float) -> str: + """Format benchmark statistics.""" + total = len(timings) + + if not timings: + return f"Benchmark '{action}': 0 calls, {errors} errors" + + total_time_ms = sum(timings) + avg_ms = total_time_ms / total + min_ms = min(timings) + max_ms = max(timings) + + # Real throughput RPS (Node.js: resCount / duration * 1000) + rps = total / wall_seconds if wall_seconds > 0 else 0.0 + + lines = [ + f"Benchmark: {action}", + "-" * 40, + f" Total calls: {total}", + f" Successful: {total - errors}", + f" Errors: {errors}", + f" Duration: {wall_seconds:.3f}s", + f" Req/sec: {rps:.1f}", + "", + "Latency:", + f" min: {min_ms:.3f}ms", + f" avg: {avg_ms:.3f}ms", + f" max: {max_ms:.3f}ms", + ] + + return "\n".join(lines) diff --git a/src/moleculerpy_repl/commands/cache.py b/src/moleculerpy_repl/commands/cache.py new file mode 100644 index 0000000..82fcdba --- /dev/null +++ b/src/moleculerpy_repl/commands/cache.py @@ -0,0 +1,80 @@ +"""Cache command — Manage broker cache.""" + +from __future__ import annotations + +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + + +class CacheCommand(BaseCommand): + """Manage broker cache keys and entries.""" + + name = "cache" + description = "Manage cache (keys/clear)" + usage = "cache keys [-f pattern] | cache clear [pattern]" + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the cache command.""" + cacher = getattr(broker, "cacher", None) + if not cacher: + return CommandResult(success=False, error="Cacher is not configured") + + subcommand = args.positional[0] if args.positional else "keys" + + if subcommand == "keys": + return await self._list_keys(cacher, args) + elif subcommand == "clear": + return await self._clear_keys(cacher, args) + else: + return CommandResult( + success=False, + error=f"Unknown subcommand '{subcommand}'. Use: keys, clear", + ) + + async def _list_keys(self, cacher: Any, args: ParsedArgs) -> CommandResult: + """List cache keys, optionally filtered by pattern.""" + pattern = args.flags.get("f") or (args.positional[1] if len(args.positional) > 1 else None) + + try: + if hasattr(cacher, "keys"): + keys = await cacher.keys(pattern) if pattern else await cacher.keys() + else: + return CommandResult( + success=False, + error="Cacher does not support listing keys", + ) + + if not keys: + output = "No cache keys found" + if pattern: + output += f" matching '{pattern}'" + else: + lines = [f"Cache keys ({len(keys)}):"] + lines.extend(f" {key}" for key in sorted(keys)) + output = "\n".join(lines) + + return CommandResult(success=True, output=output) + + except Exception as e: + return CommandResult(success=False, error=f"Failed to list keys: {e}") + + async def _clear_keys(self, cacher: Any, args: ParsedArgs) -> CommandResult: + """Clear cache entries, optionally matching pattern.""" + pattern = args.positional[1] if len(args.positional) > 1 else None + + try: + if hasattr(cacher, "clean"): + await cacher.clean(pattern) if pattern else await cacher.clean() + else: + return CommandResult( + success=False, + error="Cacher does not support clean operation", + ) + + msg = f"Cache cleared (pattern: '{pattern}')" if pattern else "Cache cleared" + return CommandResult(success=True, output=msg) + + except Exception as e: + return CommandResult(success=False, error=f"Failed to clear cache: {e}") diff --git a/src/moleculerpy_repl/commands/cls.py b/src/moleculerpy_repl/commands/cls.py new file mode 100644 index 0000000..301a71d --- /dev/null +++ b/src/moleculerpy_repl/commands/cls.py @@ -0,0 +1,24 @@ +"""Cls command — Clear terminal screen.""" + +from __future__ import annotations + +import sys +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + + +class ClsCommand(BaseCommand): + """Clear the terminal screen.""" + + name = "cls" + description = "Clear terminal screen" + usage = "cls" + aliases = ["clear"] + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the cls command.""" + sys.stdout.write("\x1bc") + sys.stdout.flush() + return CommandResult(success=True) diff --git a/src/moleculerpy_repl/commands/destroy.py b/src/moleculerpy_repl/commands/destroy.py new file mode 100644 index 0000000..f1c768f --- /dev/null +++ b/src/moleculerpy_repl/commands/destroy.py @@ -0,0 +1,59 @@ +"""Destroy command — Destroy a local service.""" + +from __future__ import annotations + +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + + +class DestroyCommand(BaseCommand): + """Destroy a local service by name.""" + + name = "destroy" + description = "Destroy a local service" + usage = "destroy " + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the destroy command.""" + if not args.positional: + return CommandResult( + success=False, + error="Service name required. Usage: destroy ", + ) + + service_name = args.positional[0] + + try: + if hasattr(broker, "destroyService"): + # Find the service instance by name + service = None + if hasattr(broker, "services"): + service = broker.services.get(service_name) + + if service is None: + return CommandResult(success=False, error=f"Service '{service_name}' not found") + + await broker.destroyService(service) + return CommandResult( + success=True, + output=f"Service '{service_name}' destroyed.", + ) + else: + return CommandResult( + success=False, + error="broker.destroyService is not supported in this version", + ) + + except Exception as e: + return CommandResult(success=False, error=f"Destroy failed: {e}") + + def get_completions(self, broker: Any, text: str, line: str) -> list[str]: + """Get service name completions.""" + try: + if hasattr(broker, "services"): + return [name for name in broker.services if name.startswith(text)] + except Exception: + pass + return [] diff --git a/src/moleculerpy_repl/commands/env.py b/src/moleculerpy_repl/commands/env.py new file mode 100644 index 0000000..d730633 --- /dev/null +++ b/src/moleculerpy_repl/commands/env.py @@ -0,0 +1,23 @@ +"""Env command — Print environment variables.""" + +from __future__ import annotations + +import os +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + + +class EnvCommand(BaseCommand): + """Print all environment variables.""" + + name = "env" + description = "Print environment variables" + usage = "env" + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the env command.""" + lines = [f"{key}={value}" for key, value in sorted(os.environ.items())] + output = "\n".join(lines) + return CommandResult(success=True, output=output) diff --git a/src/moleculerpy_repl/commands/listener.py b/src/moleculerpy_repl/commands/listener.py new file mode 100644 index 0000000..a2bba7c --- /dev/null +++ b/src/moleculerpy_repl/commands/listener.py @@ -0,0 +1,132 @@ +"""Listener command — Subscribe to Moleculer events via dynamic service. + +Node.js pattern: creates a hidden $repl-event-listener service that subscribes +to Moleculer events (works with remote nodes, not just local bus). +""" + +from __future__ import annotations + +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + + +class ListenerCommand(BaseCommand): + """Subscribe, unsubscribe, and list event listeners. + + Uses instance-level state (not module-level) to avoid leaks between + multiple broker instances. + """ + + name = "listener" + description = "Manage event listeners (add/remove/list)" + usage = "listener add | listener remove | listener list" + aliases = ["on"] + + def __init__(self) -> None: + super().__init__() + # Instance-level state — no module-level globals (audit fix C2) + self._active_listeners: dict[str, Any] = {} + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the listener command.""" + subcommand = args.positional[0] if args.positional else "list" + + if subcommand == "add": + return await self._add_listener(broker, args) + elif subcommand == "remove": + return await self._remove_listener(broker, args) + elif subcommand == "list": + return self._list_listeners() + else: + return CommandResult( + success=False, + error=f"Unknown subcommand '{subcommand}'. Use: add, remove, list", + ) + + async def _add_listener(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Subscribe to a Moleculer event.""" + if len(args.positional) < 2: + return CommandResult( + success=False, error="Event name required. Usage: listener add " + ) + + event_name = args.positional[1] + + if event_name in self._active_listeners: + return CommandResult( + success=False, + error=f"Already listening to '{event_name}'. Remove it first.", + ) + + def make_handler(name: str) -> Any: + async def handler(ctx: Any = None, **kwargs: Any) -> None: + payload = getattr(ctx, "params", None) if ctx else None + sender = getattr(ctx, "node_id", "") if ctx else "" + print(f"\n>> Event '{name}' received from '{sender}':") + if payload is not None: + print(f" {payload}") + + return handler + + handler = make_handler(event_name) + self._active_listeners[event_name] = handler + + # Register via broker event system (works for local events) + # For distributed: broker.on() subscribes to internal bus events + try: + if hasattr(broker, "local_bus") and hasattr(broker.local_bus, "on"): + broker.local_bus.on(event_name, handler) + elif hasattr(broker, "on"): + broker.on(event_name, handler) + except Exception as e: + self._active_listeners.pop(event_name, None) + return CommandResult(success=False, error=f"Failed to subscribe: {e}") + + return CommandResult( + success=True, + output=f"Subscribed to event '{event_name}'", + ) + + async def _remove_listener(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Unsubscribe from an event.""" + if len(args.positional) < 2: + return CommandResult( + success=False, + error="Event name required. Usage: listener remove ", + ) + + event_name = args.positional[1] + + if event_name not in self._active_listeners: + return CommandResult( + success=False, + error=f"Not listening to '{event_name}'", + ) + + handler = self._active_listeners.pop(event_name) + + try: + if hasattr(broker, "local_bus") and hasattr(broker.local_bus, "off"): + broker.local_bus.off(event_name, handler) + elif hasattr(broker, "off"): + broker.off(event_name, handler) + except Exception: + pass # Already removed from our dict + + return CommandResult( + success=True, + output=f"Unsubscribed from event '{event_name}'", + ) + + def _list_listeners(self) -> CommandResult: + """List all active listeners.""" + if not self._active_listeners: + return CommandResult(success=True, output="No active listeners") + + lines = [f"Active listeners ({len(self._active_listeners)}):"] + for event_name in sorted(self._active_listeners.keys()): + lines.append(f" - {event_name}") + + return CommandResult(success=True, output="\n".join(lines)) diff --git a/src/moleculerpy_repl/commands/load.py b/src/moleculerpy_repl/commands/load.py new file mode 100644 index 0000000..09654ac --- /dev/null +++ b/src/moleculerpy_repl/commands/load.py @@ -0,0 +1,84 @@ +"""Load command — Load a service from a file. + +WARNING: This command executes arbitrary Python code from the specified file. +Only load files from trusted sources. +""" + +from __future__ import annotations + +import importlib.util +import inspect +import sys +from pathlib import Path +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + + +class LoadCommand(BaseCommand): + """Load a service from a Python file.""" + + name = "load" + description = "Load a service from file" + usage = "load " + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the load command.""" + if not args.positional: + return CommandResult(success=False, error="File path required. Usage: load ") + + file_path = Path(args.positional[0]).expanduser().resolve() + + if not file_path.exists(): + return CommandResult(success=False, error=f"File not found: {file_path}") + + if file_path.suffix != ".py": + return CommandResult( + success=False, error=f"Expected a .py file, got: {file_path.suffix}" + ) + + try: + module_name = file_path.stem + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + return CommandResult(success=False, error=f"Cannot load module from: {file_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) # type: ignore[union-attr] + + # Find Service subclasses in module + service_class = None + try: + from moleculerpy import Service # type: ignore[import-untyped] + + for _name, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, Service) and obj is not Service: + service_class = obj + break + except ImportError: + # Fallback: look for class with 'name' attribute typical of services + for _name, obj in inspect.getmembers(module, inspect.isclass): + if hasattr(obj, "name") and hasattr(obj, "actions"): + service_class = obj + break + + if service_class is None: + return CommandResult( + success=False, + error=f"No Service subclass found in {file_path.name}", + ) + + svc = service_class(broker) + await broker.addLocalService(svc) + + return CommandResult( + success=True, + output=f"Service '{getattr(svc, 'name', service_class.__name__)}' loaded from {file_path.name}", + ) + + except SystemExit: + raise + except Exception as e: + return CommandResult(success=False, error=f"Load failed: {e}") diff --git a/src/moleculerpy_repl/commands/metrics.py b/src/moleculerpy_repl/commands/metrics.py new file mode 100644 index 0000000..a01d6de --- /dev/null +++ b/src/moleculerpy_repl/commands/metrics.py @@ -0,0 +1,106 @@ +"""Metrics command — Show broker metrics.""" + +from __future__ import annotations + +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + + +class MetricsCommand(BaseCommand): + """Show broker metrics.""" + + name = "metrics" + description = "Show broker metrics" + usage = "metrics [-f pattern]" + aliases = ["m"] + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the metrics command.""" + pattern = args.flags.get("f") + + # Try to find MetricsMiddleware in broker middlewares + metrics_registry = _find_metrics_registry(broker) + + if metrics_registry is None: + return CommandResult(success=False, error="Metrics not enabled") + + try: + output = _format_metrics(metrics_registry, pattern) + return CommandResult(success=True, output=output) + except Exception as e: + return CommandResult(success=False, error=f"Failed to get metrics: {e}") + + +def _find_metrics_registry(broker: Any) -> Any: + """Find metrics registry from broker.""" + # Check direct metrics attribute + if hasattr(broker, "metrics") and broker.metrics: + return broker.metrics + + # Search in middlewares list + middlewares = getattr(broker, "middlewares", None) or [] + if hasattr(middlewares, "middlewares"): + middlewares = middlewares.middlewares + + for mw in middlewares: + mw_name = type(mw).__name__.lower() + if "metric" in mw_name: + # Try common attribute names for the registry + for attr in ("registry", "metrics", "store", "_registry", "_metrics"): + registry = getattr(mw, attr, None) + if registry: + return registry + return mw + + return None + + +def _format_metrics(registry: Any, pattern: str | None) -> str: + """Format metrics as text output.""" + lines: list[str] = [] + + # Try Prometheus-style output + if hasattr(registry, "to_prometheus"): + try: + prometheus_output = registry.to_prometheus() + if pattern: + filtered = [ + line + for line in prometheus_output.splitlines() + if pattern.lower() in line.lower() + ] + lines.extend(filtered) + else: + lines.extend(prometheus_output.splitlines()) + return "\n".join(lines) if lines else "No metrics found" + except Exception: + pass + + # Try iterating metrics dict or list + metrics_data: Any = None + for attr in ("_metrics", "metrics", "_store", "store", "_registry"): + candidate = getattr(registry, attr, None) + if candidate and isinstance(candidate, dict): + metrics_data = candidate + break + + if metrics_data is None and isinstance(registry, dict): + metrics_data = registry + + if metrics_data: + header = f"Metrics ({len(metrics_data)}):" + if pattern: + header = f"Metrics matching '{pattern}':" + lines.append(header) + lines.append("-" * 40) + + for name, value in sorted(metrics_data.items()): + if pattern and pattern.lower() not in name.lower(): + continue + lines.append(f" {name}: {value}") + + return "\n".join(lines) if len(lines) > 2 else "No metrics found" + + return "Metrics registry found but no metrics available" diff --git a/src/moleculerpy_repl/commands/quit.py b/src/moleculerpy_repl/commands/quit.py new file mode 100644 index 0000000..310f0d6 --- /dev/null +++ b/src/moleculerpy_repl/commands/quit.py @@ -0,0 +1,28 @@ +"""Quit command — Stop broker and exit.""" + +from __future__ import annotations + +import sys +from typing import Any + +from ..parser import ParsedArgs +from .base import BaseCommand, CommandResult + + +class QuitCommand(BaseCommand): + """Stop the broker and exit the REPL.""" + + name = "quit" + description = "Stop broker and exit" + usage = "quit" + aliases = ["exit", "q"] + + async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult: + """Execute the quit command.""" + try: + if broker is not None and hasattr(broker, "stop"): + await broker.stop() + except Exception: + pass + + sys.exit(0)